From a4e657254f3db610495a3cf6aeb3cdf62452d879 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 30 Jun 2026 22:00:55 +0300 Subject: [PATCH 01/95] =?UTF-8?q?=E2=9C=A8=20Implement=20preview=20experie?= =?UTF-8?q?nce=20using=20qr=20code=20=E2=9C=A8=20Support=20test=20push=20n?= =?UTF-8?q?otifications=20=F0=9F=93=9D=20Update=20docs=20=E2=9C=85=20Add?= =?UTF-8?q?=20unit=20tests=20=F0=9F=9A=A7=20Minor=20enhancements=20for=20s?= =?UTF-8?q?ome=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserpilotSample.xcodeproj/project.pbxproj | 8 +- .../UserpilotSample/AppDelegate.swift | 39 +- .../ConfigurationsDialogViewController.swift | 9 +- .../Extensions/String+Exensions.swift | 36 -- .../Extensions/String+Int+Exensions.swift | 25 + .../Featues/Main/MainViewController.swift | 15 +- .../ScreenOneViewController.xcstrings | 74 +-- .../ScreenTwoViewController.xcstrings | 82 +--- .../UserpilotSample/Info.plist | 23 + .../Managers/Userpilot/UserpilotManager.swift | 10 +- .../UserpilotSample/SceneDelegate.swift | 14 +- .../Analytics/AnalyticsPublisher.swift | 4 +- .../Analytics/AutoPropertyDecorator.swift | 2 +- .../Userpilot/Analytics/Models/Event.swift | 2 +- .../Analytics/Models/EventType.swift | 2 +- ...erienceViewController+ViewExtensions.swift | 5 +- ...ceViewController+ViewModelExtensions.swift | 2 +- .../Content/Flows/ExperienceViewModel.swift | 23 +- .../SlideOutBottomSheetViewController.swift | 2 +- .../NPS/ContainerView/NPSContainerView.swift | 18 +- .../NPS/NPSBottomSheetViewController.swift | 19 + .../Content/NPS/NPSViewModel.swift | 9 +- .../SurveyListViewController+Keyboard.swift | 2 +- ...veyListViewController+ViewExtensions.swift | 4 +- ...stViewController+ViewModelExtensions.swift | 2 +- .../ListView/SurveyListViewController.swift | 2 + .../StepView/Base/SurveyContainerView.swift | 11 +- .../SurveyBottomSheetViewController.swift | 19 + .../Dialog/SurveyDialogViewController.swift | 19 + .../Content/Survey/SurveyViewModel.swift | 21 +- .../ThankYouBottomSheetViewController.swift | 4 +- .../Experiences/Events/SDKEvent.swift | 4 +- .../Survey/SurveyExperienceActionEvent.swift | 2 +- .../SurveyExperienceStepActionEvent.swift | 20 +- .../Experiences/ExperienceStateManager.swift | 372 +++++++++++++++ .../Experiences/ExperiencesPublisher.swift | 442 +++++++++++++---- .../Models/ExperienceContent.swift | 13 + .../Experiences/Models/NPSContent.swift | 2 +- .../Models/PreviewExperience.swift | 46 ++ .../Theme/Models/ThemeContent.swift | 2 +- .../Experiences/Theme/ThemeHandler.swift | 11 +- .../BottomSheetViewController.swift | 4 +- .../Containers/DialogViewController.swift | 2 +- .../Feedback/LikertView/UPLikertView.swift | 13 +- .../LikertView/Utils/UPLikertView+Views.swift | 2 +- .../Utils/ChoiceTableViewCell.swift | 2 + .../Utils/UPMultipleChoiceView+Views.swift | 2 +- .../Utils/UPOpenTextView+Views.swift | 2 +- .../SingleInputView/UPSingleInputView.swift | 2 + .../Utils/UPSingleInputView+Phone.swift | 2 +- .../Utils/UPSingleInputView+Views.swift | 20 +- .../Feedback/UPTitleDescriptionView.swift | 36 +- .../Flows/UPIconTextContainerView.swift | 2 +- .../Views/Widgets/Flows/UPIconTextView.swift | 13 +- .../Userpilot/Extensions/Int+Extensions.swift | 4 + .../Extensions/String+Extensions.swift | 3 + .../Extensions/UIApplication+Extensions.swift | 107 ++--- .../Userpilot/Extensions/URL+Extensions.swift | 16 +- Sources/Userpilot/Logging/Logging.swift | 6 +- .../UserpilotExperienceDelegate.swift | 83 ++-- .../Models/UserpilotNotification.swift | 4 + .../PushNotificationMonitor.swift | 36 +- .../Userpilot/Remote/RemoteSourceError.swift | 37 ++ .../Remote/SDKSettingsDetector.swift | 125 +---- .../Remote/UserpilotRemoteSource.swift | 297 ++++++++++++ .../Remote/Utils/DynamicCodingKeys.swift | 1 - Sources/Userpilot/Socket/SocketManager.swift | 39 +- Sources/Userpilot/Storage/Models/User.swift | 2 +- .../Userpilot.docc/URLSchemeConfiguring.md | 76 +++ Sources/Userpilot/Userpilot.swift | 29 +- .../Utilities/Helper/Constants.swift | 8 + .../Utilities/Link/DeepLinkHandler.swift | 161 +++++++ .../Userpilot/Utilities/Link/LinkOpener.swift | 101 ++++ .../Utilities/Threading/AtomicReference.swift | 124 +++++ .../ExperienceStateManagerTests.swift | 267 +++++++++++ .../ExperiencesPublisherTests.swift | 141 +++++- .../FoundationExtensionsTests.swift | 8 +- Tests/UserpilotTests/MockUserpilot.swift | 57 ++- .../PushNotificationDeferredTests.swift | 4 +- .../PushNotificationHandlingTests.swift | 4 +- .../Remote/SDKSettingsDetectorTests.swift | 171 ------- .../Remote/UserpilotRemoteSourceTests.swift | 444 ++++++++++++++++++ Tests/UserpilotTests/UserpilotTests.swift | 12 +- .../Utilities/Link/DeepLinkHandlerTests.swift | 252 ++++++++++ .../Utilities/Link/LinkOpenerTests.swift | 211 +++++++++ 85 files changed, 3539 insertions(+), 814 deletions(-) delete mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Exensions.swift create mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Int+Exensions.swift create mode 100644 Sources/Userpilot/Experiences/ExperienceStateManager.swift create mode 100644 Sources/Userpilot/Experiences/Models/PreviewExperience.swift create mode 100644 Sources/Userpilot/Remote/RemoteSourceError.swift create mode 100644 Sources/Userpilot/Remote/UserpilotRemoteSource.swift create mode 100644 Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md create mode 100644 Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift create mode 100644 Sources/Userpilot/Utilities/Link/LinkOpener.swift create mode 100644 Sources/Userpilot/Utilities/Threading/AtomicReference.swift create mode 100644 Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift delete mode 100644 Tests/UserpilotTests/Remote/SDKSettingsDetectorTests.swift create mode 100644 Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift create mode 100644 Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift create mode 100644 Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift diff --git a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj index 4dc996e5..a01993bd 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj +++ b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj @@ -32,9 +32,9 @@ AB6DBD3D2C68CE9D0009BB11 /* FlowRoutingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DBD3C2C68CE9D0009BB11 /* FlowRoutingManager.swift */; }; AB6DBD422C68D7C30009BB11 /* BaseNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DBD412C68D7C30009BB11 /* BaseNavigationController.swift */; }; AB6DBD452C68D8560009BB11 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DBD442C68D8560009BB11 /* Utils.swift */; }; - AB6DBD4B2C68DA470009BB11 /* String+Exensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DBD4A2C68DA470009BB11 /* String+Exensions.swift */; }; AB76616F2D1305AF0044BA57 /* Userpilot in Frameworks */ = {isa = PBXBuildFile; productRef = AB76616E2D1305AF0044BA57 /* Userpilot */; }; AB791F182D0B6638008E4523 /* Userpilot in Frameworks */ = {isa = PBXBuildFile; productRef = AB791F172D0B6638008E4523 /* Userpilot */; }; + AB8A08862FF27964004C16B3 /* String+Int+Exensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8A08852FF27961004C16B3 /* String+Int+Exensions.swift */; }; ABAC8E6A2CE9CF21005EEC9D /* SDKEventsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = ABAC8E692CE9CF21005EEC9D /* SDKEventsViewController.xib */; }; ABAC8E6E2CE9E9D0005EEC9D /* SDKEventTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = ABAC8E6D2CE9E9D0005EEC9D /* SDKEventTableViewCell.xib */; }; ABACA85B2C96D5AF006C9B77 /* PropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABACA85A2C96D5AF006C9B77 /* PropertyView.swift */; }; @@ -114,7 +114,6 @@ AB6DBD3C2C68CE9D0009BB11 /* FlowRoutingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowRoutingManager.swift; sourceTree = ""; }; AB6DBD412C68D7C30009BB11 /* BaseNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseNavigationController.swift; sourceTree = ""; }; AB6DBD442C68D8560009BB11 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; - AB6DBD4A2C68DA470009BB11 /* String+Exensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Exensions.swift"; sourceTree = ""; }; AB8616182F99E5F5009E2C79 /* IQKeyboardCore.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = IQKeyboardCore.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; AB8616192F99E5F5009E2C79 /* IQKeyboardCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = IQKeyboardCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AB86161A2F99E5F5009E2C79 /* IQKeyboardManagerSwift.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = IQKeyboardManagerSwift.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -131,6 +130,7 @@ AB8616252F99E5F5009E2C79 /* IQTextInputViewNotification.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = IQTextInputViewNotification.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AB8616262F99E5F5009E2C79 /* IQTextView.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = IQTextView.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; AB8616272F99E5F5009E2C79 /* IQTextView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = IQTextView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AB8A08852FF27961004C16B3 /* String+Int+Exensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Int+Exensions.swift"; sourceTree = ""; }; ABAC8E682CE9CF21005EEC9D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/SDKEventsViewController.xib; sourceTree = ""; }; ABAC8E6B2CE9CF21005EEC9D /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/SDKEventsViewController.xcstrings; sourceTree = ""; }; ABAC8E6C2CE9E9D0005EEC9D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/SDKEventTableViewCell.xib; sourceTree = ""; }; @@ -312,7 +312,7 @@ AB6DBD492C68DA370009BB11 /* Extensions */ = { isa = PBXGroup; children = ( - AB6DBD4A2C68DA470009BB11 /* String+Exensions.swift */, + AB8A08852FF27961004C16B3 /* String+Int+Exensions.swift */, ABFB5FE72C9175030054E142 /* UITableReusableView+Extension.swift */, ABED3F612C98051500004D1C /* UIStackView+Extensions.swift */, ); @@ -710,7 +710,6 @@ ABCDBF402CAEAB970083DF1D /* DeepLinkViewController.swift in Sources */, ABFB5FD82C916E500054E142 /* EventsViewController.swift in Sources */, ABACA85B2C96D5AF006C9B77 /* PropertyView.swift in Sources */, - AB6DBD4B2C68DA470009BB11 /* String+Exensions.swift in Sources */, ABED3F5E2C9803D000004D1C /* IdentifyViewController+UserProperties.swift in Sources */, AB6ABAA62CE88B9300FB72A8 /* SDKEventsViewController.swift in Sources */, ABED3F602C9803DE00004D1C /* IdentifyViewController+CompanyProperties.swift in Sources */, @@ -726,6 +725,7 @@ ABFB5FDE2C9171600054E142 /* EventTableViewCell.swift in Sources */, ABED3F652C9831BD00004D1C /* MainViewController+TableView.swift in Sources */, ABED3F622C98051500004D1C /* UIStackView+Extensions.swift in Sources */, + AB8A08862FF27964004C16B3 /* String+Int+Exensions.swift in Sources */, C5C897BB2C5CFFFA0083928E /* SceneDelegate.swift in Sources */, ABCD47302F793CB000D71EA8 /* ScreenAPIContainerChildViewController.swift in Sources */, AB585F662CD79D8C00E2A66D /* StorageManager.swift in Sources */, diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/AppDelegate.swift b/Sample/UserpilotSwiftSample/UserpilotSample/AppDelegate.swift index 9b05f424..fdc30755 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/AppDelegate.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/AppDelegate.swift @@ -11,33 +11,56 @@ import Userpilot @main class AppDelegate: UIResponder, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { // Override point for customization after application launch. UserpilotManager.shared.initialize() - // setupPush(application: application) + // Automatically configure for push notifications by Userpilot SDK Userpilot.enableAutomaticPushConfig() + // setupPush(application: application) return true } // MARK: UISceneSession Lifecycle - func application(_ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions) -> UISceneConfiguration { + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application(_ application: UIApplication, - didDiscardSceneSessions sceneSessions: Set) { + func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) { // Called when the user discards a scene session. // If any sessions were discarded while the application // was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } +// func application( +// _ app: UIApplication, +// open url: URL, +// options: [UIApplication.OpenURLOptionsKey: Any] = [:] +// ) -> Bool { +// // Handle Userpilot deep links +// let handledByUserpilot = UserpilotManager.shared.didHandleURL(url) +// +// // Handle your own deep links if Userpilot didn't handle +// if !handledByUserpilot { +// // Your handler logic +// } +// +// return true +// } + } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift index 282d179a..43458d16 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift @@ -47,21 +47,22 @@ class ConfigurationsDialogViewController: UIViewController { func excuteDoneButton() { if !self.isViewLoaded { self.executeDoneButtonHandler() + self.doneButtonHandler?() return } + executeDoneButtonHandler() + self.dismiss(animated: true, completion: { - self.executeDoneButtonHandler() + self.doneButtonHandler?() }) } func executeDoneButtonHandler() { if self.doneButtonHandler != nil, - let appToken = textFieldAppToken.text, !appToken.isEmpty { + let appToken = textFieldAppToken.text, !appToken.isEmpty { StorageManager.shared.set(value: appToken, forKey: StorageManager.Keys.appToken) - self.doneButtonHandler?() } - dismiss(animated: true) } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Exensions.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Exensions.swift deleted file mode 100644 index 4ff0005a..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Exensions.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// String+Exensions.swift -// UserpilotSample -// -// Created by Motasem Hamed on 11/08/2024. -// -// Extensions include: -// - `isNilOrEmpty`: Checks if the string is nil or empty. -// - `isValidInput`: Checks if the string has a minimum length of 6 characters, indicating valid input. -// - `isValidPassword`: Checks if the string has a minimum length of 4 characters, indicating a valid password. -// - -import Foundation -import UIKit - -internal extension Optional where Wrapped == String { - - var isNilOrEmpty: Bool { - return self?.isEmpty ?? true - } - - var isValidInput: Bool { - return self?.count ?? 0 >= 6 - } - - var isValidPassword: Bool { - return self?.count ?? 0 >= 4 - } - -} - -internal extension String { - var localized: String { - return NSLocalizedString(self, comment: "") - } -} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Int+Exensions.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Int+Exensions.swift new file mode 100644 index 00000000..c4a09cea --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Extensions/String+Int+Exensions.swift @@ -0,0 +1,25 @@ +// +// String+Exensions.swift +// UserpilotSample +// +// Created by Motasem Hamed on 11/08/2024. +// + +import Foundation +import UIKit + +internal extension String { + var localized: String { + return NSLocalizedString(self, comment: "") + } +} + +internal extension Int { + /// Loops from 0 to (self - 1) and executes the closure with the current index + func loop(_ action: (Int) -> Void) { + guard self > 0 else { return } + for index in 0.. + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + userpilot-example + + + + CFBundleTypeRole + Editor + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + userpilot-USERPILOT_TOKEN + + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift index f18d499b..5b9810bf 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift @@ -109,7 +109,15 @@ class UserpilotManager { ) -> Bool { return userpilot?.didReceiveNotification(response: response, completionHandler: completionHandler) ?? false } - + + public func didHandleURL(_ url: URL) -> Bool { + return userpilot?.didHandleURL(url) ?? false + } + + public func filterAndHandle(_ URLContexts: Set) -> Set { + return userpilot?.filterAndHandle(URLContexts) ?? Set() + } + } // MARK: - UserpilotNavigationDelegate diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/SceneDelegate.swift b/Sample/UserpilotSwiftSample/UserpilotSample/SceneDelegate.swift index 5c5ee808..10a39960 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/SceneDelegate.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/SceneDelegate.swift @@ -16,7 +16,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach // the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically + // If using a storyboard, the `window` property will automatically // be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new // (see `application:configurationForConnectingSceneSession` instead). @@ -30,6 +30,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window.makeKeyAndVisible() } + // Handle Userpilot deep links. + let unhandledURLContexts = UserpilotManager.shared.filterAndHandle(connectionOptions.urlContexts) + // Handle app-specific deep links. + print(unhandledURLContexts) + guard (scene as? UIWindowScene) != nil else { return } } @@ -63,4 +68,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // to restore the scene back to its current state. } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + // Handle Userpilot deep links. + let unhandledURLContexts = UserpilotManager.shared.filterAndHandle(URLContexts) + // Handle app-specific deep links. + print(unhandledURLContexts) + } + } diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index cf2a99d6..d2bfb92d 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -833,7 +833,7 @@ private extension AnalyticsPublisher { } #if DEBUG -internal extension AnalyticsPublisher { +extension AnalyticsPublisher { func mockGetCachedEvent() -> Event? { return cachedEvent } @@ -962,7 +962,7 @@ extension AnalyticsPublisher { // MARK: - Event Broadcasting -internal extension AnalyticsPublisher { +extension AnalyticsPublisher { /** * Broadcasts events to analytics listeners for external consumption. diff --git a/Sources/Userpilot/Analytics/AutoPropertyDecorator.swift b/Sources/Userpilot/Analytics/AutoPropertyDecorator.swift index f78cd181..efa29a5a 100644 --- a/Sources/Userpilot/Analytics/AutoPropertyDecorator.swift +++ b/Sources/Userpilot/Analytics/AutoPropertyDecorator.swift @@ -85,7 +85,7 @@ extension AutoPropertyDecorator: AutoPropertyDecoratoring { // MARK: - Properties name -internal extension AutoPropertyDecorator { +extension AutoPropertyDecorator { // Static constants static let autoPropertiesKey = "autoProperties" diff --git a/Sources/Userpilot/Analytics/Models/Event.swift b/Sources/Userpilot/Analytics/Models/Event.swift index 52987250..b5764d78 100644 --- a/Sources/Userpilot/Analytics/Models/Event.swift +++ b/Sources/Userpilot/Analytics/Models/Event.swift @@ -81,7 +81,7 @@ internal struct Event { } } -internal extension Event { +extension Event { func toUser() -> User { return User(userId: userId ?? "", properties: properties ?? [:], diff --git a/Sources/Userpilot/Analytics/Models/EventType.swift b/Sources/Userpilot/Analytics/Models/EventType.swift index 3d374c3f..fb04e247 100644 --- a/Sources/Userpilot/Analytics/Models/EventType.swift +++ b/Sources/Userpilot/Analytics/Models/EventType.swift @@ -140,7 +140,7 @@ internal enum EventType: Equatable { } } -internal extension EventType { +extension EventType { // Static constants static let identifyEvent = "user_identify" diff --git a/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewExtensions.swift b/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewExtensions.swift index eb48407a..534f4b7a 100644 --- a/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewExtensions.swift +++ b/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewExtensions.swift @@ -15,7 +15,7 @@ import Foundation import UIKit -internal extension CarouselExperienceViewController { +extension CarouselExperienceViewController { /// Configures general views constraints. func setupViews() { @@ -33,6 +33,9 @@ internal extension CarouselExperienceViewController { if experienceViewModel.isRTL { buttonDismissContainerView.semanticContentAttribute = .forceRightToLeft collectionView.semanticContentAttribute = .forceRightToLeft + } else { + buttonDismissContainerView.semanticContentAttribute = .forceLeftToRight + collectionView.semanticContentAttribute = .forceLeftToRight } } diff --git a/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewModelExtensions.swift b/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewModelExtensions.swift index 972037cf..e23702be 100644 --- a/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewModelExtensions.swift +++ b/Sources/Userpilot/Experiences/Content/Flows/Carousel/Extensions/CarouselExperienceViewController+ViewModelExtensions.swift @@ -15,7 +15,7 @@ import Foundation import UIKit -internal extension CarouselExperienceViewController { +extension CarouselExperienceViewController { /// Binds the view model to update the UI based on the view model state. /// Sets up closures for data binding and dismiss actions. diff --git a/Sources/Userpilot/Experiences/Content/Flows/ExperienceViewModel.swift b/Sources/Userpilot/Experiences/Content/Flows/ExperienceViewModel.swift index 8797f4e2..6e3df3a1 100644 --- a/Sources/Userpilot/Experiences/Content/Flows/ExperienceViewModel.swift +++ b/Sources/Userpilot/Experiences/Content/Flows/ExperienceViewModel.swift @@ -138,7 +138,7 @@ internal class ExperienceViewModel { experienceId: NSNumber(value: flowContent.id), experienceState: .started ) - logExperience(state: "Started", experienceId: flowContent.id) + logExperience(state: UserpilotExperienceState.started.rawValueString, experienceId: flowContent.id) userpilot?.experienceDelegate?.onExperienceStepStateChanged( experienceType: .flow, @@ -149,7 +149,7 @@ internal class ExperienceViewModel { totalSteps: NSNumber(value: flowContent.steps.count) ) logStep( - state: "Started", + state: UserpilotExperienceState.started.rawValueString, experienceId: flowContent.id, stepId: step.id, step: 1, @@ -159,9 +159,7 @@ internal class ExperienceViewModel { let eventExperienceSeen = ExperienceFlowSeenEvent(flowId: flowContent.id) experiencesPublisher.publishInternalSDKEvent(eventExperienceSeen) - let eventStepSeen = ExperienceFlowStepSeenEvent( - flowId: flowContent.id, - stepId: step.id) + let eventStepSeen = ExperienceFlowStepSeenEvent(flowId: flowContent.id, stepId: step.id) experiencesPublisher.publishInternalSDKEvent(eventStepSeen) } @@ -183,7 +181,7 @@ internal class ExperienceViewModel { totalSteps: NSNumber(value: flowContent.steps.count) ) logStep( - state: "Completed", + state: UserpilotExperienceState.completed.rawValueString, experienceId: flowContent.id, stepId: step.id, step: flowContent.steps.count, @@ -195,7 +193,7 @@ internal class ExperienceViewModel { experienceId: NSNumber(value: flowContent.id), experienceState: .completed ) - logExperience(state: "Completed", experienceId: flowContent.id) + logExperience(state: UserpilotExperienceState.completed.rawValueString, experienceId: flowContent.id) let hasDeepLink = !(step.buttonAction?.deepLink?.isEmpty ?? true) @@ -235,7 +233,7 @@ internal class ExperienceViewModel { totalSteps: NSNumber(value: flowContent.steps.count) ) logStep( - state: "Completed", + state: UserpilotExperienceState.completed.rawValueString, experienceId: flowContent.id, stepId: currentStep.id, step: step, @@ -251,7 +249,7 @@ internal class ExperienceViewModel { totalSteps: NSNumber(value: flowContent.steps.count) ) logStep( - state: "Started", + state: UserpilotExperienceState.started.rawValueString, experienceId: flowContent.id, stepId: currentStep.id, step: step + 1, @@ -289,7 +287,7 @@ internal class ExperienceViewModel { totalSteps: NSNumber(value: flowContent.steps.count) ) logStep( - state: "Dismissed", + state: UserpilotExperienceState.dismissed.rawValueString, experienceId: flowContent.id, stepId: step.id, step: lastStep + 1, @@ -301,7 +299,7 @@ internal class ExperienceViewModel { experienceId: NSNumber(value: flowContent.id), experienceState: .dismissed ) - logExperience(state: "Dismissed", experienceId: flowContent.id) + logExperience(state: UserpilotExperienceState.dismissed.rawValueString, experienceId: flowContent.id) let eventExperienceDismissed = ExperienceFlowDismissedEvent( flowId: flowContent.id, @@ -335,7 +333,8 @@ internal class ExperienceViewModel { experienceId: Int ) { logger.info( - "🌠 Userpilot experience -> type: Flow, experienceId: %{public}@, state: %{public}@", + "🌠 Userpilot experience -> type: %{public}@, experienceId: %{public}@, state: %{public}@", + UserpilotExperienceType.flow.rawValueString, String(experienceId), state ) diff --git a/Sources/Userpilot/Experiences/Content/Flows/SlideOut/BottomSheet/SlideOutBottomSheetViewController.swift b/Sources/Userpilot/Experiences/Content/Flows/SlideOut/BottomSheet/SlideOutBottomSheetViewController.swift index 215cb1b2..2b1bb110 100644 --- a/Sources/Userpilot/Experiences/Content/Flows/SlideOut/BottomSheet/SlideOutBottomSheetViewController.swift +++ b/Sources/Userpilot/Experiences/Content/Flows/SlideOut/BottomSheet/SlideOutBottomSheetViewController.swift @@ -78,7 +78,7 @@ internal class SlideOutBottomSheetViewController: BottomSheetViewController { } // MARK: - ViewModel Binding -internal extension SlideOutBottomSheetViewController { +extension SlideOutBottomSheetViewController { /// Binds the view model's data and updates the `slideOutContainerView` accordingly. /// This method is responsible for responding to any changes in the view model's state and ensuring the diff --git a/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift b/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift index a4e4c2fe..f4009966 100644 --- a/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift +++ b/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift @@ -17,7 +17,7 @@ internal class NPSContainerView: UIView { let view = UIView() view.backgroundColor = .clear view.translatesAutoresizingMaskIntoConstraints = false - view.heightAnchor.constraint(equalToConstant: 50).isActive = true + view.heightAnchor.constraint(equalToConstant: 45).isActive = true return view }() @@ -294,6 +294,8 @@ internal class NPSContainerView: UIView { if isRTL { UIView.appearance().semanticContentAttribute = .forceRightToLeft + } else { + UIView.appearance().semanticContentAttribute = .forceLeftToRight } } @@ -338,16 +340,22 @@ internal class NPSContainerView: UIView { // Remove old views before adding the new one stepSectionsStackView.arrangedSubviews.forEach { - if $0 is UPExperienceView { + if $0 !== imageContainerView { $0.removeFromSuperview() } } stepSectionsStackView.addArrangedSubview(newView) - // Ensure layout updates before calculating the height + // Force layout update to ensure constraints are applied + stepSectionsStackView.setNeedsLayout() stepSectionsStackView.layoutIfNeeded() + // Additional layout pass for RTL content to ensure proper text wrapping + if isRTL { + newView.setNeedsLayout() + newView.layoutIfNeeded() + } // Calculate the new height required for contentContainerView let newHeight = stepSectionsStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height @@ -598,8 +606,8 @@ extension NPSContainerView { if let completedData = getThankYouMessage() { stepsProgressView.isHidden = true barStepsProgressView.isHidden = true - if (!completedData.button.enabled) { - footerButtonsStackView.isHidden = true + if !completedData.button.enabled { + footerButtonsContianer.isHidden = true return } footerButtonsStackView.clearViews() diff --git a/Sources/Userpilot/Experiences/Content/NPS/NPSBottomSheetViewController.swift b/Sources/Userpilot/Experiences/Content/NPS/NPSBottomSheetViewController.swift index 2c74d9f6..2af7761e 100644 --- a/Sources/Userpilot/Experiences/Content/NPS/NPSBottomSheetViewController.swift +++ b/Sources/Userpilot/Experiences/Content/NPS/NPSBottomSheetViewController.swift @@ -16,6 +16,7 @@ internal class NPSBottomSheetViewController: BottomSheetViewController { npsContainerView.translatesAutoresizingMaskIntoConstraints = false return npsContainerView }() + private var appSemanticContentAttribute: UIUserInterfaceLayoutDirection? // MARK: - Properties @@ -48,6 +49,24 @@ internal class NPSBottomSheetViewController: BottomSheetViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) npsViewModel.onExperienceSeen() + appSemanticContentAttribute = UIView.userInterfaceLayoutDirection(for: view.semanticContentAttribute) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if npsViewModel.isRTL { + UIView.appearance().semanticContentAttribute = .forceRightToLeft + } else { + UIView.appearance().semanticContentAttribute = .forceLeftToRight + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if let appSemanticContentAttribute { + UIView.appearance().semanticContentAttribute = appSemanticContentAttribute == .leftToRight + ? .forceLeftToRight : .forceRightToLeft + } } deinit { diff --git a/Sources/Userpilot/Experiences/Content/NPS/NPSViewModel.swift b/Sources/Userpilot/Experiences/Content/NPS/NPSViewModel.swift index 258084c5..0f548a08 100644 --- a/Sources/Userpilot/Experiences/Content/NPS/NPSViewModel.swift +++ b/Sources/Userpilot/Experiences/Content/NPS/NPSViewModel.swift @@ -87,7 +87,7 @@ internal class NPSViewModel { experienceId: nil, experienceState: .started ) - logExperience(state: "Started") + logExperience(state: UserpilotExperienceState.started.rawValueString) let eventExperienceSeen = ExperienceNPSSeenEvent() experiencesPublisher.publishInternalSDKEvent(eventExperienceSeen) @@ -103,7 +103,7 @@ internal class NPSViewModel { experienceId: nil, experienceState: .dismissed ) - logExperience(state: "Dismissed") + logExperience(state: UserpilotExperienceState.dismissed.rawValueString) let eventExperienceDismissed = ExperienceNPSDismissedEvent() experiencesPublisher.publishInternalSDKEvent(eventExperienceDismissed) @@ -116,7 +116,7 @@ internal class NPSViewModel { experienceId: nil, experienceState: .submitted ) - logExperience(state: "Submitted") + logExperience(state: UserpilotExperienceState.submitted.rawValueString) let eventExperienceSubmitted = ExperienceNPSSubmittedEvent( score: userAnswer - 1, @@ -144,7 +144,8 @@ internal class NPSViewModel { private func logExperience(state: String) { logger.info( - "🌠 Userpilot experience -> type: NPS, state: %{public}@", + "🌠 Userpilot experience -> type: %{public}@, state: %{public}@", + UserpilotExperienceType.nps.rawValueString, state ) } diff --git a/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+Keyboard.swift b/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+Keyboard.swift index 5e6af211..fa09bcc2 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+Keyboard.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+Keyboard.swift @@ -67,7 +67,7 @@ extension SurveyListViewController { // Ensure only scroll if the field is actually hidden by the keyboard if offsetY > 0 { - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: { + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut, animations: { // Adjust content inset and scroll in the same animation block self.scrollView.contentInset.bottom = adjustmentHeight self.scrollView.verticalScrollIndicatorInsets.bottom = adjustmentHeight diff --git a/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewExtensions.swift b/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewExtensions.swift index 4abf2da1..8c82f1b4 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewExtensions.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewExtensions.swift @@ -14,7 +14,7 @@ import Foundation import UIKit -internal extension SurveyListViewController { +extension SurveyListViewController { /// Configures general views constraints. func setupViews() { @@ -95,7 +95,7 @@ internal extension SurveyListViewController { // MARK: - Bind custom views to the survey list view -internal extension SurveyListViewController { +extension SurveyListViewController { // swiftlint:disable:next function_body_length func bindSurveyViews() { guard diff --git a/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewModelExtensions.swift b/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewModelExtensions.swift index a803ab71..3108b83d 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewModelExtensions.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/ListView/Extensions/SurveyListViewController+ViewModelExtensions.swift @@ -14,7 +14,7 @@ import UIKit -internal extension SurveyListViewController { +extension SurveyListViewController { /// Binds the view model to update the UI based on the view model state. /// Sets up closures for data binding and dismiss actions. diff --git a/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift b/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift index 638a38c0..2aa04e61 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift @@ -73,6 +73,8 @@ internal class SurveyListViewController: UIViewController { super.viewWillAppear(animated) if surveyViewModel.isRTL { UIView.appearance().semanticContentAttribute = .forceRightToLeft + } else { + UIView.appearance().semanticContentAttribute = .forceLeftToRight } } diff --git a/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift b/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift index 4765d11c..16b397dc 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift @@ -239,6 +239,8 @@ internal class SurveyContainerView: UIView { if isRTL { UIView.appearance().semanticContentAttribute = .forceRightToLeft + } else { + UIView.appearance().semanticContentAttribute = .forceLeftToRight } } @@ -406,9 +408,16 @@ internal class SurveyContainerView: UIView { stepSectionsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } stepSectionsStackView.addArrangedSubview(newView) - // Ensure layout updates before calculating the height + // Force layout update to ensure constraints are applied + stepSectionsStackView.setNeedsLayout() stepSectionsStackView.layoutIfNeeded() + // Additional layout pass for RTL content to ensure proper text wrapping + if isRTL { + newView.setNeedsLayout() + newView.layoutIfNeeded() + } + // Calculate the new height required for contentContainerView var newHeight = stepSectionsStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height if newView.isKind(of: UPLikertView.self) { diff --git a/Sources/Userpilot/Experiences/Content/Survey/StepView/BottomSheet/SurveyBottomSheetViewController.swift b/Sources/Userpilot/Experiences/Content/Survey/StepView/BottomSheet/SurveyBottomSheetViewController.swift index 42798e2f..725882d9 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/StepView/BottomSheet/SurveyBottomSheetViewController.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/StepView/BottomSheet/SurveyBottomSheetViewController.swift @@ -24,6 +24,7 @@ internal class SurveyBottomSheetViewController: BottomSheetViewController { surveyContainerView.translatesAutoresizingMaskIntoConstraints = false return surveyContainerView }() + private var appSemanticContentAttribute: UIUserInterfaceLayoutDirection? // MARK: - Properties @@ -51,6 +52,7 @@ internal class SurveyBottomSheetViewController: BottomSheetViewController { setContent(content: surveyContainerView, withoutMargin: true) registerKeyboardNotifications() ExternalKeyboardManagerCompat.disableIQKeyboardManager(for: [SurveyBottomSheetViewController.self]) + appSemanticContentAttribute = UIView.userInterfaceLayoutDirection(for: view.semanticContentAttribute) } override func viewDidAppear(_ animated: Bool) { @@ -58,6 +60,23 @@ internal class SurveyBottomSheetViewController: BottomSheetViewController { surveyViewModel.onExperienceSeen() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if surveyViewModel.isRTL { + UIView.appearance().semanticContentAttribute = .forceRightToLeft + } else { + UIView.appearance().semanticContentAttribute = .forceLeftToRight + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if let appSemanticContentAttribute { + UIView.appearance().semanticContentAttribute = appSemanticContentAttribute == .leftToRight + ? .forceLeftToRight : .forceRightToLeft + } + } + deinit { removeKeyboardNotifications() } diff --git a/Sources/Userpilot/Experiences/Content/Survey/StepView/Dialog/SurveyDialogViewController.swift b/Sources/Userpilot/Experiences/Content/Survey/StepView/Dialog/SurveyDialogViewController.swift index c0cb31e3..c4f74195 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/StepView/Dialog/SurveyDialogViewController.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/StepView/Dialog/SurveyDialogViewController.swift @@ -25,6 +25,7 @@ internal class SurveyDialogViewController: DialogViewController { surveyContainerView.translatesAutoresizingMaskIntoConstraints = false return surveyContainerView }() + private var appSemanticContentAttribute: UIUserInterfaceLayoutDirection? // MARK: - Properties @@ -50,6 +51,7 @@ internal class SurveyDialogViewController: DialogViewController { super.viewDidLoad() bindViewModel() setContent(content: surveyContainerView, withMargin: CGFloat(-40)) + appSemanticContentAttribute = UIView.userInterfaceLayoutDirection(for: view.semanticContentAttribute) } override func viewDidAppear(_ animated: Bool) { @@ -57,6 +59,23 @@ internal class SurveyDialogViewController: DialogViewController { surveyViewModel.onExperienceSeen() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if surveyViewModel.isRTL { + UIView.appearance().semanticContentAttribute = .forceRightToLeft + } else { + UIView.appearance().semanticContentAttribute = .forceLeftToRight + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if let appSemanticContentAttribute { + UIView.appearance().semanticContentAttribute = appSemanticContentAttribute == .leftToRight + ? .forceLeftToRight : .forceRightToLeft + } + } + /// Handle screen rotation override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) diff --git a/Sources/Userpilot/Experiences/Content/Survey/SurveyViewModel.swift b/Sources/Userpilot/Experiences/Content/Survey/SurveyViewModel.swift index cbf1d3e9..519583bc 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/SurveyViewModel.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/SurveyViewModel.swift @@ -150,7 +150,7 @@ internal class SurveyViewModel { experienceId: NSNumber(value: surveyContent.id), experienceState: .started ) - logExperience(state: "Started", experienceId: surveyContent.id) + logExperience(state: UserpilotExperienceState.started.rawValueString, experienceId: surveyContent.id) let eventExperienceSeen = ExperienceSurveySeenEvent(surveyId: surveyContent.id, submissionId: submissionId) experiencesPublisher.publishInternalSDKEvent(eventExperienceSeen) @@ -175,7 +175,7 @@ internal class SurveyViewModel { experienceId: NSNumber(value: surveyContent.id), experienceState: .completed ) - logExperience(state: "Completed", experienceId: surveyContent.id) + logExperience(state: UserpilotExperienceState.completed.rawValueString, experienceId: surveyContent.id) let eventExperienceSeen = ExperienceSurveyCompletedEvent( surveyId: surveyContent.id, @@ -199,7 +199,7 @@ internal class SurveyViewModel { experienceId: NSNumber(value: surveyContent.id), experienceState: .completed ) - logExperience(state: "Completed", experienceId: surveyContent.id) + logExperience(state: UserpilotExperienceState.completed.rawValueString, experienceId: surveyContent.id) let eventExperienceSeen = ExperienceSurveyCompletedEvent( surveyId: surveyContent.id, @@ -213,7 +213,7 @@ internal class SurveyViewModel { experienceId: NSNumber(value: surveyContent.id), experienceState: .dismissed ) - logExperience(state: "Dismissed", experienceId: surveyContent.id) + logExperience(state: UserpilotExperienceState.dismissed.rawValueString, experienceId: surveyContent.id) let eventExperienceDismissed = ExperienceSurveyDismissedEvent( surveyId: surveyContent.id, @@ -236,7 +236,7 @@ internal class SurveyViewModel { totalSteps: nil ) logStep( - state: "Started", + state: UserpilotExperienceState.started.rawValueString, experienceId: surveyContent.id, stepId: surveyStep.id ) @@ -260,7 +260,7 @@ internal class SurveyViewModel { experienceId: NSNumber(value: surveyContent.id), experienceState: .submitted ) - logExperience(state: "Submitted", experienceId: surveyContent.id) + logExperience(state: UserpilotExperienceState.submitted.rawValueString, experienceId: surveyContent.id) let eventContentSubmitted = ExperienceSurveySubmittedEvent( surveyId: surveyContent.id, @@ -282,7 +282,7 @@ internal class SurveyViewModel { totalSteps: nil ) logStep( - state: "Submitted", + state: UserpilotExperienceState.submitted.rawValueString, experienceId: surveyContent.id, stepId: surveyStep.id ) @@ -309,7 +309,7 @@ internal class SurveyViewModel { totalSteps: nil ) logStep( - state: "Skipped", + state: UserpilotExperienceState.skipped.rawValueString, experienceId: surveyContent.id, stepId: surveyStep.id ) @@ -339,7 +339,7 @@ internal class SurveyViewModel { _ answer: Any?, _ answerPayload: Payload ) { - guard let surveyContent, let surveyStep = getCurrentStepSurveyContent() else { return } + guard let surveyContent, let surveyStep = getCurrentStepSurveyContent() else { return } // We are on the last step, close the survey if isLastStep(), surveyStep.type == .completed { onDeepLinkTriggered() @@ -407,7 +407,8 @@ internal class SurveyViewModel { stepId: Int ) { logger.info( - "🌠 Userpilot experience step -> type: Survey, experienceId: %{public}@, state: %{public}@, stepId: %{public}@", + "🌠 Userpilot experience step -> type: %{public}@, experienceId: %{public}@, state: %{public}@, stepId: %{public}@", + UserpilotExperienceType.survey.rawValueString, String(experienceId), state, String(stepId) diff --git a/Sources/Userpilot/Experiences/Content/Survey/ThankYou/ThankYouBottomSheetViewController.swift b/Sources/Userpilot/Experiences/Content/Survey/ThankYou/ThankYouBottomSheetViewController.swift index 0926ed2f..50e28949 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/ThankYou/ThankYouBottomSheetViewController.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/ThankYou/ThankYouBottomSheetViewController.swift @@ -106,7 +106,7 @@ internal class ThankYouBottomSheetViewController: BottomSheetViewController { } // MARK: - ViewModel Binding -internal extension ThankYouBottomSheetViewController { +extension ThankYouBottomSheetViewController { /// Binds the view model's data and updates the `slideOutContainerView` accordingly. /// This method is responsible for responding to any changes in the view model's state and ensuring the @@ -136,7 +136,7 @@ internal extension ThankYouBottomSheetViewController { buttonDismiss.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - buttonDismiss.topAnchor.constraint(equalTo: buttonDismissContainerView.topAnchor), + buttonDismiss.bottomAnchor.constraint(equalTo: buttonDismissContainerView.bottomAnchor), buttonDismiss.trailingAnchor.constraint( equalTo: buttonDismissContainerView.trailingAnchor, constant: ThemeHandler.DefaultValues.dismissButtonMargin), diff --git a/Sources/Userpilot/Experiences/Events/SDKEvent.swift b/Sources/Userpilot/Experiences/Events/SDKEvent.swift index a6dffa4b..d6db28d4 100644 --- a/Sources/Userpilot/Experiences/Events/SDKEvent.swift +++ b/Sources/Userpilot/Experiences/Events/SDKEvent.swift @@ -22,7 +22,7 @@ internal protocol SDKEvent { var hasDeepLink: Bool { get } } -internal extension SDKEvent { +extension SDKEvent { var hasDeepLink: Bool { return false @@ -43,7 +43,7 @@ internal extension SDKEvent { } /// Used to pass seen content for cached ScreenViewEntity -internal extension SDKEvent { +extension SDKEvent { func getContentType() -> ExperienceType { if self.eventName == SDKEventsName.flowExperienceDismissed.rawValue || diff --git a/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceActionEvent.swift b/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceActionEvent.swift index 0131e85f..d360a78c 100644 --- a/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceActionEvent.swift +++ b/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceActionEvent.swift @@ -21,7 +21,7 @@ import Foundation internal class SurveyExperienceActionEvent: SDKEvent { /// Experience Id let surveyId: Int - // Submission Id + /// Submission Id let submissionId: Int64 /// Has deeplink to open it let hasDeepLinkContent: Bool diff --git a/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceStepActionEvent.swift b/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceStepActionEvent.swift index 52e06835..8487dac1 100644 --- a/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceStepActionEvent.swift +++ b/Sources/Userpilot/Experiences/Events/Survey/SurveyExperienceStepActionEvent.swift @@ -19,16 +19,6 @@ internal class SurveyExperienceStepActionEvent: SurveyExperienceActionEvent { let moduleId: Int let type: String - /// Creates a dictionary representation of the event data, including the step Id. - /// - /// - Returns: A dictionary containing the action type, mobile content Id, application token, user Id, and step Id. - override func toMap() -> [String: Any] { - var map = super.toMap() - map["module_id"] = moduleId - map["type"] = type - return map - } - init( surveyId: Int, submissionId: Int64, @@ -39,6 +29,16 @@ internal class SurveyExperienceStepActionEvent: SurveyExperienceActionEvent { self.type = type super.init(surveyId: surveyId, submissionId: submissionId) } + + /// Creates a dictionary representation of the event data, including the step Id. + /// + /// - Returns: A dictionary containing the action type, mobile content Id, application token, user Id, and step Id. + override func toMap() -> [String: Any] { + var map = super.toMap() + map["module_id"] = moduleId + map["type"] = type + return map + } } // MARK: - FlowExperienceStepActionEvent Subclasses diff --git a/Sources/Userpilot/Experiences/ExperienceStateManager.swift b/Sources/Userpilot/Experiences/ExperienceStateManager.swift new file mode 100644 index 00000000..b380358c --- /dev/null +++ b/Sources/Userpilot/Experiences/ExperienceStateManager.swift @@ -0,0 +1,372 @@ +// +// ExperienceStateManager.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 23/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Manages experience flow state, transitions, and active component lifecycle. +// + +import Foundation + +// MARK: - TriggerType + +/// Type of trigger that initiated an experience. +internal enum TriggerType { + /// Via triggerExperience() API. + case manual + /// From screen/track events. + case automatic + /// Preview mode (QR/deep link). + case preview +} + +// MARK: - ExperienceFlowState + +/// Represents the current state of the experience flow in ExperiencesPublisher. +internal enum ExperienceFlowState { + /// No experience is being processed or displayed. + case idle + + /// Experience triggered manually via triggerExperience() API. Bypasses screen validation. + case pendingManual(experienceId: String?) + + /// Experience triggered automatically from screen/track events. Requires screen validation. + case pendingAutomatic(experience: ExperienceContent?) + + /// Experience triggered in preview mode (QR code/deep link). Bypasses analytics. + case pendingPreview + + /// Waiting for display delay to complete before showing experience. + case waitingDelay(triggerType: TriggerType) + + /// Experience is currently displayed to the user. + case active(triggerType: TriggerType, content: ExperienceContent) + + /// Thank you message is displayed after survey completion. + case showingThankYou + + /// Manual experience cached because another experience is in progress. + case cachedPendingManual(experienceId: String) + + /// Automatic experience cached because another experience is in progress. + case cachedPendingAutomatic(experience: ExperienceContent) +} + +// MARK: - State Checks + +extension ExperienceFlowState { + + /// Checks if experience was manually triggered. + func isManualTrigger() -> Bool { + switch self { + case .pendingManual, .cachedPendingManual: + return true + case .waitingDelay(let triggerType), .active(let triggerType, _): + return triggerType == .manual + default: + return false + } + } + + /// Checks if in preview mode. + func isPreviewMode() -> Bool { + switch self { + case .pendingPreview: + return true + case .waitingDelay(let triggerType), .active(let triggerType, _): + return triggerType == .preview + default: + return false + } + } + + /// Checks if an experience is active, waiting to be shown, or showing thank you. + func isActive() -> Bool { + switch self { + case .active, .showingThankYou, .waitingDelay: + return true + default: + return false + } + } + + /// Checks if an experience is visibly rendered to the user. + func isActivelyRendered() -> Bool { + switch self { + case .active, .showingThankYou: + return true + default: + return false + } + } + + /// Checks if a cached experience is waiting to be processed. + func hasCachedExperience() -> Bool { + switch self { + case .cachedPendingManual, .cachedPendingAutomatic: + return true + default: + return false + } + } + + /// Checks if screen validation should be bypassed. + func shouldBypassScreenValidation() -> Bool { + isManualTrigger() || isPreviewMode() + } +} + +// MARK: - CustomStringConvertible + +extension ExperienceFlowState: CustomStringConvertible { + var description: String { + switch self { + case .idle: + return "Idle" + case .pendingManual(let experienceId): + return "PendingManual(id=\(experienceId ?? "nil"))" + case .pendingAutomatic: + return "PendingAutomatic" + case .pendingPreview: + return "PendingPreview" + case .waitingDelay(let triggerType): + return "WaitingDelay(\(triggerType))" + case .active(let triggerType, let content): + return "Active(\(triggerType), content=\(type(of: content)))" + case .showingThankYou: + return "ShowingThankYou" + case .cachedPendingManual(let experienceId): + return "CachedPendingManual(id=\(experienceId))" + case .cachedPendingAutomatic: + return "CachedPendingAutomatic" + } + } +} + +// MARK: - WeakExperienceReference + +/// Wrapper for weak reference to UPExperience. +internal final class WeakExperienceReference { + weak var component: UPExperience? + + init(_ component: UPExperience) { + self.component = component + } + + func get() -> UPExperience? { + component + } +} + +// MARK: - ExperienceStateManaging + +/// Protocol defining experience state management behavior. +internal protocol ExperienceStateManaging: AnyObject { + + // MARK: - State Access + + func getCurrentState() -> ExperienceFlowState + func isActive() -> Bool + func isActivelyRendered() -> Bool + func shouldBypassScreenValidation() -> Bool + func isPreviewMode() -> Bool + func isManualTrigger() -> Bool + func hasCachedExperience() -> Bool + + // MARK: - State Transition Methods + + func markIdle() + func markManualTrigger(_ experienceId: String?) + func markAutomaticTrigger(_ experience: ExperienceContent?) + func markPreviewMode() + func markWaitingDelay(_ triggerType: TriggerType) + func markActive(_ triggerType: TriggerType, _ content: ExperienceContent) + func markShowingThankYou() + func markCachedManual(_ experienceId: String) + func markCachedAutomatic(_ experience: ExperienceContent) + + // MARK: - Active Experience Component Management + + func setActiveComponent(_ component: UPExperience) + func getActiveComponent() -> UPExperience? + func getActiveContent() -> ExperienceContent? + func getActiveTriggerType() -> TriggerType? + func isActiveComponentAlive() -> Bool + + // MARK: - State Query Helpers + + func getCachedExperienceId() -> String? + func getCachedExperienceContent() -> ExperienceContent? + + // MARK: - High-Level Operations + + func markActiveFromCurrentState(content: ExperienceContent) +} + +// MARK: - ExperienceStateManager + +/// Manages experience flow state transitions and provides thread-safe access to current state. +internal final class ExperienceStateManager { + + // MARK: - Properties + + private let logger: Logging + private let state: AtomicReference + private var activeComponent: WeakExperienceReference? + + // MARK: - Initialization + + init(container: DIContainer) { + self.logger = container.resolve(Userpilot.Config.self).logger + self.state = AtomicReference(.idle) + } +} + +// MARK: - ExperienceStateManaging + +extension ExperienceStateManager: ExperienceStateManaging { + + // MARK: - State Access + + func getCurrentState() -> ExperienceFlowState { + state.value + } + + func isActive() -> Bool { + state.value.isActive() + } + + func isActivelyRendered() -> Bool { + state.value.isActivelyRendered() + } + + func shouldBypassScreenValidation() -> Bool { + state.value.shouldBypassScreenValidation() + } + + func isPreviewMode() -> Bool { + state.value.isPreviewMode() + } + + func isManualTrigger() -> Bool { + state.value.isManualTrigger() + } + + func hasCachedExperience() -> Bool { + state.value.hasCachedExperience() + } + + // MARK: - State Transition Methods + + func markIdle() { + activeComponent = nil + state.value = .idle + logger.info("Experience state: Idle") + } + + func markManualTrigger(_ experienceId: String? = nil) { + state.value = .pendingManual(experienceId: experienceId) + logger.info("Experience state: PendingManual(id=%@)", experienceId ?? "nil") + } + + func markAutomaticTrigger(_ experience: ExperienceContent? = nil) { + state.value = .pendingAutomatic(experience: experience) + logger.info("Experience state: PendingAutomatic") + } + + func markPreviewMode() { + state.value = .pendingPreview + logger.info("Experience state: PendingPreview") + } + + func markWaitingDelay(_ triggerType: TriggerType) { + state.value = .waitingDelay(triggerType: triggerType) + logger.info("Experience state: WaitingDelay(%@)", String(describing: triggerType)) + } + + func markActive(_ triggerType: TriggerType, _ content: ExperienceContent) { + state.value = .active(triggerType: triggerType, content: content) + logger.info( + "Experience state: Active(%@, content=%@)", + String(describing: triggerType), + String(describing: type(of: content)) + ) + } + + func markShowingThankYou() { + state.value = .showingThankYou + logger.info("Experience state: ShowingThankYou") + } + + func markCachedManual(_ experienceId: String) { + state.value = .cachedPendingManual(experienceId: experienceId) + logger.info("Experience state: CachedPendingManual(id=%@)", experienceId) + } + + func markCachedAutomatic(_ experience: ExperienceContent) { + state.value = .cachedPendingAutomatic(experience: experience) + logger.info("Experience state: CachedPendingAutomatic") + } + + // MARK: - Active Experience Component Management + + func setActiveComponent(_ component: UPExperience) { + activeComponent = WeakExperienceReference(component) + logger.info("Active experience component set: %@", String(describing: type(of: component))) + } + + func getActiveComponent() -> UPExperience? { + activeComponent?.get() + } + + func getActiveContent() -> ExperienceContent? { + if case .active(_, let content) = state.value { + return content + } + return nil + } + + func getActiveTriggerType() -> TriggerType? { + if case .active(let triggerType, _) = state.value { + return triggerType + } + return nil + } + + func isActiveComponentAlive() -> Bool { + activeComponent?.get() != nil + } + + // MARK: - State Query Helpers + + func getCachedExperienceId() -> String? { + if case .cachedPendingManual(let experienceId) = state.value { + return experienceId + } + return nil + } + + func getCachedExperienceContent() -> ExperienceContent? { + if case .cachedPendingAutomatic(let experience) = state.value { + return experience + } + return nil + } + + // MARK: - High-Level Operations + + func markActiveFromCurrentState(content: ExperienceContent) { + let triggerType: TriggerType + if isManualTrigger() { + triggerType = .manual + } else if isPreviewMode() { + triggerType = .preview + } else { + triggerType = .automatic + } + markActive(triggerType, content) + } +} diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 34381dfb..86e57309 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -21,6 +21,9 @@ import UIKit */ internal protocol ExperiencesPublishing: AnyObject { + /// Whether the publisher is currently processing a preview experience. + func isPreviewExperienceMode() -> Bool + /// Get current experience func getActiveMobileContent() -> ExperienceContent? @@ -30,6 +33,9 @@ internal protocol ExperiencesPublishing: AnyObject { /// Manually trigger experience func triggerExperience(_ experienceId: String) + /// Preview experience + func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) + /// Manually trigger experience func updateSceen(_ screenName: String) @@ -82,6 +88,9 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Analytics publisher to manage events triggering. private let analyticsPublisher: AnalyticsPublishing + /// Remote source used for fetching preview experience content. + private let userpilotRemoteSource: UserpilotRemoteSourcing + /// Handles themes for the experiences, managing theme data and styles. private let themeHandler: ThemeHandling @@ -91,6 +100,12 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// The configuration settings for the `Userpilot` SDK. private let config: Userpilot.Config + /// Logger used for internal logging of operations and errors. + private let linkOpener: LinkOpening + + /// Manages experience flow state transitions. + private let experienceStateManager: ExperienceStateManaging + /// Logger used for internal logging of operations and errors. private let logger: Logging @@ -99,11 +114,8 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// The current screen title being tracked private lazy var currentScreen: String = "" - /// Flag indicating if a manual experience trigger is in progress - private var isTriggerManualExperience = false - - /// Flag indicating if a thank you message is currently being triggered - private var isTriggeringThankYouMessage = false + /// The last screen on which NPS was shown, preventing repeated NPS on same screen. + private lazy var npsTrackedScreen: String = "" /// Queue to track pending experience content waiting to be displayed private var pendingExperiences: [ExperienceContent] = [] @@ -125,7 +137,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Determines if there are currently active experiences being displayed private var hasActiveExperience: Bool { - return activeExperience != nil + return activeExperience != nil || experienceStateManager.isActivelyRendered() } /// Expereinces presentation style. @@ -151,8 +163,11 @@ internal class ExperiencesPublisher: ExperiencesPublishing { self.config = container.resolve(Userpilot.Config.self) self.socketManager = container.resolve(SocketEvents.self) self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) + self.userpilotRemoteSource = container.resolve(UserpilotRemoteSourcing.self) self.themeHandler = container.resolve(ThemeHandling.self) self.logger = container.resolve(Userpilot.Config.self).logger + self.linkOpener = container.resolve(LinkOpening.self) + self.experienceStateManager = container.resolve(ExperienceStateManaging.self) socketManager.registerCallback(self) } @@ -178,9 +193,10 @@ internal class ExperiencesPublisher: ExperiencesPublishing { * - Returns: true if screen events can be triggered, false otherwise */ func canRequestScreenEvent() -> Bool { - return requestFakeScreenReloadEventDate?.isMoreThanOneSecond(from: Date()) ?? true && - !hasActiveExperience && - !isTriggeringThankYouMessage + return requestFakeScreenReloadEventDate?.isMoreThanOneSecond(from: Date()) ?? true + && !hasActiveExperience + && !experienceStateManager.isActive() + && !experienceStateManager.hasCachedExperience() } /** @@ -192,17 +208,24 @@ internal class ExperiencesPublisher: ExperiencesPublishing { func triggerExperience(_ experienceId: String) { experienceQueue.async { [weak self] in guard let self else { return } - if self.pendingExperiences.isEmpty { - self.publishInternalSDKEvent(ExperienceContentEvent(experienceId: experienceId)) - } else { - logger.info("‼️ There is a currently active experience being processed") + if self.hasActiveExperience + || self.experienceStateManager.isActive() + || self.experienceStateManager.hasCachedExperience() + || !self.pendingExperiences.isEmpty { + self.experienceStateManager.markCachedManual(experienceId) + self.logger.info("Experience cached - active experience in progress") + return } + + self.delayUtils.cancelDelay() + self.experienceStateManager.markManualTrigger(experienceId) + self.publishInternalSDKEvent(ExperienceContentEvent(experienceId: experienceId)) } } /// Helper method to get top view controller internal var topViewControllerProvider: () -> UIViewController? = { - return UIApplication.shared.resolveTopViewController() + return UIApplication.shared.topViewController() } /// Resolves the host view controller that experiences should be presented on. @@ -246,21 +269,38 @@ internal class ExperiencesPublisher: ExperiencesPublishing { * - Parameter manualClose: true if the user manually closed the experience, false for automatic closure */ func endExperience(manualClose: Bool) { + endExperience(manualClose: manualClose, completion: nil) + } + + private func endExperience(manualClose: Bool, completion: (() -> Void)?) { performOn(.main) { [weak self] in - guard let self else { return } - guard let topVC = self.activeExperience else { - self.hideExperienceOverlayIfIdle() + guard let self else { + completion?() return } - + let activeViewController = self.activeExperience + let activeComponent = self.experienceStateManager.getActiveComponent() self.activeExperience = nil - guard let experience = topVC as? UPExperience else { + + guard let experience = (activeViewController as? UPExperience) ?? activeComponent else { + if !self.experienceStateManager.hasCachedExperience() { + self.experienceStateManager.markIdle() + } self.hideExperienceOverlayIfIdle() + completion?() return } experience.triggerCloseExperience(manualClose: manualClose) { [weak self] in - self?.experienceDidFinishDismissing() + guard let self else { + completion?() + return + } + if !self.experienceStateManager.hasCachedExperience() { + self.experienceStateManager.markIdle() + } + self.experienceDidFinishDismissing() + completion?() } } } @@ -302,6 +342,11 @@ internal class ExperiencesPublisher: ExperiencesPublishing { return content } + /// Preview mode suppresses normal analytics while draft content is being rendered. + func isPreviewExperienceMode() -> Bool { + experienceStateManager.isPreviewMode() + } + // MARK: - Deep Link Handling /** @@ -312,13 +357,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func triggerDeepLink(url: URL) { delay(ThemeHandler.DefaultValues.delayTimeForDeepLink) { [weak self] in - if let navigationDelegate = self?.userpilot?.navigationDelegate { - navigationDelegate.navigate(to: url) - } else { - if url.isHttpOrHttps, UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - } + self?.linkOpener.handleURL(url) } } @@ -332,6 +371,21 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func publishInternalSDKEvent(_ sdkEvent: SDKEvent) { tryCatch { + if isPreviewExperienceMode() { + if sdkEvent.isEventForCloseExperience() || sdkEvent.isEventForCloseNPSExperience() { + activeExperience = nil + if socketManager.isSocketOpened { + requestFakeScreenReloadEventDate = Date() + } + analyticsPublisher.publishFakeReloadScreenEvent( + sdkEvent.getContentType(), + sdkEvent.getContentId() + ) + resetProcessingPreviewExperienceStatus() + } + return + } + // Process the event through analytics publisher analyticsPublisher.publishInternalSDKEvent(sdkEvent, socketSubscription: self) @@ -353,14 +407,20 @@ internal class ExperiencesPublisher: ExperiencesPublishing { // Don't trigger fake reload for NPS experiences or experiences with deep links // NPS is the last content, and deep links will open new screen if sdkEvent.isEventForCloseNPSExperience() || sdkEvent.hasDeepLink { + if !experienceStateManager.hasCachedExperience() { + experienceStateManager.markIdle() + } return } // Trigger fake reload when closing experience that wasn't manually triggered - if sdkEvent.isEventForCloseExperience() && !isTriggerManualExperience { + if sdkEvent.isEventForCloseExperience() && !experienceStateManager.hasCachedExperience() { + experienceStateManager.markIdle() analyticsPublisher.publishFakeReloadScreenEvent( sdkEvent.getContentType(), sdkEvent.getContentId() ) + } else if sdkEvent.isEventForCloseExperience() { + processCachedExperienceAfterClose() } } } @@ -381,6 +441,7 @@ extension ExperiencesPublisher: SocketSubscription { */ func updateSceen(_ screenName: String) { tryCatch { + if currentScreen == screenName { return } currentScreen = screenName resetState() } @@ -407,6 +468,8 @@ extension ExperiencesPublisher: SocketSubscription { guard let self, // If there's an active experience, ignore new content !hasActiveExperience, + !experienceStateManager.isActive(), + !experienceStateManager.isPreviewMode(), !message.payload.isEmpty, let response = message.payload.toJSONString() else { @@ -436,7 +499,13 @@ extension ExperiencesPublisher: SocketSubscription { }() if let experience { - self.isTriggerManualExperience = eventName == SDKEventsName.fetchExperienceContent.rawValue + if eventName == SDKEventsName.fetchExperienceContent.rawValue { + self.experienceStateManager.markManualTrigger( + experience.experienceId().toString() + ) + } else { + self.experienceStateManager.markAutomaticTrigger(experience) + } self.pendingExperiences.append(experience) } } @@ -444,7 +513,7 @@ extension ExperiencesPublisher: SocketSubscription { // Process the first pending experience if let experience = self.pendingExperiences.first { if experience.asNPSContent() != nil { - self.openNPSBottomSheetExperience() + self.openNPSBottomSheetExperience(experience) } else { self.checkCachedThemes(experience.experienceThemeId()) } @@ -462,7 +531,7 @@ extension ExperiencesPublisher: SocketSubscription { if let payload = message.payload["payload"] as? [String: Any] { experienceQueue.async { [weak self] in guard let self, - !hasActiveExperience, + !experienceStateManager.isPreviewMode(), payload.keys.contains("request_id"), payload["request_id"] as? Int == nil else { return } @@ -485,9 +554,17 @@ extension ExperiencesPublisher: SocketSubscription { }() if let experience { - self.pendingExperiences.append(experience) - self.isTriggerManualExperience = true - self.checkCachedThemes(experience.experienceThemeId()) + if self.hasActiveExperience || self.experienceStateManager.isActive() { + self.experienceStateManager.markCachedAutomatic(experience) + self.logger.info("Active experience in progress, caching incoming experience") + } else { + self.delayUtils.cancelDelay() + self.experienceStateManager.markManualTrigger( + experience.experienceId().toString() + ) + self.pendingExperiences.append(experience) + self.checkCachedThemes(experience.experienceThemeId()) + } } } } @@ -546,29 +623,29 @@ extension ExperiencesPublisher { case .flow(let content): switch content.type { case .carousel: - self.openCarouselExperience() + self.openCarouselExperience(experienceContent) case .slideout: if self.isBottomSheetContent(content) { - self.openSlideOutBottomSheetExperience() + self.openSlideOutBottomSheetExperience(experienceContent) } else { - self.openSlideOutDialogExperience() + self.openSlideOutDialogExperience(experienceContent) } } case .survey(let content): switch content.type { case .list: - self.openSurveyListExperience() + self.openSurveyListExperience(experienceContent) case .step: if self.isBottomSheetSurveyContent(content) { - self.openSurveyBottomSheetExperience() + self.openSurveyBottomSheetExperience(experienceContent) } else { - self.openSurveyDialogExperience() + self.openSurveyDialogExperience(experienceContent) } } case .nps: - openNPSBottomSheetExperience() + openNPSBottomSheetExperience(experienceContent) } } } @@ -613,8 +690,9 @@ extension ExperiencesPublisher { * Opens a carousel experience in a full-screen activity. * Content is passed to prevent issues if pending experiences are cleared during screen transitions. */ - private func openCarouselExperience() { + private func openCarouselExperience(_ experienceContent: ExperienceContent) { showExperience( + experienceContent, makeViewModel: ExperienceViewModel.init, makeViewController: CarouselExperienceViewController.init, presentation: .fullScreen @@ -622,8 +700,9 @@ extension ExperiencesPublisher { } /** Opens a slide-out experience as a dialog fragment */ - private func openSlideOutDialogExperience() { + private func openSlideOutDialogExperience(_ experienceContent: ExperienceContent) { showExperience( + experienceContent, makeViewModel: ExperienceViewModel.init, makeViewController: SlideOutDialogViewController.init, presentation: .dialog @@ -631,8 +710,9 @@ extension ExperiencesPublisher { } /** Opens a slide-out experience as a bottom sheet fragment */ - private func openSlideOutBottomSheetExperience() { + private func openSlideOutBottomSheetExperience(_ experienceContent: ExperienceContent) { showExperience( + experienceContent, makeViewModel: ExperienceViewModel.init, makeViewController: SlideOutBottomSheetViewController.init, presentation: .bottomSheet @@ -640,8 +720,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience in a full-screen activity with list view */ - private func openSurveyListExperience() { + private func openSurveyListExperience(_ experienceContent: ExperienceContent) { showExperience( + experienceContent, makeViewModel: SurveyViewModel.init, makeViewController: SurveyListViewController.init, presentation: .fullScreen @@ -649,8 +730,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience as a dialog fragment */ - private func openSurveyDialogExperience() { + private func openSurveyDialogExperience(_ experienceContent: ExperienceContent) { showExperience( + experienceContent, makeViewModel: SurveyViewModel.init, makeViewController: SurveyDialogViewController.init, presentation: .dialog @@ -658,8 +740,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience as a bottom sheet fragment */ - private func openSurveyBottomSheetExperience() { + private func openSurveyBottomSheetExperience(_ experienceContent: ExperienceContent) { showExperience( + experienceContent, makeViewModel: SurveyViewModel.init, makeViewController: SurveyBottomSheetViewController.init, presentation: .bottomSheet @@ -667,8 +750,13 @@ extension ExperiencesPublisher { } /** Opens an NPS experience as a bottom sheet fragment */ - private func openNPSBottomSheetExperience() { + private func openNPSBottomSheetExperience(_ experienceContent: ExperienceContent) { + if currentScreen == npsTrackedScreen { + resetProcessingExperienceStatus() + return + } showExperience( + experienceContent, makeViewModel: NPSViewModel.init, makeViewController: NPSBottomSheetViewController.init, presentation: .bottomSheet @@ -681,13 +769,13 @@ extension ExperiencesPublisher { _ surveyTheme: SurveyTheme, _ submissionId: Int64 ) { - isTriggeringThankYouMessage = true + experienceStateManager.markShowingThankYou() performOn(.main) { [weak self] in guard let self = self, let host = self.experiencePresentationHost() else { - self?.isTriggeringThankYouMessage = false + self?.resetProcessingExperienceStatus() self?.experienceDidFinishDismissing() return } @@ -709,7 +797,7 @@ extension ExperiencesPublisher { } } thankYouBottomSheetViewController.onDismissCompleted = { [weak self] in - self?.isTriggeringThankYouMessage = false + self?.resetProcessingExperienceStatus() self?.experienceDidFinishDismissing() } host.presentBottomSheet(viewController: thankYouBottomSheetViewController) @@ -721,7 +809,7 @@ extension ExperiencesPublisher { // MARK: - Experience Validation and Display Logic -internal extension ExperiencesPublisher { +extension ExperiencesPublisher { /** * Resolves the display delay for an experience. @@ -729,11 +817,11 @@ internal extension ExperiencesPublisher { * * - Returns: The delay duration in seconds */ - func resolvedDelay() -> TimeInterval { - if let surveyDelay = pendingExperiences.first?.asSurveyContent()?.delayDuration, surveyDelay > 0 { + func resolvedDelay(_ experienceContent: ExperienceContent) -> TimeInterval { + if let surveyDelay = experienceContent.asSurveyContent()?.delayDuration, surveyDelay > 0 { return surveyDelay } - if let npsDelay = pendingExperiences.first?.asNPSContent()?.delayDuration, npsDelay > 0 { + if let npsDelay = experienceContent.asNPSContent()?.delayDuration, npsDelay > 0 { return npsDelay } return ThemeHandler.DefaultValues.delayTimeForExperience @@ -745,15 +833,26 @@ internal extension ExperiencesPublisher { * dropped frames and interrupts opening content animations. */ private func showExperience( + _ experienceContent: ExperienceContent, makeViewModel: @escaping (DIContainer) -> VM, makeViewController: @escaping (VM) -> VC, presentation: PresentationStyle ) { tryCatch { - delayUtils.delayAction(delayTime: resolvedDelay()) { [weak self] in + let triggerType: TriggerType + if experienceStateManager.isManualTrigger() { + triggerType = .manual + } else if experienceStateManager.isPreviewMode() { + triggerType = .preview + } else { + triggerType = .automatic + } + experienceStateManager.markWaitingDelay(triggerType) + + delayUtils.delayAction(delayTime: resolvedDelay(experienceContent)) { [weak self] in guard let self, - self.canShowExperience(), + self.canShowExperience(experienceContent), let container = self.container else { // Delay was cancelled through resetState, stop processing this experience @@ -773,6 +872,13 @@ internal extension ExperiencesPublisher { viewController.modalPresentationStyle = .fullScreen } self.activeExperience = viewController + self.experienceStateManager.markActiveFromCurrentState(content: experienceContent) + if let upExperience = viewController as? UPExperience { + self.experienceStateManager.setActiveComponent(upExperience) + } + if viewController.isKind(of: NPSBottomSheetViewController.self) { + self.npsTrackedScreen = self.currentScreen + } switch presentation { case .fullScreen, .normal: host.present(viewController, animated: true) @@ -793,27 +899,65 @@ internal extension ExperiencesPublisher { extension ExperiencesPublisher { /** - * Determines whether an experience can be shown based on current state and targeting rules. - * - * Validation rules: - * - Returns `false` if there are no pending experiences to show. - * - Returns `false` if there is already an active rendered experience. - * - Returns `true` if the experience was manually triggered (screen validation not required). - * - Returns `true` if the current session has just started and the experience is tied to the start session - * (these experiences have no screens to check, so they are shown without screen validation). - * - Otherwise, validates screen targeting rules for Flow, Survey, or NPS content. - * - * @param experienceContent The experience content to validate. - * @return `true` if the experience can be shown, `false` otherwise. - */ - private func canShowExperience() -> Bool { - guard - // No pending content to show (removed for some reason) - !pendingExperiences.isEmpty, - // Already have an active rendered experience - !hasActiveExperience - else { return false } - return true + * Determines whether an experience can be shown based on current state and targeting rules. + * + * Validation rules: + * - Returns `false` if there are no pending experiences to show. + * - Returns `false` if there is already an active rendered experience. + * - Returns `true` if the experience was manually triggered (screen validation not required). + * - Returns `true` if the current session has just started and the experience is tied to the start session + * (these experiences have no screens to check, so they are shown without screen validation). + * - Otherwise, validates screen targeting rules for Flow, Survey, or NPS content. + * + * @param experienceContent The experience content to validate. + * @return `true` if the experience can be shown, `false` otherwise. + */ + private func canShowExperience(_ experienceContent: ExperienceContent) -> Bool { + guard !pendingExperiences.isEmpty else { + logger.info("Cannot show experience: pending experiences is empty") + return false + } + + guard !hasActiveExperience else { + logger.info("Cannot show experience: there is an active experience already") + return false + } + + if experienceStateManager.shouldBypassScreenValidation() { + logger.info("Can show experience: bypassing screen validation") + return true + } + + if analyticsPublisher.isStartSession { + logger.info("Can show experience: start session is true") + return true + } + + let flowContent = experienceContent.asFlowContent() + let surveyContent = experienceContent.asSurveyContent() + let npsContent = experienceContent.asNPSContent() + + let isFlowContentValid = + flowContent.map { $0.isForAllScreens || $0.screens.contains(currentScreen) } ?? false + let isSurveyContentValid = + surveyContent.map { $0.isForAllScreens || $0.screens.contains(currentScreen) } ?? false + let isNPSContentValid = + npsContent.map { $0.isForAllScreens || $0.screens.contains(currentScreen) } ?? false + let isValidScreen = isFlowContentValid || isSurveyContentValid || isNPSContentValid + + logger.info( + """ + Screen validation result: Flow=%{public}@, Survey=%{public}@, NPS=%{public}@, \ + is valid=%{public}@, current screen='%{public}@' + """, + "\(isFlowContentValid)", + "\(isSurveyContentValid)", + "\(isNPSContentValid)", + "\(isValidScreen)", + currentScreen + ) + + return isValidScreen } /** Removes all cached/pending experiences from the queue */ @@ -833,11 +977,13 @@ extension ExperiencesPublisher { if pendingExperiences.isEmpty { return } experienceQueue.async { [weak self] in guard let self else { return } - if pendingExperiences.count == 1 { - pendingExperiences.removeAll() + self.resetProcessingExperienceStatus() + if self.pendingExperiences.count == 1 { + self.pendingExperiences.removeAll() } else { if let lastContent = self.pendingExperiences.last, self.pendingExperiences.count > 1 { self.pendingExperiences = [lastContent] + self.experienceStateManager.markAutomaticTrigger(lastContent) self.openExperienceFlow() } } @@ -851,16 +997,148 @@ extension ExperiencesPublisher { * becomes invalid from view models. */ private func resetState() { + resetState(completion: nil) + } + + private func resetState(completion: (() -> Void)?) { tryCatch { delayUtils.cancelDelay() clearPendingExperiences() - isTriggeringThankYouMessage = false + npsTrackedScreen = "" + + if hasActiveExperience || experienceStateManager.getActiveComponent() != nil { + endExperience(manualClose: true, completion: completion) + } else { + activeExperience = nil + experienceStateManager.markIdle() + hideExperienceOverlayIfIdle() + completion?() + } + } + } + + private func resetProcessingExperienceStatus() { + activeExperience = nil + experienceStateManager.markIdle() + } + + private func resetProcessingPreviewExperienceStatus() { + resetProcessingExperienceStatus() + } + + private func processCachedExperienceAfterClose() { + let state = experienceStateManager.getCurrentState() + switch state { + case .cachedPendingAutomatic(let experience): + activeExperience = nil + pendingExperiences.append(experience) + experienceStateManager.markAutomaticTrigger(experience) + checkCachedThemes(experience.experienceThemeId()) + + case .cachedPendingManual(let experienceId): + activeExperience = nil + experienceStateManager.markIdle() + triggerExperience(experienceId) + + default: + resetProcessingExperienceStatus() + } + } +} + +// MARK: - Preview Experience + +extension ExperiencesPublisher { + + func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) { + resetState { [weak self] in + guard let self else { return } + self.experienceStateManager.markPreviewMode() + self.userpilotRemoteSource.fetchPreviewExperience( + params: PreviewExperienceQueryParams( + baseUrl: RemoteSource.experienceBaseURL, + appToken: self.config.token, + contentType: queryItems.first(where: { $0.name == "type" })?.value ?? "", + contentId: experienceId + ), + completion: { [weak self] result in + guard let self else { return } + switch result { + case .success(let previewExperience): + self.processPreviewExperience(previewExperience) + case .failure(let error): + self.showExperienceTriggeringDebugMessage(error.localizedDescription) + } + } + ) + } + } + + private func processPreviewExperience(_ previewExperience: PreviewExperience) { + tryCatch { + guard let theme = previewExperience.theme, + previewExperience.flow != nil || previewExperience.survey != nil + else { + resetProcessingPreviewExperienceStatus() + return + } + + let experience: ExperienceContent? + if let flow = previewExperience.flow { + experience = .flow(content: flow) + } else if let survey = previewExperience.survey { + experience = .survey(content: survey) + } else { + experience = nil + } + + guard let experience else { + resetProcessingPreviewExperienceStatus() + return + } + + experienceQueue.async { [weak self] in + guard let self else { return } + self.pendingExperiences.append(experience) + self.themeHandler.saveTheme(theme) + self.openExperienceFlow() + } + } + } + + private func showExperienceTriggeringDebugMessage(_ message: String) { + resetProcessingPreviewExperienceStatus() + performOn(.main) { [weak self] in + guard + let self, + let host = self.experiencePresentationHost() + else { return } + + let alert = UIAlertController( + title: "Preview Experience", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil)) + + if let popover = alert.popoverPresentationController { + popover.sourceView = host.view + popover.sourceRect = CGRect( + x: host.view.bounds.midX, + y: host.view.bounds.midY, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + + host.present(alert, animated: true, completion: nil) } } } #if DEBUG -internal extension ExperiencesPublisher { +extension ExperiencesPublisher { func mockSetCurrentScreen(title: String) { currentScreen = title } diff --git a/Sources/Userpilot/Experiences/Models/ExperienceContent.swift b/Sources/Userpilot/Experiences/Models/ExperienceContent.swift index 1eaae94f..19e9a6e6 100644 --- a/Sources/Userpilot/Experiences/Models/ExperienceContent.swift +++ b/Sources/Userpilot/Experiences/Models/ExperienceContent.swift @@ -85,4 +85,17 @@ extension ExperienceContent { return content } } + + /// Retrieves the id for the content. + /// - Returns: A Int representing the id of the content. + func experienceId() -> Int { + switch self { + case .flow(let content): + return content.id + case .survey(let content): + return content.id + case .nps: + return 0 + } + } } diff --git a/Sources/Userpilot/Experiences/Models/NPSContent.swift b/Sources/Userpilot/Experiences/Models/NPSContent.swift index 47c450bc..7ea95d6e 100644 --- a/Sources/Userpilot/Experiences/Models/NPSContent.swift +++ b/Sources/Userpilot/Experiences/Models/NPSContent.swift @@ -205,7 +205,7 @@ internal struct NPSTheme: Decodable { // radius var borderRadius: CGFloat { - CGFloat(box?.radius ?? 12) + CGFloat(box?.radius ?? 24) } } diff --git a/Sources/Userpilot/Experiences/Models/PreviewExperience.swift b/Sources/Userpilot/Experiences/Models/PreviewExperience.swift new file mode 100644 index 00000000..3e2816f6 --- /dev/null +++ b/Sources/Userpilot/Experiences/Models/PreviewExperience.swift @@ -0,0 +1,46 @@ +// +// PreviewExperience.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 03/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Models preview experience payload and query parameters for preview content. +// +// MARK: - Query Parameters + +internal struct PreviewExperienceQueryParams { + let baseUrl: String + let appToken: String + let contentType: String + let contentId: String +} + +// MARK: - Preview Experience Model + +internal struct PreviewExperience: Decodable { + let flow: FlowContent? + let survey: SurveyContent? + let contentType: String? + let theme: ThemeContent? + + private enum CodingKeys: String, CodingKey { + case flow = "mobile_content" + case contentType = "content_type" + case survey, theme + } +} + +// MARK: - String Extension for JSON Deserialization + +internal extension String { + /// Converts a JSON string into a `FlowContentData` object using `JSONDecoder`. + func toPreviewExperience() -> PreviewExperience? { + if let previewExperience: PreviewExperience = self.toObject() { + return previewExperience + } else { + return nil + } + } +} diff --git a/Sources/Userpilot/Experiences/Theme/Models/ThemeContent.swift b/Sources/Userpilot/Experiences/Theme/Models/ThemeContent.swift index 05a5432f..6248986f 100644 --- a/Sources/Userpilot/Experiences/Theme/Models/ThemeContent.swift +++ b/Sources/Userpilot/Experiences/Theme/Models/ThemeContent.swift @@ -355,7 +355,7 @@ internal struct SurveyTheme: Decodable { // Border var borderRadius: CGFloat { - CGFloat(general?.cornerRadius ?? 12) + CGFloat(general?.cornerRadius ?? 24) } } diff --git a/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift b/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift index 66daea47..904fe0c9 100644 --- a/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift +++ b/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift @@ -46,10 +46,10 @@ internal class ThemeHandler: ThemeHandling { // MARK: - Nested Types /// Default values for various text styles and attributes. - struct DefaultValues { + enum DefaultValues { // Carousels & Slide out static let delayTimeForExperience = 0.5 - static let delayTimeForDeepLink = 0.5 + static let delayTimeForDeepLink = 0.3 static let headerTextSize = 16 static let normalTextSize = 16 static let dimSlideOutDegree = 40 @@ -77,12 +77,13 @@ internal class ThemeHandler: ThemeHandling { static let distanceBetweenSections = CGFloat(12) static let smallDistanceBetweenSections = CGFloat(8) static let contentMargin = CGFloat(20) + static let contentTopMargin = CGFloat(10) static let contentBottomMargin = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(30) : CGFloat(20) static let buttonBottomMarginWithStepProgress = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(62) : CGFloat(52) static let buttonBottomMarginWithoutStepProgress = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(35) : CGFloat(25) - static let carouselContentTopMargin = CGFloat(55) + static let carouselContentTopMargin = CGFloat(65) static let slideOutCornerRadius = CGFloat(12) static let blurImageSize = CGSize(width: 64, height: 64) @@ -118,7 +119,7 @@ internal class ThemeHandler: ThemeHandling { } /// Style names used for text formatting in themes. - struct StyleName { + enum StyleName { static let textStyle = "textStyle" static let textLink = "link" static let textBold = "bold" @@ -126,7 +127,7 @@ internal class ThemeHandler: ThemeHandling { } /// Additional style values. - struct StyleValues { + enum StyleValues { static let manual = "manual" static let borderRadius = 0 } diff --git a/Sources/Userpilot/Experiences/Views/Containers/BottomSheetViewController.swift b/Sources/Userpilot/Experiences/Views/Containers/BottomSheetViewController.swift index 1abbae79..108b83bc 100644 --- a/Sources/Userpilot/Experiences/Views/Containers/BottomSheetViewController.swift +++ b/Sources/Userpilot/Experiences/Views/Containers/BottomSheetViewController.swift @@ -201,7 +201,7 @@ private extension BottomSheetViewController { // MARK: - Animations -internal extension BottomSheetViewController { +extension BottomSheetViewController { /// Animate the presentation of the bottom sheet func animatePresent() { @@ -227,7 +227,7 @@ internal extension BottomSheetViewController { // MARK: - Public APIs -internal extension BottomSheetViewController { +extension BottomSheetViewController { /// Set the dynamic content for the bottom sheet func setContent( diff --git a/Sources/Userpilot/Experiences/Views/Containers/DialogViewController.swift b/Sources/Userpilot/Experiences/Views/Containers/DialogViewController.swift index dbbaa1df..b57f0408 100644 --- a/Sources/Userpilot/Experiences/Views/Containers/DialogViewController.swift +++ b/Sources/Userpilot/Experiences/Views/Containers/DialogViewController.swift @@ -217,7 +217,7 @@ extension DialogViewController { // MARK: - Update constraints on screen rotation -internal extension DialogViewController { +extension DialogViewController { func resetWidth(_ size: CGSize) { let widthRatio = self.calculateDialogWidthRatio() self.mainContainerWidthConstraint?.constant = size.width * widthRatio diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/UPLikertView.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/UPLikertView.swift index 63d7cd51..41290bc3 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/UPLikertView.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/UPLikertView.swift @@ -27,6 +27,7 @@ internal class UPLikertView: UIView { layout.minimumInteritemSpacing = 8 let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear return collectionView }() @@ -125,6 +126,9 @@ internal class UPLikertView: UIView { collectionView.transform = CGAffineTransform(scaleX: -1, y: 1) lowScoreTextLabel.textAlignment = .right highScoreTextLabel.textAlignment = .left + } else { + lowScoreTextLabel.textAlignment = .left + highScoreTextLabel.textAlignment = .right } // Reload the collection view data @@ -163,6 +167,9 @@ internal class UPLikertView: UIView { collectionView.transform = CGAffineTransform(scaleX: -1, y: 1) lowScoreTextLabel.textAlignment = .right highScoreTextLabel.textAlignment = .left + } else { + lowScoreTextLabel.textAlignment = .left + highScoreTextLabel.textAlignment = .right } // Reload the collection view data @@ -223,7 +230,7 @@ internal class UPLikertView: UIView { return size } - func collectionViewHeight() -> Int { - return Int(collectionView.frame.height) - } +// func collectionViewHeight() -> Int { +// return Int(collectionView.frame.height) +// } } diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/Utils/UPLikertView+Views.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/Utils/UPLikertView+Views.swift index cfd0eb37..55222e28 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/Utils/UPLikertView+Views.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/LikertView/Utils/UPLikertView+Views.swift @@ -13,7 +13,7 @@ import UIKit -internal extension UPLikertView { +extension UPLikertView { // MARK: - Setup View Hierarchy diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/ChoiceTableViewCell.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/ChoiceTableViewCell.swift index f228a6d2..4e37a9dc 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/ChoiceTableViewCell.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/ChoiceTableViewCell.swift @@ -120,6 +120,8 @@ internal class ChoiceTableViewCell: UITableViewCell { fontSize: CGFloat(ThemeHandler.DefaultValues.surveyDescriptionTextSize)) if isRTL { label.textAlignment = .right + } else { + label.textAlignment = .left } // Styling based on selection state diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/UPMultipleChoiceView+Views.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/UPMultipleChoiceView+Views.swift index efe6f1b0..da2dc28c 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/UPMultipleChoiceView+Views.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/MultipleChoiceView/Utils/UPMultipleChoiceView+Views.swift @@ -11,7 +11,7 @@ import UIKit -internal extension UPMultipleChoiceView { +extension UPMultipleChoiceView { /// Configures the view's subviews and applies layout constraints. /// diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/OpenTextView/Utils/UPOpenTextView+Views.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/OpenTextView/Utils/UPOpenTextView+Views.swift index 6342e247..e355c56d 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/OpenTextView/Utils/UPOpenTextView+Views.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/OpenTextView/Utils/UPOpenTextView+Views.swift @@ -11,7 +11,7 @@ import UIKit -internal extension UPOpenTextView { +extension UPOpenTextView { // Sets up the views. // swiftlint:disable:next function_body_length diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/UPSingleInputView.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/UPSingleInputView.swift index d249b32a..9aaf1e53 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/UPSingleInputView.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/UPSingleInputView.swift @@ -118,6 +118,8 @@ internal class UPSingleInputView: UIView { if isRTL { textField.textAlignment = .right + } else { + textField.textAlignment = .left } } } diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Phone.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Phone.swift index 96e3f874..3b634f54 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Phone.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Phone.swift @@ -12,7 +12,7 @@ import UIKit // MARK: - Country Picker Popup Menu Management for UPSingleInputView -internal extension UPSingleInputView { +extension UPSingleInputView { // MARK: - Popup Menu Display diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Views.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Views.swift index 7eb2bd54..53e5392f 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Views.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/SingleInputView/Utils/UPSingleInputView+Views.swift @@ -24,22 +24,14 @@ extension UPSingleInputView { countryStackView.isHidden = true calendarIconButton.isHidden = true case .date: - if isRTL { - textField.setPadding(start: 60, end: 10) - } else { - textField.setPadding(start: 10, end: 60) - } + setDirectionalPadding(leading: 10, trailing: 60) textField.keyboardType = .numberPad textField.placeholder = "dd/mm/yyyy" calendarIconButton.isHidden = false countryStackView.isHidden = true calendarIconButton.tintColor = ThemeHandler.DefaultValues.grayColor case .phone: - if isRTL { - textField.setPadding(start: 10, end: 120) - } else { - textField.setPadding(start: 120, end: 10) - } + setDirectionalPadding(leading: 120, trailing: 10) textField.keyboardType = .phonePad countryStackView.isHidden = false calendarIconButton.isHidden = true @@ -63,6 +55,14 @@ extension UPSingleInputView { } } + private func setDirectionalPadding(leading: CGFloat, trailing: CGFloat) { + if isRTL == isAppRTL { + textField.setPadding(start: leading, end: trailing) + } else { + textField.setPadding(start: trailing, end: leading) + } + } + // MARK: - Setup View // swiftlint:disable:next function_body_length func setupView() { diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/UPTitleDescriptionView.swift b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/UPTitleDescriptionView.swift index d709704e..cab98d39 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Feedback/UPTitleDescriptionView.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Feedback/UPTitleDescriptionView.swift @@ -18,6 +18,10 @@ internal class UPTitleDescriptionView: UIView { private let titleLabel = UILabel() private let descriptionLabel = UILabel() + private var descriptionTopConstraint: NSLayoutConstraint? + private var descriptionBottomConstraint: NSLayoutConstraint? + private var titleBottomConstraint: NSLayoutConstraint? + // MARK: - Initializers override init(frame: CGRect) { @@ -49,16 +53,21 @@ internal class UPTitleDescriptionView: UIView { titleLabel.translatesAutoresizingMaskIntoConstraints = false descriptionLabel.translatesAutoresizingMaskIntoConstraints = false - // Activate constraints for the title and description labels + // Store constraints that will be conditionally activated + descriptionTopConstraint = descriptionLabel.topAnchor.constraint( + equalTo: titleLabel.bottomAnchor, constant: 4) + descriptionBottomConstraint = descriptionLabel.bottomAnchor.constraint( + equalTo: bottomAnchor) + titleBottomConstraint = titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + + // Activate fixed constraints for the title and description labels NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: topAnchor), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor), titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), - descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), descriptionLabel.leadingAnchor.constraint(equalTo: leadingAnchor), - descriptionLabel.trailingAnchor.constraint(equalTo: trailingAnchor), - descriptionLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + descriptionLabel.trailingAnchor.constraint(equalTo: trailingAnchor) ]) } @@ -147,7 +156,10 @@ internal class UPTitleDescriptionView: UIView { fontSize: CGFloat(ThemeHandler.DefaultValues.surveyTitleTextSize) ) - if let subHeader, !subHeader.isEmpty { + // Check if description has text + let hasDescription = subHeader?.trim().isNotEmpty == true + + if hasDescription, let subHeader = subHeader { descriptionLabel.text = subHeader descriptionLabel.textColor = textColor descriptionLabel.font = UIFont.matching( @@ -155,13 +167,27 @@ internal class UPTitleDescriptionView: UIView { fontSize: CGFloat(ThemeHandler.DefaultValues.surveyDescriptionTextSize) ) descriptionLabel.isHidden = false + + // Activate description constraints + titleBottomConstraint?.isActive = false + descriptionTopConstraint?.isActive = true + descriptionBottomConstraint?.isActive = true } else { + descriptionLabel.text = nil descriptionLabel.isHidden = true + + // Deactivate description constraints and anchor title to bottom + descriptionTopConstraint?.isActive = false + descriptionBottomConstraint?.isActive = false + titleBottomConstraint?.isActive = true } if isRTL { titleLabel.textAlignment = .right descriptionLabel.textAlignment = .right + } else { + titleLabel.textAlignment = .left + descriptionLabel.textAlignment = .left } } diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextContainerView.swift b/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextContainerView.swift index 207e1918..6bb6bfbc 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextContainerView.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextContainerView.swift @@ -30,7 +30,7 @@ internal class UPIconTextContainerView: UIStackView { private func setupViewProperties() { axis = .vertical alignment = .fill - spacing = 8 + spacing = 16 distribution = .fill } diff --git a/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextView.swift b/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextView.swift index 4dcbb931..d016e794 100644 --- a/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextView.swift +++ b/Sources/Userpilot/Experiences/Views/Widgets/Flows/UPIconTextView.swift @@ -28,6 +28,8 @@ internal class UPIconTextView: UIStackView { imageView.widthAnchor.constraint(equalToConstant: CGFloat(ThemeHandler.DefaultValues.iconImageDimensions)), imageView.heightAnchor.constraint(equalToConstant: CGFloat(ThemeHandler.DefaultValues.iconImageDimensions)) ]) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true return imageView }() @@ -72,13 +74,20 @@ internal class UPIconTextView: UIStackView { imageLoader: ImageLoading, isRTL: Bool) { // Add the subviews to the stack view - if isRTL { + if isRTL && isAppRTL { + addArrangedSubview(imageView) addArrangedSubview(textView) + } else if !isRTL && !isAppRTL { addArrangedSubview(imageView) - } else { + addArrangedSubview(textView) + } else if isRTL && !isAppRTL { + addArrangedSubview(textView) addArrangedSubview(imageView) + } else { addArrangedSubview(textView) + addArrangedSubview(imageView) } + // Configure the imageView using the provided line data. imageView.setupView(line: line, imageLoader: imageLoader) diff --git a/Sources/Userpilot/Extensions/Int+Extensions.swift b/Sources/Userpilot/Extensions/Int+Extensions.swift index 830271d6..5ac8b08f 100644 --- a/Sources/Userpilot/Extensions/Int+Extensions.swift +++ b/Sources/Userpilot/Extensions/Int+Extensions.swift @@ -17,6 +17,10 @@ internal extension Int { mutating func increment(by value: Int = 1) { self += value } + + func toString() -> String { + return String(self) + } } internal extension CGFloat { diff --git a/Sources/Userpilot/Extensions/String+Extensions.swift b/Sources/Userpilot/Extensions/String+Extensions.swift index 4b6b7b6e..fe26a637 100644 --- a/Sources/Userpilot/Extensions/String+Extensions.swift +++ b/Sources/Userpilot/Extensions/String+Extensions.swift @@ -20,6 +20,9 @@ internal extension Optional where Wrapped == String { return !(self?.isEmpty ?? true) } + func orEmpty() -> String { + return self ?? "" + } } internal extension String { diff --git a/Sources/Userpilot/Extensions/UIApplication+Extensions.swift b/Sources/Userpilot/Extensions/UIApplication+Extensions.swift index 41fec0d6..46ad51de 100644 --- a/Sources/Userpilot/Extensions/UIApplication+Extensions.swift +++ b/Sources/Userpilot/Extensions/UIApplication+Extensions.swift @@ -1,21 +1,28 @@ // -// UIApplication+Extension.swift +// UIApplication+TopViewController.swift +// Userpilot SDK // -// Created by Motasem Hamed on 20/08/2024. +// Created by Userpilot on 2025-10-16. +// Copyright © 2025 Userpilot. All rights reserved. // // [Brief Description] -// UIApplication+Extension file contains an extension of the `UIApplication` class, providing helper methods -// for retrieving the top-most view controller and managing active window scenes. +// Utilities to get the top view controller and open URLs. // -import Foundation import UIKit -internal extension UIApplication { +internal protocol TopControllerGetting { + var hasActiveWindowScenes: Bool { get } - // MARK: - Active Window Scenes + func topViewController() -> UIViewController? +} + +internal protocol URLOpening { + func open(_ url: URL) +} + +extension UIApplication: TopControllerGetting { - /// Returns all active window scenes in the foreground. @available(iOS 13.0, *) var activeWindowScenes: [UIWindowScene] { self.connectedScenes @@ -23,7 +30,14 @@ internal extension UIApplication { .compactMap { $0 as? UIWindowScene } } - /// Checks if there are any active window scenes. + // Prefer the active window scene, but in the case where there's no active scene, use + // the one from the main app window. + @available(iOS 13.0, *) + var mainWindowScene: UIWindowScene? { + activeWindowScenes.first + } + + // We expose this property because a unit test cannot init a UIWindowScene for mocking different states. var hasActiveWindowScenes: Bool { if #available(iOS 13.0, *) { return !activeWindowScenes.isEmpty @@ -32,7 +46,8 @@ internal extension UIApplication { } } - /// Returns the active key window, considering iOS versions and multitasking. + // Note: multitasking with two instances of the same app side by side will have both + // designated as `.foregroundActive`, and as a result the returned window may not be the one expected. private var activeKeyWindow: UIWindow? { if #available(iOS 13.0, *) { return self.activeWindowScenes @@ -43,66 +58,48 @@ internal extension UIApplication { } } - // MARK: - Public API - - /// Retrieves the top-most view controller, ensuring it is executed on the main thread. - func fetchTopViewController(completion: @escaping (UIViewController?) -> Void) { - if Thread.isMainThread { - completion(resolveTopViewController()) - } else { - DispatchQueue.main.async { [weak self] in - completion(self?.resolveTopViewController()) - } - } - } - - // MARK: - Private Helpers - - /// Resolves the top-most view controller starting from the root view controller. - /// - Returns: The top-most `UIViewController`, or `nil` if none is found. - func resolveTopViewController() -> UIViewController? { - var window: UIWindow? = activeKeyWindow + func topViewController() -> UIViewController? { + let window: UIWindow? = activeKeyWindow + guard let rootViewController = window?.rootViewController else { return nil } + let topVC = topViewController(controller: rootViewController) - if window == nil { - if #available(iOS 15.0, *) { - if let windowScene = UIApplication.shared.connectedScenes - .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { - window = windowScene.windows.first - } - } else { - window = UIApplication.shared.windows.first - } + // Return nil if a UIAlertController is currently being presented + // Cause showing Userpilot experience could lead to pushing the UIAlert view out + // of screen bounds! + if topVC is UIAlertController { + return nil } - guard let rootViewController = window?.rootViewController else { return nil } - return findTopViewController(from: rootViewController) + return topVC } - /// Recursively finds the top-most view controller starting from a given controller. - /// - Parameter controller: The `UIViewController` to start the search from. - /// - Returns: The top-most `UIViewController`. - private func findTopViewController(from controller: UIViewController) -> UIViewController { + private func topViewController(controller: UIViewController) -> UIViewController { if let navigationController = controller as? UINavigationController, let visibleViewController = navigationController.visibleViewController { if !visibleViewController.isBeingDismissed { - return findTopViewController(from: visibleViewController) + return topViewController(controller: visibleViewController) } else if let topStack = navigationController.viewControllers.last { - return findTopViewController(from: topStack) + // This gets the VC under what is being dismissed + return topViewController(controller: topStack) } else { - return findTopViewController(from: visibleViewController) + return topViewController(controller: visibleViewController) } } - if let tabController = controller as? UITabBarController, - let selectedViewController = tabController.selectedViewController { - return findTopViewController(from: selectedViewController) + let selected = tabController.selectedViewController { + return topViewController(controller: selected) } - - if let presentedViewController = controller.presentedViewController, - !presentedViewController.isBeingDismissed { - return findTopViewController(from: presentedViewController) + if let presented = controller.presentedViewController, !presented.isBeingDismissed { + return topViewController(controller: presented) } - return controller } } + +extension UIApplication: URLOpening { + + func open(_ url: URL) { + open(url, options: [:]) + } + +} diff --git a/Sources/Userpilot/Extensions/URL+Extensions.swift b/Sources/Userpilot/Extensions/URL+Extensions.swift index f9e9b153..9b079e04 100644 --- a/Sources/Userpilot/Extensions/URL+Extensions.swift +++ b/Sources/Userpilot/Extensions/URL+Extensions.swift @@ -12,7 +12,19 @@ import Foundation internal extension URL { - var isHttpOrHttps: Bool { - return scheme == "http" || scheme == "https" + var isWebLink: Bool { + return scheme?.lowercased() == "http" || scheme?.lowercased() == "https" + } + + var queryItems: [URLQueryItem] { + URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems ?? [] + } + + func queryValue(for name: String) -> String? { + URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name.lowercased() == name }? + .value } } diff --git a/Sources/Userpilot/Logging/Logging.swift b/Sources/Userpilot/Logging/Logging.swift index a6204f88..e3ccc373 100644 --- a/Sources/Userpilot/Logging/Logging.swift +++ b/Sources/Userpilot/Logging/Logging.swift @@ -96,8 +96,8 @@ extension OSLog: Logging { Swift doesn't support splatting so unfortunately `args` needs to be manually enumerated. Limiting it to 5 since that seems reasonable. */ - guard args.count <= 5 else { - error("Too many log args. 5 are supported, %{public}d passed.", args.count) + guard args.count <= 6 else { + error("Too many log args. 6 are supported, %{public}d passed.", args.count) return } @@ -112,6 +112,8 @@ extension OSLog: Logging { os_log(message, log: self, type: type, args[0], args[1], args[2], args[3]) case 5: os_log(message, log: self, type: type, args[0], args[1], args[2], args[3], args[4]) + case 6: + os_log(message, log: self, type: type, args[0], args[1], args[2], args[3], args[4], args[5]) default: os_log(message, log: self, type: type) } diff --git a/Sources/Userpilot/Protocols/UserpilotExperienceDelegate.swift b/Sources/Userpilot/Protocols/UserpilotExperienceDelegate.swift index fbaad482..fd21e59b 100644 --- a/Sources/Userpilot/Protocols/UserpilotExperienceDelegate.swift +++ b/Sources/Userpilot/Protocols/UserpilotExperienceDelegate.swift @@ -13,33 +13,6 @@ import Foundation // swiftlint:disable all -/// The Userpilot experience type. -@objc -public enum UserpilotExperienceType: Int { - case flow - case survey - case nps -} - -/// The various states of a Userpilot experience. -@objc -public enum UserpilotExperienceState: Int { - /// Indicates that the experience/step has started. - case started - - /// Indicates that the experience/step has been completed successfully. - case completed - - /// Indicates that the experience/step was dismissed before completion. - case dismissed - - /// Indicates that the experience/step was skipped. - case skipped - - /// Indicates that the experience/step was Submitted. - case submitted -} - /// A protocol to observe and respond to state changes in Userpilot experiences. @objc public protocol UserpilotExperienceDelegate: AnyObject { @@ -75,4 +48,60 @@ public protocol UserpilotExperienceDelegate: AnyObject { ) } + +/// The Userpilot experience type. +@objc +public enum UserpilotExperienceType: Int { + case flow + case survey + case nps + + /// Converts the analytic type to its string representation. + public var rawValueString: String { + switch self { + case .flow: + return "Flow" + case .survey: + return "Survey" + case .nps: + return "NPS" + } + } +} + +/// The various states of a Userpilot experience. +@objc +public enum UserpilotExperienceState: Int { + /// Indicates that the experience/step has started. + case started + + /// Indicates that the experience/step has been completed successfully. + case completed + + /// Indicates that the experience/step was dismissed before completion. + case dismissed + + /// Indicates that the experience/step was skipped. + case skipped + + /// Indicates that the experience/step was Submitted. + case submitted + + /// Converts the analytic type to its string representation. + public var rawValueString: String { + switch self { + case .started: + return "Started" + case .completed: + return "Completed" + case .dismissed: + return "Dismissed" + case .skipped: + return "Skipped" + case .submitted: + return "Submitted" + } + } +} + // swiftlint:enable all diff --git a/Sources/Userpilot/PushNotification/Models/UserpilotNotification.swift b/Sources/Userpilot/PushNotification/Models/UserpilotNotification.swift index 0520e496..206b8bf5 100644 --- a/Sources/Userpilot/PushNotification/Models/UserpilotNotification.swift +++ b/Sources/Userpilot/PushNotification/Models/UserpilotNotification.swift @@ -45,6 +45,9 @@ internal struct UserpilotNotification { /// An optional deep link URL contained in the notification. let deeplink: URL? + /// An optional is test flag for testing notification. + let isTest: String? + // MARK: - Initializer /** @@ -67,5 +70,6 @@ internal struct UserpilotNotification { self.userId = userId self.deeplink = (data["deep_link"] as? String).flatMap { URL(string: $0) } + self.isTest = data["is_test"] as? String } } diff --git a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift index cbe99883..8dfeba5f 100644 --- a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift +++ b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift @@ -59,6 +59,7 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr private let storage: DataStoring private let analyticsPublisher: AnalyticsPublishing private let socketManager: SocketEvents + private let linkOpener: LinkOpening // MARK: - Push Token Management @@ -86,6 +87,7 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr self.storage = container.resolve(DataStoring.self) self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) self.socketManager = container.resolve(SocketEvents.self) + self.linkOpener = container.resolve(LinkOpening.self) PushNotificationAutoConfig.register(observer: self) socketManager.registerCallback(self) @@ -307,39 +309,21 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr parsedNotification: UserpilotNotification, completionHandler: (() -> Void)? = nil ) { - let properties: [String: Any] = [ - "notification_id": Int(parsedNotification.notificationId) ?? 0 - ] - - analyticsPublisher.publishInternalSDKEvent( - PushNotificationOpenedEvent(payload: properties), - socketSubscription: self - ) + if parsedNotification.isTest != "true" { + let properties: [String: Any] = ["notification_id": Int(parsedNotification.notificationId) ?? 0] + analyticsPublisher.publishInternalSDKEvent( + PushNotificationOpenedEvent(payload: properties), + socketSubscription: self + ) + } if let url = parsedNotification.deeplink { - navigateToDeepLink(url, userpilot: userpilot) + linkOpener.handleURL(url) } completionHandler?() } - /// Navigates to a deep link URL when the notification includes a deep link. - /// - /// - Parameters: - /// - url: The URL to navigate to. - /// - userpilot: The Userpilot instance managing navigation. - private func navigateToDeepLink( - _ url: URL, - userpilot: Userpilot - ) { - if let navigationDelegate = userpilot.navigationDelegate { - navigationDelegate.navigate(to: url) - } else { - if url.isHttpOrHttps, UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - } - } } #if DEBUG diff --git a/Sources/Userpilot/Remote/RemoteSourceError.swift b/Sources/Userpilot/Remote/RemoteSourceError.swift new file mode 100644 index 00000000..bcc58a4f --- /dev/null +++ b/Sources/Userpilot/Remote/RemoteSourceError.swift @@ -0,0 +1,37 @@ +// +// RemoteSourceError.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 06/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Defines error cases for remote API interactions. +// +// MARK: - RemoteSourceError + +internal enum RemoteSourceError: Error { + case invalidURL + case invalidResponse + case networkError(String) + case httpError(statusCode: Int, message: String) + case decodingError(String) + case emptyResponse + + var localizedDescription: String { + switch self { + case .invalidURL: + return "Invalid URL" + case .invalidResponse: + return "Invalid response type" + case .networkError(let message): + return "Network request failed: \(message)" + case .httpError(_, let message): + return message + case .decodingError(let message): + return "Failed to parse response: \(message)" + case .emptyResponse: + return "Empty response body" + } + } +} diff --git a/Sources/Userpilot/Remote/SDKSettingsDetector.swift b/Sources/Userpilot/Remote/SDKSettingsDetector.swift index c0525b8e..a2a3a8c9 100644 --- a/Sources/Userpilot/Remote/SDKSettingsDetector.swift +++ b/Sources/Userpilot/Remote/SDKSettingsDetector.swift @@ -5,128 +5,5 @@ // Created by Motasem Hamed on 17/09/2024. // Copyright © 2024 Userpilot. All rights reserved. // -// [Brief Description] -// The `SDKSettingsDetector` responsible for fetching SDK settings. -// -// It allows events to be mutated or updated to add additional context or information as needed by conforming classes. -// - -import Foundation - -/** - * `SDKSettingsDetectoring` is a protocol that defines the interface for classes - * responsible for fetching SDK settings. - */ -internal protocol SDKSettingsDetectoring: AnyObject { - /// Fetches SDK settings and invokes the provided callback upon completion. - func fetchSettings(callback: @escaping () -> Void) -} - -/** - * `SDKSettingsDetector` is a class that implements `SDKSettingsDetectoring` protocol. - * It fetches SDK settings from a remote server and updates the storage with the obtained data. - */ -internal class SDKSettingsDetector { - - // MARK: - Properties - - /// Base URL for the SDK settings API. - private let baseURL = "https://find.userpilot.io/v1/lookups/" - - /// A configuration instance holding SDK-related configuration details. - private let config: Userpilot.Config - - /// Logger instance for logging information and errors. - private let logger: Logging - - /// Storage used to store user-related data. - private let storage: DataStoring - - /// URLSession to request API - private let session: URLSession - - // MARK: - Initialization - - /** - * Initializes the `SDKSettingsDetector` with dependencies from the provided dependency injection container. - * - * - Parameter container: The dependency injection container holding references to required services. - */ - init(container: DIContainer) { - self.config = container.resolve(Userpilot.Config.self) - self.storage = container.resolve(DataStoring.self) - self.logger = config.logger - self.session = URLSession.shared - } - - // Custom (test) initializer -#if DEBUG - init(container: DIContainer, session: URLSession) { - self.config = container.resolve(Userpilot.Config.self) - self.storage = container.resolve(DataStoring.self) - self.logger = config.logger - self.session = session - } -#endif - -} - -// MARK: - SDKSettingsDetectoring - -extension SDKSettingsDetector: SDKSettingsDetectoring { - - // Fetches SDK settings from the remote server and updates the storage with the obtained data. - // swiftlint:disable:next cyclomatic_complexity function_body_length superfluous_disable_command - func fetchSettings(callback: @escaping () -> Void) { - if let configurationDate = storage.configurationDate, storage.socketURL.isNotEmpty { - let difference = Date().timeIntervalSince(configurationDate) - if difference < GeneralConstants.CONFIGURATION_DURATION { - callback() - return - } - } - - let urlString = baseURL + config.token - guard let url = URL(string: urlString) else { - self.logger.error("Invalid URL: %{public}@", urlString) - callback() - return - } - - let task = session.dataTask(with: URLRequest(url: url)) { [weak self] data, response, error in - guard let self else { - callback() - return - } - - if let error { - self.logger.error("Request failed: %{public}@", error.localizedDescription) - callback() - return - } - - guard let httpResponse = response as? HTTPURLResponse, httpResponse.isSuccessStatusCode else { - if let httpResponse = response as? HTTPURLResponse { - self.logger.error("Request failed with code: %{public}@", String(httpResponse.statusCode)) - } - callback() - return - } - if let data, let responseBody = String(data: data, encoding: .utf8) { - self.logger.info("SDK Settings response: %{public}@", responseBody) - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let endpoint = json["endpoint"] as? String, let url = endpoint.baseURL() { - self.storage.configurationDate = Date() - self.storage.socketURL = url + GeneralConstants.PATH_NAME - } - } catch { - self.logger.error("Failed to parse JSON: %{public}@", error.localizedDescription) - } - } - callback() - } - task.resume() - } -} +// SDK settings fetching now lives in UserpilotRemoteSource. diff --git a/Sources/Userpilot/Remote/UserpilotRemoteSource.swift b/Sources/Userpilot/Remote/UserpilotRemoteSource.swift new file mode 100644 index 00000000..a292691b --- /dev/null +++ b/Sources/Userpilot/Remote/UserpilotRemoteSource.swift @@ -0,0 +1,297 @@ +// +// UserpilotRemoteSource.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 17/09/2024. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// The `UserpilotRemoteSource` responsible for fetching SDK settings. +// +// It allows events to be mutated or updated to add additional context or information as needed by conforming classes. +// + +import Foundation + +// MARK: - Protocol + +internal protocol UserpilotRemoteSourcing: AnyObject { + /// Fetches SDK settings and invokes the provided callback with Result type. + func fetchSettings(completion: @escaping (Result) -> Void) + + /// Fetches preview experience from the public content API using Result type. + func fetchPreviewExperience( + params: PreviewExperienceQueryParams, + completion: @escaping (Result) -> Void + ) +} + +/// `UserpilotRemoteSource` is a class that handles all remote API interactions. +/// It fetches SDK settings and preview experiences from remote servers. +internal class UserpilotRemoteSource { + + // MARK: - Properties + + /// A configuration instance holding SDK-related configuration details. + private let config: Userpilot.Config + + /// Logger instance for logging information and errors. + private let logger: Logging + + /// Storage used to store user-related data. + private let storage: DataStoring + + /// URLSession to request API + private let session: URLSession + + // MARK: - Initialization + + /** + * Initializes the `UserpilotRemoteSource` with dependencies from the provided dependency injection container. + * + * - Parameter container: The dependency injection container holding references to required services. + */ + init(container: DIContainer) { + self.config = container.resolve(Userpilot.Config.self) + self.storage = container.resolve(DataStoring.self) + self.logger = config.logger + self.session = URLSession.shared + } + + // Custom (test) initializer + #if DEBUG + init(container: DIContainer, session: URLSession) { + self.config = container.resolve(Userpilot.Config.self) + self.storage = container.resolve(DataStoring.self) + self.logger = config.logger + self.session = session + } + #endif + + // MARK: - Private Helper Methods + + /// Checks if cached SDK configuration is still valid. + private func shouldUseCachedConfiguration() -> Bool { + guard let configurationDate = storage.configurationDate, + storage.socketURL.isNotEmpty + else { + return false + } + + let elapsedTime = Date().timeIntervalSince(configurationDate) + return elapsedTime < RemoteSource.configurationDuration + } + + // Returns appropriate error message based on HTTP status code. + // swiftlint:disable:next cyclomatic_complexity + private func getErrorMessage(statusCode: Int) -> String { + switch statusCode { + case 400: + return "Bad request: The content request is invalid" + case 401: + return "Unauthorized: Invalid or missing authentication" + case 403: + return "Forbidden: Access to this content is denied" + case 404: + return "Not found: The requested content could not be found" + case 408: + return "Request timeout: The server took too long to respond" + case 429: + return "Too many requests: Please try again later" + case 500: + return "Server error: The server encountered an internal error" + case 502: + return "Bad gateway: The server received an invalid response" + case 503: + return "Service unavailable: The server is temporarily unavailable" + case 504: + return "Gateway timeout: The server did not respond in time" + default: + return "Request failed with status code: \(statusCode)" + } + } + + /// Builds the remote settings URL using the SDK token. + private func buildSettingsUrl() -> String { + return RemoteSource.settingsBaseURL + config.token + } + + /// Builds the URL for fetching preview experience content. + private func buildPreviewExperienceUrl(params: PreviewExperienceQueryParams) -> String { + var components = URLComponents(string: params.baseUrl) + components?.queryItems = [ + URLQueryItem(name: "app_token", value: params.appToken), + URLQueryItem(name: "content_type", value: params.contentType), + URLQueryItem(name: "content_id", value: params.contentId) + ] + return components?.url?.absoluteString ?? params.baseUrl + } + + /// Executes a network request and returns Result with success or failure. + private func executeRequest( + url: String, + completion: @escaping (Result) -> Void + ) { + guard let requestUrl = URL(string: url) else { + logger.error("❌ Invalid URL: %{public}@", url) + completion(.failure(.invalidURL)) + return + } + + logger.info("✅ Request URL: %{public}@", url) + + let task = session.dataTask(with: URLRequest(url: requestUrl)) { [weak self] data, response, error in + guard let self else { + completion(.failure(.networkError("Request cancelled"))) + return + } + + if let error { + self.logger.error("🌐 Network request failed: %{public}@", error.localizedDescription) + completion(.failure(.networkError(error.localizedDescription))) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + self.logger.error("❌ Invalid response type") + completion(.failure(.invalidResponse)) + return + } + + guard httpResponse.isSuccessStatusCode else { + let errorMessage = self.getErrorMessage(statusCode: httpResponse.statusCode) + self.logger.error("❌ Request failed with code: %{public}@", String(httpResponse.statusCode)) + completion( + .failure(.httpError(statusCode: httpResponse.statusCode, message: errorMessage)) + ) + return + } + + guard let data else { + self.logger.error("📭 Request failed: Empty response body") + completion(.failure(.emptyResponse)) + return + } + + self.logger.info("✅ Request successful: %{public}@", String(httpResponse.statusCode)) + completion(.success(data)) + } + task.resume() + } + + /// Parses SDK settings response and updates socket URL storage. Returns Result. + private func handleSettingsResponse(_ data: Data) -> Result { + if let responseBody = String(data: data, encoding: .utf8) { + logger.info("⚙️ SDK settings response: %{public}@", responseBody) + } + + do { + guard + let json = try JSONSerialization.jsonObject(with: data, options: []) + as? [String: Any], + let endpoint = json["endpoint"] as? String, + let baseUrl = endpoint.baseURL() + else { + logger.error("🔍 Failed to extract endpoint from response") + return .failure(.decodingError("Failed to extract endpoint from response")) + } + + storage.configurationDate = Date() + storage.socketURL = baseUrl + RemoteSource.socketPath + logger.info("🔗 Socket URL updated: %{public}@", storage.socketURL) + return .success(()) + } catch { + logger.error("💥 Failed to parse settings response: %{public}@", error.localizedDescription) + return .failure(.decodingError(error.localizedDescription)) + } + } + + /// Parses preview experience API response and returns result. + private func handlePreviewExperienceResponse(_ data: Data) -> Result< + PreviewExperience, RemoteSourceError + > { + logger.info("📱 Preview experience response received") + + do { + let decoder = JSONDecoder() + let previewExperience = try decoder.decode(PreviewExperience.self, from: data) + logger.info("✅ Successfully parsed Preview experience") + return .success(previewExperience) + } catch { + logger.error("💥 Failed to process Preview experience: %{public}@", error.localizedDescription) + return .failure(.decodingError(error.localizedDescription)) + } + } +} + +// MARK: - UserpilotRemoteSourcing + +extension UserpilotRemoteSource: UserpilotRemoteSourcing { + + /** + * Fetches SDK settings from the remote server and updates storage with the socket URL. + * + * This method implements caching logic to avoid unnecessary network calls. Settings are + * refreshed only if the cached configuration is older than the configuration duration. + * + * - Parameter completion: Closure invoked with Result containing Void on success or RemoteSourceError on failure + */ + func fetchSettings(completion: @escaping (Result) -> Void) { + if shouldUseCachedConfiguration() { + logger.info("📋 Using cached SDK settings") + completion(.success(())) + return + } + + let url = buildSettingsUrl() + executeRequest(url: url) { [weak self] result in + guard let self else { + completion(.failure(.networkError("Request cancelled"))) + return + } + + switch result { + case .success(let data): + let result = self.handleSettingsResponse(data) + completion(result) + case .failure(let error): + self.logger.error( + "❌ Failed to fetch settings: %{public}@", error.localizedDescription) + completion(.failure(error)) + } + } + } + + /** + * Fetches preview experience from the public content API. + * + * This method retrieves survey or other content types based on the provided parameters. The + * response is parsed into a PreviewExperience object using Codable. + * + * - Parameters: + * - params: Query parameters for the content request + * - completion: Closure invoked with Result containing PreviewExperience or RemoteSourceError + */ + func fetchPreviewExperience( + params: PreviewExperienceQueryParams, + completion: @escaping (Result) -> Void + ) { + let url = buildPreviewExperienceUrl(params: params) + executeRequest(url: url) { [weak self] result in + guard let self else { + completion(.failure(.networkError("Request cancelled"))) + return + } + + switch result { + case .success(let data): + let result = self.handlePreviewExperienceResponse(data) + completion(result) + case .failure(let error): + self.logger.error( + "❌ Failed to fetch preview experience: %{public}@", error.localizedDescription) + completion(.failure(error)) + } + } + } +} diff --git a/Sources/Userpilot/Remote/Utils/DynamicCodingKeys.swift b/Sources/Userpilot/Remote/Utils/DynamicCodingKeys.swift index 88edaeae..413984c0 100644 --- a/Sources/Userpilot/Remote/Utils/DynamicCodingKeys.swift +++ b/Sources/Userpilot/Remote/Utils/DynamicCodingKeys.swift @@ -10,7 +10,6 @@ // import Foundation -import os.log internal struct DynamicCodingKeys: CodingKey { var stringValue: String diff --git a/Sources/Userpilot/Socket/SocketManager.swift b/Sources/Userpilot/Socket/SocketManager.swift index 34877660..0eb5cbdc 100644 --- a/Sources/Userpilot/Socket/SocketManager.swift +++ b/Sources/Userpilot/Socket/SocketManager.swift @@ -46,7 +46,7 @@ internal protocol SocketEvents: AnyObject { func registerCallback(_ socketSubscription: SocketSubscription) } -internal extension SocketEvents { +extension SocketEvents { func publish( _ eventName: String, @@ -173,7 +173,7 @@ internal class SocketManager { private let logger: Logging /// SDK settings detector. - private let sdkSettingsDetector: SDKSettingsDetectoring + private let userpilotRemoteSource: UserpilotRemoteSourcing /// socket susbcriber @Multicast var socketSubscription: SocketSubscription @@ -181,6 +181,9 @@ internal class SocketManager { // track socket state private var socketState: SocketState = .closed + // track fetching socket settings + private lazy var isFetchingSocketSettings: AtomicReference = AtomicReference(false) + // MARK: - Initialization /** @@ -193,7 +196,7 @@ internal class SocketManager { self.config = container.resolve(Userpilot.Config.self) self.storage = container.resolve(DataStoring.self) self.autoPropertyDecorator = container.resolve(AutoPropertyDecoratoring.self) - self.sdkSettingsDetector = container.resolve(SDKSettingsDetectoring.self) + self.userpilotRemoteSource = container.resolve(UserpilotRemoteSourcing.self) self.logger = config.logger } @@ -277,6 +280,7 @@ extension SocketManager { self.logger.info("🚀 SOCKET channel joined") self.updateSocketState(.opened) self.$socketSubscription.invoke { $0.onSocketOpened() } + self.isFetchingSocketSettings.value = false } ) .delegateReceive( @@ -286,17 +290,20 @@ extension SocketManager { "⚠️ SOCKET channel join failed: %{public}@", message.payload) self.updateSocketState(.error) self.closeSocket() + self.isFetchingSocketSettings.value = false }) phoenixChannel?.onError { [weak self] message in self?.logger.error("❗ SOCKET Channel error: %{public}@", message.payload) self?.updateSocketState(.error) self?.closeSocket() + self?.isFetchingSocketSettings.value = false } phoenixChannel?.onClose { [weak self] message in self?.logger.debug("🛑 SOCKET Channel close: %{public}@", message.payload) self?.updateSocketState(.closed) + self?.isFetchingSocketSettings.value = false } // Connect the socket @@ -371,20 +378,24 @@ extension SocketManager: SocketEvents { /// Implementation to open a WebSocket connection func connect() { - if config.token.isEmpty || storage.userId.isEmpty { - updateSocketState(.closed) + if config.token.isEmpty || storage.userId.isEmpty || isSocketOpened || isJoiningSocket { return } - - // Prevent multiple connection attempts when already connecting or connected - guard socketState != .connecting && !isSocketOpened else { - logger.debug("🔄 SOCKET already connecting or opened, skipping connect") + if !isFetchingSocketSettings.compareAndSet(expected: false, new: true) { return } - - updateSocketState(.connecting) - sdkSettingsDetector.fetchSettings { [weak self] in - self?.openSocket() + logger.debug("🚥 Socket connection is establishing...") + userpilotRemoteSource.fetchSettings { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.openSocket() + case .failure(let error): + self.logger.error( + "❗ Failed to fetch SDK settings: %{public}@, socket connection aborted", + error.localizedDescription) + self.isFetchingSocketSettings.value = false + } } } @@ -436,7 +447,7 @@ extension SocketManager: SocketEvents { // MARK: - Properties name -internal extension SocketManager { +extension SocketManager { // Static constants private static let channelTopic = "events:*" diff --git a/Sources/Userpilot/Storage/Models/User.swift b/Sources/Userpilot/Storage/Models/User.swift index fb712cf9..5da88568 100644 --- a/Sources/Userpilot/Storage/Models/User.swift +++ b/Sources/Userpilot/Storage/Models/User.swift @@ -174,7 +174,7 @@ extension User { } } -enum ComparisonStrategy { +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 diff --git a/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md new file mode 100644 index 00000000..4f19c4f2 --- /dev/null +++ b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md @@ -0,0 +1,76 @@ +# Configuring the Userpilot URL Scheme + +The Userpilot iOS SDK includes support for a custom URL scheme that supports previewing Userpilot experiences. + +## Overview + +Configuring the Userpilot URL scheme involves adding a `CFBundleURLTypes` value and then directing the incoming URL to the Userpilot iOS SDK. + +## Register the Custom URL Scheme + +Update your `Info.plist` to register the custom URL scheme. Replace `USERPILOT_TOKEN` in the snippet below with your app's Userpilot Token. This value can be obtained from your [Environments Page](https://run.userpilot.io/environment). + +For example, if your Userpilot Token is `NX-12345678` your url scheme value would be `userpilot-nx-12345678`. + +``` +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + userpilot-USERPILOT_TOKEN + + + +``` + +## Handle the Custom URL Scheme + +Custom URL's should be handled with a call to ``Userpilot/filterAndHandle(_:)`` or ``Userpilot/didHandleURL(_:)``. If the URL being opened is an Userpilot URL, the URL will be handled. + +If your app uses a Scene delegate, add the following: + +```swift +func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Handle Userpilot deep links. + let unhandledURLContexts = userpilot.filterAndHandle(connectionOptions.urlContexts) + + // Handle any links remaining in unhandledURLContexts. +} + +func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + // Handle Userpilot deep links. + let unhandledURLContexts = userpilot.filterAndHandle(URLContexts) + + // Handle any links remaining in unhandledURLContexts. +} +``` + +If your app uses only an App delegate, add the following: + +```swift +func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:] ) -> Bool { + // Handle Userpilot deep links. + guard !userpilot.didHandleURL(url) else { return true } + + // Handle a non-Userpilot URL. + return false +} +``` + +A SwiftUI app can handle the custom URL scheme as part of the `onOpenURL` modifier associated with the `Scene` of your main `App`: + +```swift +var body: some Scene { + WindowGroup { + MyApp() + .onOpenURL { url in + guard !userpilot.didHandleURL(url) else { return } + } + } +} +``` diff --git a/Sources/Userpilot/Userpilot.swift b/Sources/Userpilot/Userpilot.swift index 41e5419c..acd48d47 100644 --- a/Sources/Userpilot/Userpilot.swift +++ b/Sources/Userpilot/Userpilot.swift @@ -219,11 +219,14 @@ public class Userpilot: NSObject { container.registerLazy( AutoPropertyDecoratoring.self, initializer: AutoPropertyDecorator.init) container.registerLazy(SocketEvents.self, initializer: SocketManager.init) - container.registerLazy(SDKSettingsDetectoring.self, initializer: SDKSettingsDetector.init) + container.registerLazy(UserpilotRemoteSourcing.self, initializer: UserpilotRemoteSource.init) container.registerLazy(ThemeHandling.self, initializer: ThemeHandler.init) container.registerLazy(ImageLoading.self, initializer: ImageLoader.init) container.registerLazy(ScreenNameTracking.self, initializer: ScreenNameTracker.init) container.registerLazy(AutoCaptureCoordinating.self, initializer: AutoCaptureCoordinater.init) + container.registerLazy(DeepLinkHandling.self, initializer: DeepLinkHandler.init) + container.registerLazy(LinkOpening.self, initializer: LinkOpener.init) + container.registerLazy(ExperienceStateManaging.self, initializer: ExperienceStateManager.init) container.registerEager(DataStoring.self, initializer: Storage.init) container.registerEager(AnalyticsPublishing.self, initializer: AnalyticsPublisher.init) container.registerEager( @@ -597,4 +600,28 @@ extension Userpilot { } +// MARK: - Deep links + +extension Userpilot { + + /// Verifies if an incoming URL is intended for the Userpilot SDK. + /// - Parameter url: The URL being opened. + /// - Returns: `true` if the URL matches the Userpilot URL Scheme or `false` if the URL is not + /// known by the Userpilot SDK. + /// + /// If the `url` is an Userpilot URL, this function may launch an experience or otherwise alter + /// the UI state. + /// + /// This function is intended to be called added at the top of your + /// `UIApplicationDelegate`'s `application(_:open:options:)` function: + /// ```swift + /// guard !userpilot.didHandleURL(url) else { return true } + /// ``` + @discardableResult + @objc + public func didHandleURL(_ url: URL) -> Bool { + return container.resolve(DeepLinkHandling.self).didHandleURL(url) + } + +} // swiftlint:enable file_length diff --git a/Sources/Userpilot/Utilities/Helper/Constants.swift b/Sources/Userpilot/Utilities/Helper/Constants.swift index 7d9c3f0e..c25cc1aa 100644 --- a/Sources/Userpilot/Utilities/Helper/Constants.swift +++ b/Sources/Userpilot/Utilities/Helper/Constants.swift @@ -31,6 +31,14 @@ internal struct GeneralConstants { static let CONFIGURATION_DURATION = TimeInterval(30 * 60) } +internal struct RemoteSource { + static let settingsBaseURL = "https://find.userpilot.io/v1/lookups/" + static let socketPath = "/mobile/v1/events/websocket" + static let experienceBaseURL = + "https://appex-dev-nxtapp-14664.userpilot.io/api/v1/public/content" + static let configurationDuration = TimeInterval(30 * 60) // 30 minutes +} + /// Dictionary / JSON keys for Auto Capture payloads (UIKit, SwiftUI helpers, and screen context). internal struct AutoCaptureConstants { diff --git a/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift b/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift new file mode 100644 index 00000000..2c201fc0 --- /dev/null +++ b/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift @@ -0,0 +1,161 @@ +// +// DeepLinkHandler.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 16/10/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// Handles deep link URLs for the Userpilot SDK, including experience preview links. +// Manages deferred URL handling when app scenes are not yet active. +// + +import UIKit + +internal protocol DeepLinkHandling: AnyObject { + func didHandleURL(_ url: URL) -> Bool +} + +internal class DeepLinkHandler: DeepLinkHandling { + + // MARK: - Nested Types + + enum Action: Hashable { + /// Preview for draft content with experience ID and optional query parameters + case preview(experienceID: String, queryItems: [URLQueryItem]) + + init?(url: URL, token: String) { + let scheme = url.scheme?.lowercased() + // QR code link uses prod token always so we need to convert the stg token to prod token + let normalizedToken = token.replacingOccurrences(of: "STG-", with: "", options: .caseInsensitive) + let isValidScheme = scheme == "userpilot-\(normalizedToken)".lowercased() + guard isValidScheme, url.host == "sdk" else { return nil } + + // Supported paths: + // userpilot-{token}://sdk/experience_preview/{experience_id} + + let pathTokens = url.path.split(separator: "/").map { String($0) } + + if pathTokens.count == 2, pathTokens[0] == "experience_preview" { + self = .preview(experienceID: pathTokens[1], queryItems: url.queryItems) + } else { + return nil + } + } + } + + // MARK: - Properties + + private weak var container: DIContainer? + private lazy var config = container?.resolve(Userpilot.Config.self) + private lazy var logger = container?.resolve(Userpilot.Config.self).logger + + /// This is a set because a `SceneDelegate` has a `Set` to handle. + private var actionsToHandle: Set = [] + + /// Dependency for getting the top view controller. Can be mocked for testing. + var topControllerGetting: TopControllerGetting = UIApplication.shared + + // MARK: - Initialization + + init(container: DIContainer) { + self.container = container + } + + // MARK: - DeepLinkHandling + + func didHandleURL(_ url: URL) -> Bool { + guard + let token = config?.token, + let action = Action(url: url, token: token) + else { + logger?.debug("🔗 Deep link not handled: %{public}@", url.absoluteString) + return false + } + + logger?.info("🔗 Deep link handling: %{public}@", url.absoluteString) + + if Thread.isMainThread { + dispatch(action: action) + } else { + DispatchQueue.main.async { [weak self] in + self?.dispatch(action: action) + } + } + + return true + } + + // MARK: - Private Methods + + /// Dispatches an action either immediately or defers it until a scene becomes active. + private func dispatch(action: Action) { + if topControllerGetting.hasActiveWindowScenes { + // UIScene is already active and we can handle the action immediately. + handle(action: action) + } else if actionsToHandle.isEmpty { + actionsToHandle.insert(action) + + // Set up a single observer to trigger handling any action(s). + NotificationCenter.default.addObserver( + self, + selector: #selector(sceneDidActivate), + name: UIScene.didActivateNotification, + object: nil + ) + logger?.debug("🔗 Deep link deferred until scene activates") + } else { + actionsToHandle.insert(action) + } + } + + /// Handles the deep link action by triggering the appropriate SDK functionality. + private func handle(action: Action) { + tryCatch { + switch action { + case let .preview(experienceID, queryItems): + logger?.info("🚀 Triggering experience preview: %{public}@", experienceID) + container?.resolve(ExperiencesPublishing.self).triggerPreviewExperience( + experienceID, + queryItems + ) + } + } + } + + @objc + private func sceneDidActivate() { + logger?.info("✅ Scene activated, handling %d deferred deep link(s)", actionsToHandle.count) + + actionsToHandle.forEach(handle(action:)) + + // Reset after handling to avoid handling notifications multiple times. + actionsToHandle.removeAll() + NotificationCenter.default.removeObserver( + self, name: UIScene.didActivateNotification, object: nil) + } +} + +// MARK: - Public API + +// Note: this is an extension compared to `Userpilot.didHandle(_:)` because `UIOpenURLContext` is part of UIKit. +extension Userpilot { + + /// Verifies if an incoming URL is intended for the Userpilot SDK. + /// - Parameter URLContexts: One or more `UIOpenURLContext` objects. + /// Each object contains one URL to open and any additional information needed to open that URL. + /// - Returns: The set of `UIOpenURLContext` objects that were not intended for the Userpilot SDK. + /// + /// If the `url` is an Userpilot URL, this function may launch a flow preview or otherwise alter the UI state. + /// + /// This function is intended to be called added at the top of your + /// `UISceneDelegate`'s `scene(_:openURLContexts:)` function: + /// ```swift + /// guard !userpilot.filterAndHandle(URLContexts) else { return } + /// ``` + @available(iOS 13.0, *) + @discardableResult + @objc + public func filterAndHandle(_ URLContexts: Set) -> Set { + URLContexts.filter { !didHandleURL($0.url) } + } +} diff --git a/Sources/Userpilot/Utilities/Link/LinkOpener.swift b/Sources/Userpilot/Utilities/Link/LinkOpener.swift new file mode 100644 index 00000000..1597bda1 --- /dev/null +++ b/Sources/Userpilot/Utilities/Link/LinkOpener.swift @@ -0,0 +1,101 @@ +// +// LinkOpener.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 16/10/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// Handles opening URLs either through a custom navigation delegate, in-app browser, +// or external browser/scheme handler based on SDK configuration. +// + +import SafariServices +import UIKit + +internal protocol LinkOpening: AnyObject { + func handleURL(_ url: URL) +} + +internal class LinkOpener: LinkOpening { + + // MARK: - Properties + + private weak var userpilot: Userpilot? + private let config: Userpilot.Config + private let logger: Logging + + /// Dependency for getting the top view controller and opening URLs. Can be mocked for testing. + var urlOpener: TopControllerGetting & URLOpening = UIApplication.shared + + // MARK: - Initialization + + init(container: DIContainer) { + self.userpilot = container.owner + self.config = container.resolve(Userpilot.Config.self) + self.logger = config.logger + } + + // MARK: - LinkOpening + + func handleURL(_ url: URL) { + tryCatch { + guard let userpilot = userpilot else { + logger.error("❌ Cannot open URL - Userpilot instance is nil") + return + } + + // If a delegate is provided from the host application, preference is to use it for + // handling navigation and invoking the completion handler. + if let delegate = userpilot.navigationDelegate { + logger.info("🔗 UserpilotNavigationDelegate opening %{private}@", url.absoluteString) + delegate.navigate(to: url) + return + } + + // If no delegate provided, fall back to automatic handling behavior provided by the + // UIApplication - caveat, the completion callback may execute before the app has + // fully navigated to the destination. + + // SFSafariViewController only supports HTTP and HTTPS URLs and crashes otherwise, + // and scheme links crash the universal link opener, so check here to be sure we route safely. + if url.isWebLink { + if config.useInAppBrowser { + openInAppBrowser(url) + } else { + openExternalBrowser(url) + } + } else { + openSchemeLink(url) + } + } + } + + // MARK: - Private Methods + + /// Opens a URL in the in-app Safari view controller. + private func openInAppBrowser(_ url: URL) { + logger.info("🌐 In-app browser opening %{private}@", url.absoluteString) + + guard let topViewController = urlOpener.topViewController() else { + logger.error("❌ Cannot present in-app browser - no top view controller available") + // Fallback to external browser + openExternalBrowser(url) + return + } + + let safariVC = SFSafariViewController(url: url) + topViewController.present(safariVC, animated: true) + } + + /// Opens a URL in the device's external browser (Safari). + private func openExternalBrowser(_ url: URL) { + logger.info("🌍 External browser opening %{private}@", url.absoluteString) + urlOpener.open(url) + } + + /// Opens a URL with a custom scheme (e.g., mailto:, tel:, app-specific schemes). + private func openSchemeLink(_ url: URL) { + logger.info("🔗 Scheme link opening %{private}@", url.absoluteString) + urlOpener.open(url) + } +} diff --git a/Sources/Userpilot/Utilities/Threading/AtomicReference.swift b/Sources/Userpilot/Utilities/Threading/AtomicReference.swift new file mode 100644 index 00000000..b969a073 --- /dev/null +++ b/Sources/Userpilot/Utilities/Threading/AtomicReference.swift @@ -0,0 +1,124 @@ +// +// AtomicReference.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 23/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// A simple thread-safe atomic reference wrapper. +// + +import Foundation + +/// A thread-safe generic reference value that may be updated atomically. +/// This class mimics the behavior of Kotlin's AtomicReference. +internal class AtomicReference { + private var _value: T + private let lock = NSLock() + + /// Creates a new AtomicReference with the given initial value. + /// - Parameter initialValue: The initial value + init(_ initialValue: T) { + self._value = initialValue + } + + /// Gets or sets the current value atomically. + var value: T { + get { + lock.lock() + defer { lock.unlock() } + return _value + } + set { + lock.lock() + defer { lock.unlock() } + _value = newValue + } + } + + /// Atomically sets the value to the given updated value and returns the old value. + /// - Parameter newValue: The new value + /// - Returns: The previous value + func getAndSet(_ newValue: T) -> T { + lock.lock() + defer { lock.unlock() } + let oldValue = _value + _value = newValue + return oldValue + } + + /// Atomically sets the value to the given updated value if the current value equals the expected value. + /// - Parameters: + /// - expected: The expected value + /// - new: The new value + /// - Returns: true if successful, false if the actual value was not equal to the expected value + @discardableResult + func compareAndSet(expected: T, new: T) -> Bool where T: Equatable { + lock.lock() + defer { lock.unlock() } + if _value == expected { + _value = new + return true + } + return false + } + + /// Performs an atomic operation with the current value. + /// - Parameter block: A closure that receives the current value and returns a new value + /// - Returns: The new value after the operation + @discardableResult + func update(_ block: (T) -> T) -> T { + lock.lock() + defer { lock.unlock() } + _value = block(_value) + return _value + } + + /// Performs an atomic read operation with the current value. + /// - Parameter block: A closure that receives the current value + /// - Returns: The result of the block + func read(_ block: (T) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return block(_value) + } +} + +// MARK: - Usage Example +/* +let atomicRef = AtomicReference("initial") + +// Simple get/set +atomicRef.value = "updated" +print(atomicRef.value) // "updated" + +// Get and set atomically +let oldValue = atomicRef.getAndSet("newest") +print("Old: \(oldValue), New: \(atomicRef.value)") // Old: updated, New: newest + +// Compare and set +let success = atomicRef.compareAndSet(expected: "newest", new: "final") +print("CAS Success: \(success), Value: \(atomicRef.value)") // true, final + +// Update with transformation +let newValue = atomicRef.update { current in + return current.uppercased() +} +print("New value: \(newValue)") // FINAL + +// Thread-safe usage +let ref = AtomicReference(0) +let group = DispatchGroup() + +for i in 0..<1000 { + group.enter() + DispatchQueue.global().async { + ref.update { $0 + 1 } + group.leave() + } +} + +group.wait() +print("Final value: \(ref.value)") // 1000 +*/ diff --git a/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift b/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift new file mode 100644 index 00000000..f82931bd --- /dev/null +++ b/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift @@ -0,0 +1,267 @@ +// +// ExperienceStateManagerTests.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 23/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all +final class ExperienceStateManagerTests: XCTestCase { + + private var userpilot: MockUserpilot! + private var logger: MockLogger! + private var stateManager: ExperienceStateManaging! + + override func setUp() { + super.setUp() + let config = Userpilot.Config(token: "NX-\(UUID().uuidString)").defaultInstance(false) + logger = MockLogger() + config.logger = logger + userpilot = MockUserpilot(config: config) + stateManager = ExperienceStateManager(container: userpilot.container) + } + + override func tearDown() { + stateManager = nil + logger = nil + userpilot = nil + super.tearDown() + } + + // MARK: - Initial State + + func testInitialState_isIdle() { + // Assert + if case .idle = stateManager.getCurrentState() { + XCTAssertTrue(true) + } else { + XCTFail("Expected idle state") + } + XCTAssertFalse(stateManager.isActive()) + XCTAssertFalse(stateManager.isManualTrigger()) + XCTAssertFalse(stateManager.isPreviewMode()) + XCTAssertFalse(stateManager.hasCachedExperience()) + } + + // MARK: - State Transitions + + func testMarkManualTrigger_setsPendingManualAndBypassesScreenValidation() { + // Act + stateManager.markManualTrigger("exp-123") + + // Assert + if case .pendingManual(let experienceId) = stateManager.getCurrentState() { + XCTAssertEqual(experienceId, "exp-123") + } else { + XCTFail("Expected pendingManual state") + } + XCTAssertTrue(stateManager.isManualTrigger()) + XCTAssertTrue(stateManager.shouldBypassScreenValidation()) + XCTAssertTrue(logger.loggedInfos.contains(where: { $0.contains("PendingManual") })) + } + + func testMarkAutomaticTrigger_setsPendingAutomaticWithoutScreenBypass() { + // Arrange + let content = makeExperienceContent() + + // Act + stateManager.markAutomaticTrigger(content) + + // Assert + if case .pendingAutomatic(let storedContent) = stateManager.getCurrentState() { + XCTAssertEqual(storedContent?.experienceId(), content.experienceId()) + } else { + XCTFail("Expected pendingAutomatic state") + } + XCTAssertFalse(stateManager.isManualTrigger()) + XCTAssertFalse(stateManager.shouldBypassScreenValidation()) + XCTAssertTrue(logger.loggedInfos.contains(where: { $0.contains("PendingAutomatic") })) + } + + func testMarkPreviewMode_setsPendingPreviewAndBypassesScreenValidation() { + // Act + stateManager.markPreviewMode() + + // Assert + if case .pendingPreview = stateManager.getCurrentState() { + XCTAssertTrue(true) + } else { + XCTFail("Expected pendingPreview state") + } + XCTAssertTrue(stateManager.isPreviewMode()) + XCTAssertTrue(stateManager.shouldBypassScreenValidation()) + } + + func testMarkWaitingDelay_isActiveButNotActivelyRendered() { + // Act + stateManager.markWaitingDelay(.manual) + + // Assert + if case .waitingDelay(let triggerType) = stateManager.getCurrentState() { + XCTAssertEqual(triggerType, .manual) + } else { + XCTFail("Expected waitingDelay state") + } + XCTAssertTrue(stateManager.isActive()) + XCTAssertFalse(stateManager.isActivelyRendered()) + XCTAssertTrue(stateManager.isManualTrigger()) + } + + func testMarkActive_storesContentAndTriggerType() { + // Arrange + let content = makeExperienceContent() + + // Act + stateManager.markActive(.automatic, content) + + // Assert + if case .active(let triggerType, let activeContent) = stateManager.getCurrentState() { + XCTAssertEqual(triggerType, .automatic) + XCTAssertEqual(activeContent.experienceId(), content.experienceId()) + } else { + XCTFail("Expected active state") + } + XCTAssertTrue(stateManager.isActive()) + XCTAssertTrue(stateManager.isActivelyRendered()) + XCTAssertEqual(stateManager.getActiveContent()?.experienceId(), content.experienceId()) + XCTAssertEqual(stateManager.getActiveTriggerType(), .automatic) + } + + func testMarkShowingThankYou_isActiveAndActivelyRendered() { + // Act + stateManager.markShowingThankYou() + + // Assert + if case .showingThankYou = stateManager.getCurrentState() { + XCTAssertTrue(true) + } else { + XCTFail("Expected showingThankYou state") + } + XCTAssertTrue(stateManager.isActive()) + XCTAssertTrue(stateManager.isActivelyRendered()) + } + + func testMarkCachedManual_setsCachedExperienceId() { + // Act + stateManager.markCachedManual("cached-exp") + + // Assert + if case .cachedPendingManual(let experienceId) = stateManager.getCurrentState() { + XCTAssertEqual(experienceId, "cached-exp") + } else { + XCTFail("Expected cachedPendingManual state") + } + XCTAssertTrue(stateManager.hasCachedExperience()) + XCTAssertEqual(stateManager.getCachedExperienceId(), "cached-exp") + } + + func testMarkCachedAutomatic_setsCachedExperienceContent() { + // Arrange + let content = makeExperienceContent() + + // Act + stateManager.markCachedAutomatic(content) + + // Assert + if case .cachedPendingAutomatic(let cachedContent) = stateManager.getCurrentState() { + XCTAssertEqual(cachedContent.experienceId(), content.experienceId()) + } else { + XCTFail("Expected cachedPendingAutomatic state") + } + XCTAssertTrue(stateManager.hasCachedExperience()) + XCTAssertEqual(stateManager.getCachedExperienceContent()?.experienceId(), content.experienceId()) + } + + func testMarkIdle_clearsActiveStateAndComponent() { + // Arrange + let content = makeExperienceContent() + let component = MockExperienceComponent() + stateManager.markActive(.manual, content) + stateManager.setActiveComponent(component) + + // Act + stateManager.markIdle() + + // Assert + if case .idle = stateManager.getCurrentState() { + XCTAssertTrue(true) + } else { + XCTFail("Expected idle state") + } + XCTAssertFalse(stateManager.isActive()) + XCTAssertNil(stateManager.getActiveComponent()) + } + + // MARK: - Component Management + + func testSetActiveComponent_storesWeakComponentReference() { + // Arrange + let content = makeExperienceContent() + let component = MockExperienceComponent() + stateManager.markActive(.manual, content) + + // Act + stateManager.setActiveComponent(component) + + // Assert + XCTAssertNotNil(stateManager.getActiveComponent()) + XCTAssertTrue(stateManager.isActiveComponentAlive()) + XCTAssertTrue(logger.loggedInfos.contains(where: { $0.contains("component set") })) + } + + // MARK: - High-Level Operations + + func testMarkActiveFromCurrentState_usesManualTrigger() { + // Arrange + stateManager.markManualTrigger("exp-123") + let content = makeExperienceContent() + + // Act + stateManager.markActiveFromCurrentState(content: content) + + // Assert + XCTAssertEqual(stateManager.getActiveTriggerType(), .manual) + } + + func testMarkActiveFromCurrentState_usesPreviewTrigger() { + // Arrange + stateManager.markPreviewMode() + let content = makeExperienceContent() + + // Act + stateManager.markActiveFromCurrentState(content: content) + + // Assert + XCTAssertEqual(stateManager.getActiveTriggerType(), .preview) + } + + func testMarkActiveFromCurrentState_defaultsToAutomaticTrigger() { + // Arrange + stateManager.markAutomaticTrigger(nil) + let content = makeExperienceContent() + + // Act + stateManager.markActiveFromCurrentState(content: content) + + // Assert + XCTAssertEqual(stateManager.getActiveTriggerType(), .automatic) + } + + private func makeExperienceContent() -> ExperienceContent { + .survey(content: MockContentFactory.makeSurveyContent(id: Int.random(in: 1...10_000))) + } +} + +private final class MockExperienceComponent: UPExperience { + var onTriggerClose: ((Bool) -> Void)? + + func triggerCloseExperience(manualClose: Bool, completion: (() -> Void)?) { + onTriggerClose?(manualClose) + completion?() + } +} +// swiftlint:enable all diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 7ee42fbb..50d2dacc 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -79,6 +79,63 @@ final class ExperiencesPublisherTests: XCTestCase { } } + func testTriggerExperience_shouldCacheManualTrigger_WhenActiveExperienceExists() { + // Arrange + let mockVC = MockUPExperience() + experiencesPublisher.mockActiveExperience(experience: mockVC) + var publishedEvent: SDKEvent? + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + publishedEvent = event + } + + // Act + experiencesPublisher.triggerExperience("cached-experience-id") + + // Assert + let expectation = XCTestExpectation(description: "manual trigger cached") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertNil(publishedEvent) + XCTAssertTrue(self.userpilot.experienceStateManager.hasCachedExperience()) + XCTAssertEqual(self.userpilot.experienceStateManager.getCachedExperienceId(), "cached-experience-id") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testPublishInternalSDKEvent_shouldReplayCachedManualTrigger_WhenExperienceCloses() { + // Arrange + let cacheExpectation = XCTestExpectation(description: "manual trigger cached") + let replayExpectation = XCTestExpectation(description: "cached manual trigger replayed") + let mockVC = MockUPExperience() + experiencesPublisher.mockActiveExperience(experience: mockVC) + + var publishedExperienceIds: [String] = [] + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + if let event = event as? ExperienceContentEvent { + publishedExperienceIds.append(event.experienceId) + replayExpectation.fulfill() + } + } + + experiencesPublisher.triggerExperience("cached-experience-id") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + cacheExpectation.fulfill() + } + wait(for: [cacheExpectation], timeout: 1.0) + + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 77] + ) + + // Act + experiencesPublisher.publishInternalSDKEvent(closeEvent) + + // Assert + wait(for: [replayExpectation], timeout: 1.0) + XCTAssertEqual(publishedExperienceIds, ["cached-experience-id"]) + } + // MARK: - endExperience Tests func testEndExperience_shouldTriggerCloseOnTopViewController() { @@ -139,16 +196,14 @@ final class ExperiencesPublisherTests: XCTestCase { // MARK: - triggerDeepLink Tests - func testTriggerDeepLink_shouldCallNavigationDelegate_WhenDelegateExists() { + func testTriggerDeepLink_shouldForwardURLToLinkOpener() { // Arrange let testURL = URL(string: "https://example.com")! - let mockDelegate = MockNavigationDelegate() - userpilot.navigationDelegate = mockDelegate let expectation = XCTestExpectation(description: "Wait for deep link handling") - var navigatedURL: URL? - mockDelegate.onNavigate = { url in - navigatedURL = url + var handledURL: URL? + userpilot.linkOpener.onHandleURL = { url in + handledURL = url expectation.fulfill() } @@ -157,7 +212,7 @@ final class ExperiencesPublisherTests: XCTestCase { // Assert wait(for: [expectation], timeout: 2.0) - XCTAssertEqual(navigatedURL, testURL) + XCTAssertEqual(handledURL, testURL) } // MARK: - Socket Event Tests @@ -378,6 +433,36 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertEqual(publishedEvent?.eventName, "test-event") } + func testPublishInternalSDKEvent_shouldSuppressAnalytics_WhenPreviewModeIsActive() { + // Arrange + var publishedEvent: SDKEvent? + userpilot.experienceStateManager.markPreviewMode() + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + publishedEvent = event + } + + // Act + experiencesPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "test-event")) + + // Assert + XCTAssertNil(publishedEvent) + } + + func testPublishInternalSDKEvent_shouldResetPreviewMode_WhenPreviewExperienceCloses() { + // Arrange + userpilot.experienceStateManager.markPreviewMode() + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 77] + ) + + // Act + experiencesPublisher.publishInternalSDKEvent(closeEvent) + + // Assert + XCTAssertFalse(userpilot.experienceStateManager.isPreviewMode()) + } + func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForCloseNPSEvent() { // Arrange userpilot.socketManager.isSocketOpened = true @@ -457,6 +542,48 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + // MARK: - Preview Experience Tests + + func testTriggerPreviewExperience_shouldFetchPreviewContentWithQueryType() { + // Arrange + let expectation = XCTestExpectation(description: "preview fetch requested") + var capturedParams: PreviewExperienceQueryParams? + userpilot.remoteSource.onFetchPreviewExperience = { params, completion in + capturedParams = params + completion(.failure(.emptyResponse)) + expectation.fulfill() + } + + // Act + experiencesPublisher.triggerPreviewExperience( + "preview-123", + [URLQueryItem(name: "type", value: "survey")] + ) + + // Assert + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(capturedParams?.appToken, userpilot.config.token) + XCTAssertEqual(capturedParams?.contentType, "survey") + XCTAssertEqual(capturedParams?.contentId, "preview-123") + XCTAssertEqual(capturedParams?.baseUrl, RemoteSource.experienceBaseURL) + } + + func testTriggerPreviewExperience_shouldEnterPreviewModeBeforeFetching() { + // Arrange + let expectation = XCTestExpectation(description: "preview mode entered") + userpilot.remoteSource.onFetchPreviewExperience = { _, completion in + XCTAssertTrue(self.userpilot.experienceStateManager.isPreviewMode()) + completion(.failure(.emptyResponse)) + expectation.fulfill() + } + + // Act + experiencesPublisher.triggerPreviewExperience("preview-123", []) + + // Assert + wait(for: [expectation], timeout: 1.0) + } + // MARK: - Thread Safety Tests func testThreadSafety_multipleSimultaneousAccess() { diff --git a/Tests/UserpilotTests/Extensions/FoundationExtensionsTests.swift b/Tests/UserpilotTests/Extensions/FoundationExtensionsTests.swift index 47a10923..6ca5e7ff 100644 --- a/Tests/UserpilotTests/Extensions/FoundationExtensionsTests.swift +++ b/Tests/UserpilotTests/Extensions/FoundationExtensionsTests.swift @@ -18,10 +18,10 @@ final class FoundationExtensionsTests: XCTestCase { } func testURLHttpSchemeDetection() throws { - XCTAssertTrue(try XCTUnwrap(URL(string: "http://example.com")).isHttpOrHttps) - XCTAssertTrue(try XCTUnwrap(URL(string: "https://example.com")).isHttpOrHttps) - XCTAssertFalse(try XCTUnwrap(URL(string: "ftp://example.com")).isHttpOrHttps) - XCTAssertFalse(try XCTUnwrap(URL(string: "mailto:test@example.com")).isHttpOrHttps) + XCTAssertTrue(try XCTUnwrap(URL(string: "http://example.com")).isWebLink) + XCTAssertTrue(try XCTUnwrap(URL(string: "https://example.com")).isWebLink) + XCTAssertFalse(try XCTUnwrap(URL(string: "ftp://example.com")).isWebLink) + XCTAssertFalse(try XCTUnwrap(URL(string: "mailto:test@example.com")).isWebLink) } func testHTTPURLResponseSuccessStatusCodeRange() throws { diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 17928ed7..0fb3fd88 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -38,10 +38,13 @@ class MockUserpilot: Userpilot { container.register(PushNotificationMonitoring.self, value: pushNotificationMonitor) container.register(SocketEvents.self, value: socketManager) container.register(ExperiencesPublishing.self, value: experiencesPublisher) + container.register(UserpilotRemoteSourcing.self, value: remoteSource) container.register(AutoPropertyDecoratoring.self, value: autoPropertyDecorator) - container.register(SDKSettingsDetectoring.self, value: sdkSettingsDetectorer) container.register(ThemeHandling.self, value: themeHandler) container.register(ImageLoading.self, value: imageLoader) + container.register(LinkOpening.self, value: linkOpener) + experienceStateManager = ExperienceStateManager(container: container) + container.register(ExperienceStateManaging.self, value: experienceStateManager) // Real screen tracker + autocapture coordinator (lazy: only built when a test // resolves `autoCaptureCoordinator`). `ScreenNameTracker.init` ignores its // container, and `AutoCaptureCoordinater.init` resolves the mocks registered @@ -62,10 +65,12 @@ class MockUserpilot: Userpilot { var pushNotificationMonitor = MockPushNotificationMonitor() var socketManager = MockSocketManager() var experiencesPublisher = MockExperiencesPublisher() + var remoteSource = MockUserpilotRemoteSource() var autoPropertyDecorator = MockAutoPropertyDecoratorer() - var sdkSettingsDetectorer = MockSDKSettingsDetectorer() var themeHandler = MockThemeHandler() var imageLoader = MockImageLoader() + var linkOpener = MockLinkOpening() + var experienceStateManager: ExperienceStateManaging! var mockLogger = MockLogger() } @@ -117,17 +122,6 @@ class MockThemeHandler: ThemeHandling { } -// MARK: - Mock DKSettings Detectorer - -class MockSDKSettingsDetectorer: SDKSettingsDetectoring { - - var onFetchSettings: (() -> Void)? - func fetchSettings(callback: @escaping () -> Void) { - onFetchSettings?() - callback() - } -} - // MARK: - Mock Auto Property Decoratorer class MockAutoPropertyDecoratorer: AutoPropertyDecoratoring { @@ -149,6 +143,11 @@ class MockAutoPropertyDecoratorer: AutoPropertyDecoratoring { // MARK: - Mock Experiences Publisher class MockExperiencesPublisher: ExperiencesPublishing { + var previewExperienceMode = false + func isPreviewExperienceMode() -> Bool { + return previewExperienceMode + } + var onGetActiveMobileContent: (() -> ExperienceContent)? func getActiveMobileContent() -> ExperienceContent? { return onGetActiveMobileContent?() ?? nil @@ -164,6 +163,11 @@ class MockExperiencesPublisher: ExperiencesPublishing { onTriggerExperience?(experienceId) } + var onTriggerPreviewExperience: ((String, [URLQueryItem]) -> Void)? + func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) { + onTriggerPreviewExperience?(experienceId, queryItems) + } + var onEndExperience: ((Bool) -> Void)? func endExperience(manualClose: Bool) { onEndExperience?(manualClose) @@ -206,6 +210,33 @@ class MockExperiencesPublisher: ExperiencesPublishing { } +// MARK: - Mock Userpilot Remote Source + +class MockUserpilotRemoteSource: UserpilotRemoteSourcing { + var onFetchSettings: ((@escaping (Result) -> Void) -> Void)? + func fetchSettings(completion: @escaping (Result) -> Void) { + onFetchSettings?(completion) ?? completion(.success(())) + } + + var onFetchPreviewExperience: + ((PreviewExperienceQueryParams, @escaping (Result) -> Void) -> Void)? + func fetchPreviewExperience( + params: PreviewExperienceQueryParams, + completion: @escaping (Result) -> Void + ) { + onFetchPreviewExperience?(params, completion) ?? completion(.failure(.emptyResponse)) + } +} + +// MARK: - Mock Link Opening + +class MockLinkOpening: LinkOpening { + var onHandleURL: ((URL) -> Void)? + func handleURL(_ url: URL) { + onHandleURL?(url) + } +} + // MARK: - Mock Socket Manager class MockSocketManager: SocketEvents { diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift index f4eaff31..776bcdab 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift @@ -42,9 +42,7 @@ final class PushNotificationDeferredTests: PushNotificationMonitorTestCase { completionHandler: completion ) - let navigationDelegate = MockNavigationDelegate() - userpilot.navigationDelegate = navigationDelegate - navigationDelegate.onNavigate = { url in + userpilot.linkOpener.onHandleURL = { url in XCTAssertEqual(url.absoluteString, "app://some-link") linkCompletionExpectation.fulfill() } diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift index ed8fa823..b4746063 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift @@ -103,9 +103,7 @@ final class PushNotificationHandlingTests: PushNotificationMonitorTestCase { host.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in hostEvents.append(event) } vendor.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in vendorEvents.append(event) } - let navigationDelegate = MockNavigationDelegate() - vendor.navigationDelegate = navigationDelegate - navigationDelegate.onNavigate = { url in navigatedURL = url } + vendor.linkOpener.onHandleURL = { url in navigatedURL = url } let userInfo = [AnyHashable: Any].userpilotPushNotification( appToken: vendor.config.token, diff --git a/Tests/UserpilotTests/Remote/SDKSettingsDetectorTests.swift b/Tests/UserpilotTests/Remote/SDKSettingsDetectorTests.swift deleted file mode 100644 index 3054f4c2..00000000 --- a/Tests/UserpilotTests/Remote/SDKSettingsDetectorTests.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// SDKSettingsDetectorTests.swift -// Userpilot SDK -// -// Created by Motasem Hamed on 06/07/2025. -// Copyright © 2025 Userpilot. All rights reserved. -// - -import XCTest -@testable import Userpilot - -final class SDKSettingsDetectorTests: XCTestCase { - - var userpilot: MockUserpilot! - var logger: MockLogger! - var detector: SDKSettingsDetector! - - override func setUpWithError() throws { - super.setUp() - URLProtocolStub.reset() - let config = Userpilot.Config(token: "NX-\(UUID().uuidString)").defaultInstance(false) - logger = MockLogger() - config.logger = logger - userpilot = MockUserpilot(config: config) - userpilot.storage.configurationDate = nil - userpilot.storage.socketURL = "" - - let sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [URLProtocolStub.self] - let testSession = URLSession(configuration: sessionConfig) - - detector = SDKSettingsDetector(container: userpilot.container, session: testSession) - } - - override func tearDownWithError() throws { - URLProtocolStub.reset() - detector = nil - userpilot = nil - logger = nil - try super.tearDownWithError() - } - - // Test 1: Should skip network when config is recent - func testUsesCachedSettingsIfNotExpired() { - // Arrange - var trackedErrorLog = 0 - logger.onError = { _, _ in trackedErrorLog += 1 } - var trackedInfoLog = 0 - logger.onInfo = { _, _ in trackedInfoLog += 1 } - - userpilot.storage.configurationDate = Date() - userpilot.storage.socketURL = "https://socket.userpilot.io/ws" - var callbackCalled = false - - // Act - detector.fetchSettings { - callbackCalled = true - } - - // Assert - XCTAssertTrue(callbackCalled) - XCTAssertEqual(trackedErrorLog, 0) - XCTAssertEqual(trackedInfoLog, 0) - } - - // Test 3: Should store settings from successful response - func testParsesAndStoresSettingsFromValidResponse() { - // Arrange - let expectation = expectation(description: "callback called") - - let json = Data(""" - { "endpoint": "https://socket.userpilot.io" } - """.utf8) - - URLProtocolStub.response = ( - json, - HTTPURLResponse( - url: URL(string: "https://find.userpilot.io/v1/lookups/NX-TEST")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - ), - nil - ) - - // Act - detector.fetchSettings { - expectation.fulfill() - } - - // Assert - wait(for: [expectation], timeout: 1) - XCTAssertEqual(userpilot.storage.socketURL, "https://socket.userpilot.io" + GeneralConstants.PATH_NAME) - XCTAssertNotNil(userpilot.storage.configurationDate) - } - - // Test 4: Should log JSON parse error - func testLogsJsonParseError() { - // Arrange - let expectation = expectation(description: "callback called") - - URLProtocolStub.response = ( - Data("invalid json".utf8), - HTTPURLResponse( - url: URL(string: "https://find.userpilot.io")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - ), - nil - ) - - // Act - detector.fetchSettings { - expectation.fulfill() - } - - // Assert - wait(for: [expectation], timeout: 1) - - XCTAssertTrue(logger.loggedErrors.contains(where: { $0.contains("Failed to parse JSON") })) - } - - // Test 5: Should log HTTP error status code - func testLogsHttpErrorStatusCode() { - // Arrange - let expectation = expectation(description: "callback called") - - URLProtocolStub.response = ( - nil, - HTTPURLResponse(url: URL(string: "https://find.userpilot.io")!, - statusCode: 500, - httpVersion: nil, - headerFields: nil), - nil - ) - - // Act - detector.fetchSettings { - expectation.fulfill() - } - - // Assert - wait(for: [expectation], timeout: 1) - - XCTAssertTrue(logger.loggedErrors.contains(where: { $0.contains("Request failed with code:") })) - } - - // Test 6: Should log error on network failure - func testLogsNetworkError() { - // Arrange - let expectation = expectation(description: "callback called") - - URLProtocolStub.response = ( - nil, - nil, - NSError(domain: NSURLErrorDomain, code: -1009, userInfo: [NSLocalizedDescriptionKey: "No internet"]) - ) - - // Act - detector.fetchSettings { - expectation.fulfill() - } - - // Assert - wait(for: [expectation], timeout: 1) - - XCTAssertTrue(logger.loggedErrors.contains(where: { $0.contains("Request failed") })) - } - -} diff --git a/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift b/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift new file mode 100644 index 00000000..6f1a0990 --- /dev/null +++ b/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift @@ -0,0 +1,444 @@ +// +// UserpilotRemoteSourceTests.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 06/07/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all +final class UserpilotRemoteSourceTests: XCTestCase { + + private var userpilot: MockUserpilot! + private var logger: MockLogger! + private var remoteSource: UserpilotRemoteSource! + + override func setUpWithError() throws { + try super.setUpWithError() + URLProtocolStub.reset() + let config = Userpilot.Config(token: "NX-\(UUID().uuidString)").defaultInstance(false) + logger = MockLogger() + config.logger = logger + userpilot = MockUserpilot(config: config) + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [URLProtocolStub.self] + let testSession = URLSession(configuration: sessionConfig) + remoteSource = UserpilotRemoteSource(container: userpilot.container, session: testSession) + } + + override func tearDownWithError() throws { + URLProtocolStub.reset() + userpilot = nil + logger = nil + remoteSource = nil + try super.tearDownWithError() + } + + // MARK: - Cached Configuration Tests + + func testFetchSettings_usesCachedConfiguration_whenNotExpired() { + // Arrange + userpilot.storage.configurationDate = Date() + userpilot.storage.socketURL = "https://socket.userpilot.io/mobile/v1/events/websocket" + var callbackCalled = false + var isSuccess = false + + // Act + remoteSource.fetchSettings { result in + callbackCalled = true + if case .success = result { + isSuccess = true + } + } + + // Assert + XCTAssertTrue(callbackCalled) + XCTAssertTrue(isSuccess) + XCTAssertTrue(logger.loggedInfos.contains(where: { $0.contains("Using cached SDK settings") })) + } + + func testFetchSettings_makesNetworkRequest_whenCacheExpired() { + // Arrange + let expectation = expectation(description: "callback called") + var isSuccess = false + userpilot.storage.configurationDate = Date().addingTimeInterval(-31 * 60) + userpilot.storage.socketURL = "https://old-socket.userpilot.io/ws" + + URLProtocolStub.response = ( + Data(#"{ "endpoint": "https://new-socket.userpilot.io" }"#.utf8), + HTTPURLResponse( + url: URL(string: "https://find.userpilot.io/v1/lookups/NX-00000")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + // Act + remoteSource.fetchSettings { result in + if case .success = result { + isSuccess = true + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + XCTAssertTrue(isSuccess) + XCTAssertEqual(userpilot.storage.socketURL, "https://new-socket.userpilot.io/mobile/v1/events/websocket") + } + + func testFetchSettings_parsesAndStoresSettings_fromValidResponse() { + // Arrange + let expectation = expectation(description: "callback called") + var isSuccess = false + + URLProtocolStub.response = ( + Data(#"{ "endpoint": "https://socket.userpilot.io" }"#.utf8), + HTTPURLResponse( + url: URL(string: "https://find.userpilot.io/v1/lookups/NX-00000")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + // Act + remoteSource.fetchSettings { result in + if case .success = result { + isSuccess = true + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + XCTAssertTrue(isSuccess) + XCTAssertEqual(userpilot.storage.socketURL, "https://socket.userpilot.io/mobile/v1/events/websocket") + XCTAssertNotNil(userpilot.storage.configurationDate) + XCTAssertTrue(logger.loggedInfos.contains(where: { $0.contains("Socket URL updated") })) + } + + // MARK: - Settings Error Tests + + func testFetchSettings_failsWithDecodingError_whenJsonIsInvalid() { + // Arrange + let expectation = expectation(description: "callback called") + var errorType: RemoteSourceError? + + URLProtocolStub.response = ( + Data("invalid json".utf8), + HTTPURLResponse( + url: URL(string: "https://find.userpilot.io")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + // Act + remoteSource.fetchSettings { result in + if case .failure(let error) = result { + errorType = error + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + if case .decodingError = errorType { + XCTAssertTrue(true) + } else { + XCTFail("Expected decodingError") + } + XCTAssertTrue(logger.loggedErrors.contains(where: { $0.contains("Failed to parse") })) + } + + func testFetchSettings_failsWithDecodingError_whenEndpointMissing() { + // Arrange + let expectation = expectation(description: "callback called") + var errorType: RemoteSourceError? + + URLProtocolStub.response = ( + Data(#"{ "other_field": "value" }"#.utf8), + HTTPURLResponse( + url: URL(string: "https://find.userpilot.io")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + // Act + remoteSource.fetchSettings { result in + if case .failure(let error) = result { + errorType = error + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + if case .decodingError(let message) = errorType { + XCTAssertTrue(message.contains("Failed to extract endpoint")) + } else { + XCTFail("Expected decodingError") + } + } + + func testFetchSettings_failsWithHttpError_when404() { + // Arrange + let expectation = expectation(description: "callback called") + var errorType: RemoteSourceError? + + URLProtocolStub.response = ( + nil, + HTTPURLResponse( + url: URL(string: "https://find.userpilot.io")!, + statusCode: 404, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + // Act + remoteSource.fetchSettings { result in + if case .failure(let error) = result { + errorType = error + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + if case .httpError(let code, let message) = errorType { + XCTAssertEqual(code, 404) + XCTAssertTrue(message.contains("Not found")) + } else { + XCTFail("Expected httpError") + } + } + + func testFetchSettings_failsWithNetworkError_whenNoInternet() { + // Arrange + let expectation = expectation(description: "callback called") + var errorType: RemoteSourceError? + + URLProtocolStub.response = ( + nil, + nil, + NSError( + domain: NSURLErrorDomain, + code: -1009, + userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] + ) + ) + + // Act + remoteSource.fetchSettings { result in + if case .failure(let error) = result { + errorType = error + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + if case .networkError(let message) = errorType { + XCTAssertTrue(message.contains("offline") || message.contains("Internet")) + } else { + XCTFail("Expected networkError") + } + } + + // MARK: - Preview Experience Tests + + func testFetchPreviewExperience_succeeds_withValidSurveyResponse() { + // Arrange + let expectation = expectation(description: "callback called") + var result: PreviewExperience? + + URLProtocolStub.response = ( + Data( + """ + { + "content_type": "survey", + "theme": { + "id": 4, + "theme_data": { + "button": { + "background_color": "#4E4CE8", + "border_color": "#4E4CE8", + "label_color": "#FFFFFF" + }, + "colors": { + "background_color": "#FFFFFF", + "text_color": "#000000", + "title_color": "#000000" + }, + "general": { + "font_family": "Default", + "content_alignment": "top" + }, + "progress": { + "enabled": true, + "color": "#4E4CE8", + "color_type": "automatic" + } + } + }, + "survey": { + "id": 12, + "token": "survey:12", + "type": "step", + "modules": [ + { + "id": 101, + "type": "likert_scale", + "question": "How satisfied are you with the app?", + "is_required": true, + "metadata": { + "high_score": "Very satisfied", + "low_score": "Not at all", + "range": 5, + "type": "stars" + }, + "logic": null, + "button_label": "Next" + } + ], + "metadata": { + "cta_label": "Start survey" + }, + "theme_data": { + "id": 4 + }, + "screens": [], + "screen_type": "all", + "locale_code": "default", + "time_delay": 0 + } + } + """.utf8 + ), + HTTPURLResponse( + url: URL(string: RemoteSource.experienceBaseURL)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + let params = PreviewExperienceQueryParams( + baseUrl: RemoteSource.experienceBaseURL, + appToken: userpilot.config.token, + contentType: "survey", + contentId: "123" + ) + + // Act + remoteSource.fetchPreviewExperience(params: params) { response in + if case .success(let experience) = response { + result = experience + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + XCTAssertEqual(result?.contentType, "survey") + XCTAssertNotNil(result?.survey) + XCTAssertNotNil(result?.theme) + } + + func testFetchPreviewExperience_failsWithDecodingError_whenInvalidJson() { + // Arrange + let expectation = expectation(description: "callback called") + var errorType: RemoteSourceError? + + URLProtocolStub.response = ( + Data("invalid json".utf8), + HTTPURLResponse( + url: URL(string: RemoteSource.experienceBaseURL)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + let params = PreviewExperienceQueryParams( + baseUrl: RemoteSource.experienceBaseURL, + appToken: userpilot.config.token, + contentType: "survey", + contentId: "123" + ) + + // Act + remoteSource.fetchPreviewExperience(params: params) { response in + if case .failure(let error) = response { + errorType = error + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + if case .decodingError = errorType { + XCTAssertTrue(true) + } else { + XCTFail("Expected decodingError") + } + } + + func testFetchPreviewExperience_failsWithHttpError_when404() { + // Arrange + let expectation = expectation(description: "callback called") + var errorType: RemoteSourceError? + + URLProtocolStub.response = ( + nil, + HTTPURLResponse( + url: URL(string: RemoteSource.experienceBaseURL)!, + statusCode: 404, + httpVersion: nil, + headerFields: nil + ), + nil + ) + + let params = PreviewExperienceQueryParams( + baseUrl: RemoteSource.experienceBaseURL, + appToken: userpilot.config.token, + contentType: "survey", + contentId: "999" + ) + + // Act + remoteSource.fetchPreviewExperience(params: params) { response in + if case .failure(let error) = response { + errorType = error + } + expectation.fulfill() + } + + // Assert + wait(for: [expectation], timeout: 1) + if case .httpError(let code, _) = errorType { + XCTAssertEqual(code, 404) + } else { + XCTFail("Expected httpError") + } + } +} +// swiftlint:enable all diff --git a/Tests/UserpilotTests/UserpilotTests.swift b/Tests/UserpilotTests/UserpilotTests.swift index 872fea0b..f5b52990 100644 --- a/Tests/UserpilotTests/UserpilotTests.swift +++ b/Tests/UserpilotTests/UserpilotTests.swift @@ -355,7 +355,9 @@ class UserpilotTests: XCTestCase { weak var weakSessionMonitoring: SessionMonitoring? weak var weakAutoPropertyDecoration: AutoPropertyDecoratoring? weak var weakSocketManager: SocketEvents? - weak var weakSDKSettingsDetector: SDKSettingsDetectoring? + weak var weakRemoteSource: UserpilotRemoteSourcing? + weak var weakExperienceStateManager: ExperienceStateManaging? + weak var weakLinkOpener: LinkOpening? weak var weakThemeHandler: ThemeHandling? weak var weakImageLoader: ImageLoading? weak var weakPushNotificationMonitor: PushNotificationMonitoring? @@ -373,7 +375,9 @@ class UserpilotTests: XCTestCase { weakSessionMonitoring = userpilot.container.resolve(SessionMonitoring.self) weakAutoPropertyDecoration = userpilot.container.resolve(AutoPropertyDecoratoring.self) weakSocketManager = userpilot.container.resolve(SocketEvents.self) - weakSDKSettingsDetector = userpilot.container.resolve(SDKSettingsDetectoring.self) + weakRemoteSource = userpilot.container.resolve(UserpilotRemoteSourcing.self) + weakExperienceStateManager = userpilot.container.resolve(ExperienceStateManaging.self) + weakLinkOpener = userpilot.container.resolve(LinkOpening.self) weakThemeHandler = userpilot.container.resolve(ThemeHandling.self) weakImageLoader = userpilot.container.resolve(ImageLoading.self) weakPushNotificationMonitor = userpilot.container.resolve(PushNotificationMonitoring.self) @@ -394,7 +398,9 @@ class UserpilotTests: XCTestCase { XCTAssertNil(weakSessionMonitoring) XCTAssertNil(weakAutoPropertyDecoration) XCTAssertNil(weakSocketManager) - XCTAssertNil(weakSDKSettingsDetector) + XCTAssertNil(weakRemoteSource) + XCTAssertNil(weakExperienceStateManager) + XCTAssertNil(weakLinkOpener) XCTAssertNil(weakThemeHandler) XCTAssertNil(weakImageLoader) XCTAssertNil(weakPushNotificationMonitor) diff --git a/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift b/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift new file mode 100644 index 00000000..7fb250c7 --- /dev/null +++ b/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift @@ -0,0 +1,252 @@ +// +// DeepLinkHandlerTests.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 13/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all +final class DeepLinkHandlerTests: XCTestCase { + + private var deepLinkHandler: DeepLinkHandler! + private var mockUserpilot: MockUserpilot! + private var mockTopControllerGetting: MockTopControllerGetting! + private var mockExperiencesPublisher: MockExperiencesPublisher! + + override func setUp() { + super.setUp() + let config = Userpilot.Config(token: "NX-12345").defaultInstance(false) + mockUserpilot = MockUserpilot(config: config) + mockExperiencesPublisher = mockUserpilot.experiencesPublisher + deepLinkHandler = DeepLinkHandler(container: mockUserpilot.container) + mockTopControllerGetting = MockTopControllerGetting() + deepLinkHandler.topControllerGetting = mockTopControllerGetting + } + + override func tearDown() { + deepLinkHandler = nil + mockUserpilot = nil + mockTopControllerGetting = nil + mockExperiencesPublisher = nil + super.tearDown() + } + + // MARK: - Action Init Tests + + func testAction_validPreviewURL_createsAction() { + // Arrange + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + + // Act + let action = DeepLinkHandler.Action(url: url, token: "NX-12345") + + // Assert + if case let .preview(experienceID, queryItems) = action { + XCTAssertEqual(experienceID, "EX-123") + XCTAssertTrue(queryItems.isEmpty) + } else { + XCTFail("Expected preview action") + } + } + + func testAction_previewURLWithQueryItems_createsActionWithQueryItems() { + // Arrange + let url = URL( + string: "userpilot-nx-12345://sdk/experience_preview/EX-123?locale_id=en-US&type=survey" + )! + + // Act + let action = DeepLinkHandler.Action(url: url, token: "NX-12345") + + // Assert + if case let .preview(experienceID, queryItems) = action { + XCTAssertEqual(experienceID, "EX-123") + XCTAssertEqual(queryItems.count, 2) + XCTAssertTrue(queryItems.contains(where: { $0.name == "locale_id" && $0.value == "en-US" })) + XCTAssertTrue(queryItems.contains(where: { $0.name == "type" && $0.value == "survey" })) + } else { + XCTFail("Expected preview action") + } + } + + func testAction_invalidScheme_returnsNil() { + let url = URL(string: "wrongscheme://sdk/experience_preview/EX-123")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + + func testAction_invalidHost_returnsNil() { + let url = URL(string: "userpilot-nx-12345://wronghost/experience_preview/EX-123")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + + func testAction_invalidPath_returnsNil() { + let url = URL(string: "userpilot-nx-12345://sdk/invalid_path/EX-123")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + + func testAction_missingExperienceID_returnsNil() { + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + + func testAction_wrongToken_returnsNil() { + let url = URL(string: "userpilot-nx-99999://sdk/experience_preview/EX-123")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + + func testAction_stagingToken_acceptsProdScheme() { + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + XCTAssertNotNil(DeepLinkHandler.Action(url: url, token: "STG-NX-12345")) + } + + func testAction_caseInsensitiveScheme_createsAction() { + let url = URL(string: "USERPILOT-NX-12345://sdk/experience_preview/EX-123")! + XCTAssertNotNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + + // MARK: - DidHandleURL Tests + + func testDidHandleURL_validURL_returnsTrue() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = true + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + + // Act + let handled = deepLinkHandler.didHandleURL(url) + + // Assert + XCTAssertTrue(handled) + } + + func testDidHandleURL_invalidURL_returnsFalse() { + let url = URL(string: "https://example.com")! + XCTAssertFalse(deepLinkHandler.didHandleURL(url)) + } + + func testDidHandleURL_validURL_triggersPreviewExperience() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = true + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + var triggeredExperienceID: String? + mockExperiencesPublisher.onTriggerPreviewExperience = { id, _ in + triggeredExperienceID = id + } + + // Act + _ = deepLinkHandler.didHandleURL(url) + + // Assert + XCTAssertEqual(triggeredExperienceID, "EX-123") + } + + func testDidHandleURL_validURLWithQueryItems_passesQueryItems() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = true + let url = URL( + string: "userpilot-nx-12345://sdk/experience_preview/EX-123?locale_id=en-US&type=survey" + )! + var capturedQueryItems: [URLQueryItem]? + mockExperiencesPublisher.onTriggerPreviewExperience = { _, queryItems in + capturedQueryItems = queryItems + } + + // Act + _ = deepLinkHandler.didHandleURL(url) + + // Assert + XCTAssertEqual(capturedQueryItems?.count, 2) + XCTAssertTrue(capturedQueryItems?.contains(where: { $0.name == "locale_id" && $0.value == "en-US" }) ?? false) + XCTAssertTrue(capturedQueryItems?.contains(where: { $0.name == "type" && $0.value == "survey" }) ?? false) + } + + // MARK: - Deferred Action Tests + + func testDidHandleURL_whenScenesNotActive_defersAction() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = false + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + var triggeredExperienceID: String? + mockExperiencesPublisher.onTriggerPreviewExperience = { id, _ in + triggeredExperienceID = id + } + + // Act + _ = deepLinkHandler.didHandleURL(url) + + // Assert + XCTAssertNil(triggeredExperienceID) + } + + func testDidHandleURL_deferredAction_triggersWhenSceneActivates() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = false + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + var triggeredExperienceID: String? + mockExperiencesPublisher.onTriggerPreviewExperience = { id, _ in + triggeredExperienceID = id + } + _ = deepLinkHandler.didHandleURL(url) + + // Act + NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil) + + // Assert + XCTAssertEqual(triggeredExperienceID, "EX-123") + } + + func testDidHandleURL_multipleDeferredActions_allTriggerWhenSceneActivates() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = false + let url1 = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + let url2 = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-456")! + var triggeredExperienceIDs: [String] = [] + mockExperiencesPublisher.onTriggerPreviewExperience = { id, _ in + triggeredExperienceIDs.append(id) + } + _ = deepLinkHandler.didHandleURL(url1) + _ = deepLinkHandler.didHandleURL(url2) + + // Act + NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil) + + // Assert + XCTAssertEqual(triggeredExperienceIDs.count, 2) + XCTAssertTrue(triggeredExperienceIDs.contains("EX-123")) + XCTAssertTrue(triggeredExperienceIDs.contains("EX-456")) + } + + func testDidHandleURL_fromBackgroundThread_handlesOnMainThread() { + // Arrange + mockTopControllerGetting.hasActiveWindowScenes = true + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + var triggeredExperienceID: String? + let expectation = expectation(description: "preview triggered") + mockExperiencesPublisher.onTriggerPreviewExperience = { id, _ in + triggeredExperienceID = id + expectation.fulfill() + } + + // Act + DispatchQueue.global().async { + _ = self.deepLinkHandler.didHandleURL(url) + } + + // Assert + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(triggeredExperienceID, "EX-123") + } +} + +private final class MockTopControllerGetting: TopControllerGetting { + var hasActiveWindowScenes = true + var topViewControllerToReturn: UIViewController? + + func topViewController() -> UIViewController? { + topViewControllerToReturn + } +} +// swiftlint:enable all diff --git a/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift b/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift new file mode 100644 index 00000000..d8ed262d --- /dev/null +++ b/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift @@ -0,0 +1,211 @@ +// +// LinkOpenerTests.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 13/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all +final class LinkOpenerTests: XCTestCase { + + private var linkOpener: LinkOpener! + private var mockUserpilot: MockUserpilot! + private var urlOpener: MockURLOpener! + private var navigationDelegate: MockNavigationDelegate! + + override func setUp() { + super.setUp() + let config = Userpilot.Config(token: "NX-\(UUID().uuidString)").defaultInstance(false) + mockUserpilot = MockUserpilot(config: config) + linkOpener = LinkOpener(container: mockUserpilot.container) + urlOpener = MockURLOpener() + linkOpener.urlOpener = urlOpener + navigationDelegate = MockNavigationDelegate() + } + + override func tearDown() { + linkOpener = nil + mockUserpilot = nil + urlOpener = nil + navigationDelegate = nil + super.tearDown() + } + + // MARK: - Navigation Delegate Tests + + func testHandleURL_withNavigationDelegate_usesDelegate() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + let url = URL(string: "https://example.com")! + var capturedURL: URL? + navigationDelegate.onNavigate = { url in + capturedURL = url + } + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertEqual(capturedURL, url) + XCTAssertFalse(urlOpener.openCalled) + XCTAssertFalse(urlOpener.topViewControllerCalled) + } + + func testHandleURL_withNavigationDelegate_doesNotCallURLOpener() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + let url = URL(string: "myapp://deeplink")! + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertFalse(urlOpener.openCalled) + } + + // MARK: - Web Link Tests + + func testHandleURL_httpLink_withInAppBrowser_opensInSafariVC() { + // Arrange + mockUserpilot.config.useInAppBrowser = true + let url = URL(string: "http://example.com")! + urlOpener.topViewControllerToReturn = UIViewController() + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertTrue(urlOpener.topViewControllerCalled) + XCTAssertFalse(urlOpener.openCalled) + } + + func testHandleURL_httpsLink_withInAppBrowser_opensInSafariVC() { + // Arrange + mockUserpilot.config.useInAppBrowser = true + let url = URL(string: "https://example.com")! + urlOpener.topViewControllerToReturn = UIViewController() + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertTrue(urlOpener.topViewControllerCalled) + XCTAssertFalse(urlOpener.openCalled) + } + + func testHandleURL_httpLink_withoutInAppBrowser_opensExternally() { + // Arrange + mockUserpilot.config.useInAppBrowser = false + let url = URL(string: "http://example.com")! + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertTrue(urlOpener.openCalled) + XCTAssertEqual(urlOpener.lastOpenedURL, url) + } + + func testHandleURL_httpsLink_withoutInAppBrowser_opensExternally() { + // Arrange + mockUserpilot.config.useInAppBrowser = false + let url = URL(string: "https://example.com")! + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertTrue(urlOpener.openCalled) + XCTAssertEqual(urlOpener.lastOpenedURL, url) + } + + func testHandleURL_inAppBrowserWithoutTopViewController_fallsBackToExternal() { + // Arrange + mockUserpilot.config.useInAppBrowser = true + let url = URL(string: "https://example.com")! + urlOpener.topViewControllerToReturn = nil + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertTrue(urlOpener.topViewControllerCalled) + XCTAssertTrue(urlOpener.openCalled) + XCTAssertEqual(urlOpener.lastOpenedURL, url) + } + + // MARK: - Scheme Link Tests + + func testHandleURL_mailtoScheme_opensExternally() { + assertOpensExternally("mailto:test@example.com") + } + + func testHandleURL_telScheme_opensExternally() { + assertOpensExternally("tel:+1234567890") + } + + func testHandleURL_smsScheme_opensExternally() { + assertOpensExternally("sms:+1234567890") + } + + func testHandleURL_customScheme_opensExternally() { + assertOpensExternally("myapp://action/something") + } + + // MARK: - Edge Cases + + func testHandleURL_uppercaseHTTP_treatedAsWebLink() { + // Arrange + mockUserpilot.config.useInAppBrowser = true + urlOpener.topViewControllerToReturn = UIViewController() + let url = URL(string: "HTTP://EXAMPLE.COM")! + + // Act + linkOpener.handleURL(url) + + // Assert + XCTAssertTrue(urlOpener.topViewControllerCalled) + XCTAssertFalse(urlOpener.openCalled) + } + + func testHandleURL_urlWithQueryParameters_handledCorrectly() { + assertOpensExternally("https://example.com/path?param1=value1¶m2=value2") + } + + func testHandleURL_urlWithFragment_handledCorrectly() { + assertOpensExternally("https://example.com/page#section") + } + + private func assertOpensExternally(_ urlString: String) { + let url = URL(string: urlString)! + mockUserpilot.config.useInAppBrowser = false + + linkOpener.handleURL(url) + + XCTAssertTrue(urlOpener.openCalled) + XCTAssertEqual(urlOpener.lastOpenedURL, url) + } +} + +private final class MockURLOpener: TopControllerGetting, URLOpening { + var hasActiveWindowScenes = true + var topViewControllerCalled = false + var topViewControllerToReturn: UIViewController? + var openCalled = false + var lastOpenedURL: URL? + + func topViewController() -> UIViewController? { + topViewControllerCalled = true + return topViewControllerToReturn + } + + func open(_ url: URL) { + openCalled = true + lastOpenedURL = url + } +} +// swiftlint:enable all From dd0520de266ccb98ddc8d013978f4f3d8e705040 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 7 Jul 2026 10:10:11 +0300 Subject: [PATCH 02/95] =?UTF-8?q?=F0=9F=90=9B=20avoid=20preview=20dismissa?= =?UTF-8?q?l=20side=20effects=20and=20seen=20content.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/AnalyticsPublisher.swift | 22 ++++++++----- .../ListView/SurveyListViewController.swift | 12 ++++++- .../Experiences/ExperiencesPublisher.swift | 12 ++++--- .../Analytics/AnalyticsPublisherTests.swift | 30 ++++++++++++++++-- .../ExperiencesPublisherTests.swift | 31 ++++++++++++++++++- Tests/UserpilotTests/MockUserpilot.swift | 10 ++++-- 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index d2bfb92d..32827bc4 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -48,7 +48,11 @@ internal protocol AnalyticsPublishing: AnyObject { ) /// publish fake reload event - func publishFakeReloadScreenEvent(_ experienceType: ExperienceType, _ experienceId: Int?) + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType, + _ experienceId: Int?, + _ markExperienceAsSeen: Bool + ) /// update seen experiences func experiencePublished( @@ -894,16 +898,20 @@ extension AnalyticsPublisher { * - Parameter experienceType: The type of experience (FLOW or SURVEY) * - Parameter experienceId: The ID of the experience being shown */ - func publishFakeReloadScreenEvent(_ experienceType: ExperienceType, _ experienceId: Int?) { + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType, + _ experienceId: Int?, + _ markExperienceAsSeen: Bool + ) { tryCatch { - guard - experienceId != nil, - canRequestEvent - else { return } + guard canRequestEvent else { return } if let screenViewEntity { // update the seen content to make sure it contains the dismissed content that // trigger this fake reload - experiencePublished(experienceType, experienceId!) + if markExperienceAsSeen { + guard let experienceId else { return } + experiencePublished(experienceType, experienceId) + } if eventThrottle.shouldThrottleScreenEvent(screenTitle: screenViewEntity.event.screenTitle ?? "") { return } diff --git a/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift b/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift index 2aa04e61..f882490a 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/ListView/SurveyListViewController.swift @@ -114,6 +114,16 @@ extension SurveyListViewController: UPExperience { manualClose: Bool, completion: (() -> Void)? ) { - closeExperience(completion: completion) + if manualClose { + closeExperience(completion: completion) + } else { + dismiss(animated: true) { [weak self] in + if let completion { + completion() + } else { + self?.surveyViewModel.onExperienceDismissalCompleted() + } + } + } } } diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 86e57309..de90b73b 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -379,7 +379,8 @@ internal class ExperiencesPublisher: ExperiencesPublishing { } analyticsPublisher.publishFakeReloadScreenEvent( sdkEvent.getContentType(), - sdkEvent.getContentId() + sdkEvent.getContentId(), + false ) resetProcessingPreviewExperienceStatus() } @@ -417,7 +418,9 @@ internal class ExperiencesPublisher: ExperiencesPublishing { if sdkEvent.isEventForCloseExperience() && !experienceStateManager.hasCachedExperience() { experienceStateManager.markIdle() analyticsPublisher.publishFakeReloadScreenEvent( - sdkEvent.getContentType(), sdkEvent.getContentId() + sdkEvent.getContentType(), + sdkEvent.getContentId(), + true ) } else if sdkEvent.isEventForCloseExperience() { processCachedExperienceAfterClose() @@ -1007,10 +1010,9 @@ extension ExperiencesPublisher { npsTrackedScreen = "" if hasActiveExperience || experienceStateManager.getActiveComponent() != nil { - endExperience(manualClose: true, completion: completion) + endExperience(manualClose: false, completion: completion) } else { - activeExperience = nil - experienceStateManager.markIdle() + resetProcessingExperienceStatus() hideExperienceOverlayIfIdle() completion?() } diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 4d36451c..6fd95043 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -386,7 +386,7 @@ class AnalyticsPublisherTests: XCTestCase { // Act (add delay before publishing, cause of throttling logic) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10) + self.analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, true) } // Assert (wait for the delayed call) @@ -404,13 +404,39 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.socketManager.onPublish = { _, _, _ in publishScreenEventCalled = true } // Act - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10) + analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, true) // Assert // Would need to verify socket manager publish was called with fake reload flag XCTAssertFalse(publishScreenEventCalled) } + func testPublishFakeReloadScreenEvent_shouldNotAddSeenContent_WhenMarkSeenIsFalse() { + // Arrange + userpilot.socketManager.isSocketOpened = true + let screenEvent = Event(type: .screen("Test Screen")) + analyticsPublisher.publish(screenEvent) + + let expectation = XCTestExpectation(description: "Wait for delayed fake reload publish") + var fakeReloadPayload: Payload = nil + userpilot.socketManager.onPublish = { _, payload, _ in + fakeReloadPayload = payload + expectation.fulfill() + } + + // Act (add delay before publishing, cause of throttling logic) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, false) + } + + // Assert + wait(for: [expectation], timeout: 1.0) + let metadata = fakeReloadPayload?[AnalyticsPublisher.metaDataProperty] as? [String: Any] + let seenContents = metadata?[AnalyticsPublisher.seenContents] as? [Int] + XCTAssertFalse(seenContents?.contains(10) ?? false) + XCTAssertFalse(analyticsPublisher.screenEntity?.seenExperiences.contains(10) ?? false) + } + func testExperiencePublished_shouldUpdateSeenExperiences() { // Arrange let screenEvent = Event(type: .screen("Test Screen")) diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 50d2dacc..50fdd3ab 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -127,6 +127,7 @@ final class ExperiencesPublisherTests: XCTestCase { eventName: SDKEventsName.flowExperienceDismissed.rawValue, eventPayload: ["mobile_content_id": 77] ) + closeEvent.isCloseEvent = true // Act experiencesPublisher.publishInternalSDKEvent(closeEvent) @@ -451,16 +452,22 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldResetPreviewMode_WhenPreviewExperienceCloses() { // Arrange userpilot.experienceStateManager.markPreviewMode() + var markExperienceAsSeen: Bool? + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, markSeen in + markExperienceAsSeen = markSeen + } let closeEvent = MockSDKEvent( eventName: SDKEventsName.flowExperienceDismissed.rawValue, eventPayload: ["mobile_content_id": 77] ) + closeEvent.isCloseEvent = true // Act experiencesPublisher.publishInternalSDKEvent(closeEvent) // Assert XCTAssertFalse(userpilot.experienceStateManager.isPreviewMode()) + XCTAssertEqual(markExperienceAsSeen, false) } func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForCloseNPSEvent() { @@ -505,7 +512,7 @@ final class ExperiencesPublisherTests: XCTestCase { // so watch for that as proof the debounced block ran. let reloadExpectation = XCTestExpectation(description: "debounced fake‑reload published") var publishFakeReloadEventCalled = false - userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _ in + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in publishFakeReloadEventCalled = true reloadExpectation.fulfill() } @@ -584,6 +591,28 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + func testTriggerPreviewExperience_shouldCloseActiveExperienceAsProgrammaticReset() { + // Arrange + let closeExpectation = XCTestExpectation(description: "active experience closed silently") + let fetchExpectation = XCTestExpectation(description: "preview fetch requested") + let mockVC = MockUPExperience() + experiencesPublisher.mockActiveExperience(experience: mockVC) + + mockVC.onTriggerClose = { manualClose in + XCTAssertFalse(manualClose) + closeExpectation.fulfill() + } + userpilot.remoteSource.onFetchPreviewExperience = { _, _ in + fetchExpectation.fulfill() + } + + // Act + experiencesPublisher.triggerPreviewExperience("preview-123", []) + + // Assert + wait(for: [closeExpectation, fetchExpectation], timeout: 1.0) + } + // MARK: - Thread Safety Tests func testThreadSafety_multipleSimultaneousAccess() { diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 0fb3fd88..e838b9ff 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -320,9 +320,13 @@ class MockAnalyticsPublisher: AnalyticsPublishing { onPublishInternalSDKEvent?(sdkEvent, socketSubscription) } - var onPublishFakeReloadScreenEvent: ((ExperienceType, Int?) -> Void)? - func publishFakeReloadScreenEvent(_ experienceType: ExperienceType, _ experienceId: Int?) { - onPublishFakeReloadScreenEvent?(experienceType, experienceId) + var onPublishFakeReloadScreenEvent: ((ExperienceType, Int?, Bool) -> Void)? + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType, + _ experienceId: Int?, + _ markExperienceAsSeen: Bool + ) { + onPublishFakeReloadScreenEvent?(experienceType, experienceId, markExperienceAsSeen) } var onExperiencePublished: ((ExperienceType, Int) -> Void)? From 7b617a11a6c43643209caa8c3f014fe6e863d92e Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 8 Jul 2026 11:32:22 +0300 Subject: [PATCH 03/95] =?UTF-8?q?=E2=9C=A8=20Added=20offline=20event=20per?= =?UTF-8?q?sistence=20and=20restore=20support,=20including=20autocapture?= =?UTF-8?q?=20events.=20=E2=9C=A8=20=20Added=20database/event=20storage=20?= =?UTF-8?q?support=20for=20offline=20events.=20=F0=9F=9A=A7=20Updated=20an?= =?UTF-8?q?alytics=20publishing=20to=20work=20with=20offline=20saving,=20n?= =?UTF-8?q?etwork=20monitoring,=20and=20serialized=20event=20queue=20behav?= =?UTF-8?q?ior.=20=F0=9F=9A=A7=20Improved=20event/event-type=20modeling=20?= =?UTF-8?q?for=20identify,=20track,=20and=20autocapture=20events.=20?= =?UTF-8?q?=E2=9C=85=20=20Refactored=20socket=20tests=20and=20testability.?= =?UTF-8?q?=20=F0=9F=94=A5=20Removed=20unused=20ReadWriteLock=20and=20its?= =?UTF-8?q?=20tests.=20=E2=9C=85=20Added/updated=20tests=20for=20offline?= =?UTF-8?q?=20storage,=20event=20queue,=20atomic=20reference,=20socket,=20?= =?UTF-8?q?analytics,=20database,=20and=20state=20machines.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/AnalyticsPublisher.swift | 1134 ++++++++++------- .../Userpilot/Analytics/Models/Event.swift | 120 +- .../Analytics/Models/EventType.swift | 174 +-- .../Analytics/OfflineEventsHandler.swift | 367 ++++++ .../ScreenSessionStateMachine.swift} | 26 +- .../UserSessionStateMachine.swift | 344 +++++ .../AutoCapture/AutoCaptureCoordinater.swift | 46 +- .../Extensions/ScreenNameResolver.swift | 2 +- .../UICollectionView+AutoCapture.swift | 4 +- .../Extensions/UIControl+AutoCapture.swift | 20 +- .../Extensions/UIPickerView+AutoCapture.swift | 6 +- .../Extensions/UITableView+AutoCapture.swift | 4 +- .../Extensions/UITextInput+AutoCapture.swift | 12 +- .../AutoCapture/Models/InteractionEvent.swift | 28 +- .../Models/ScreenTrackingPayload.swift | 20 +- .../AutoCapture/ScreenNameTracker.swift | 6 +- .../Utils/InteractionEventCache.swift | 2 +- .../AutoCapture/Utils/UIKitViewResolver.swift | 8 +- Sources/Userpilot/DI/DIContainer.swift | 2 +- Sources/Userpilot/DI/Registry.swift | 2 +- .../Userpilot/Database/DatabaseStats.swift | 52 + .../Database/EventDatabaseStorage.swift | 600 +++++++++ Sources/Userpilot/Database/EventStorage.swift | 45 + .../Experiences/Events/SDKEvent.swift | 2 +- ...ger.swift => ExperienceStateMachine.swift} | 8 +- .../Experiences/ExperiencesPublisher.swift | 115 +- .../Extensions/Socket+Extensions.swift | 3 + .../Extensions/String+Extensions.swift | 11 +- .../UIApplication+AutoCapture.swift | 4 +- .../UITabBarController+Extensions.swift | 2 +- .../Extensions/UIWindow+Extensions.swift | 18 +- .../Userpilot/Monitor/NetworkMonitor.swift | 450 +++++++ .../SessionMonitor.swift | 15 +- .../PushNotificationMonitor.swift | 10 +- .../Remote/UserpilotRemoteSource.swift | 8 +- Sources/Userpilot/Socket/SocketManager.swift | 302 ++--- Sources/Userpilot/Userpilot.swift | 20 +- .../Utilities/Helper/AnyCodable.swift | 68 + .../Helper/Constants+AutoCapture.swift | 103 ++ .../Utilities/Helper/Constants.swift | 219 ++-- .../Utilities/Helper/JsonCoder.swift | 32 + .../Utilities/Threading/DelayUtils.swift | 2 +- .../Utilities/Threading/EventDebounce.swift | 2 +- .../Utilities/Threading/EventQueue.swift | 105 ++ .../Utilities/Threading/EventThrottle.swift | 2 +- .../Utilities/Threading/ReadWriteLock.swift | 62 - .../Analytics/AnalyticsPublisherTests.swift | 96 +- .../UserpilotTests/Analytics/EventTests.swift | 17 +- ...t => ScreenSessionStateMachineTests.swift} | 10 +- ...wift => ExperienceStateMachineTests.swift} | 6 +- .../ExperiencesPublisherTests.swift | 31 +- Tests/UserpilotTests/MockUserpilot.swift | 158 ++- .../PushNotificationDeferredTests.swift | 2 +- .../PushNotificationHandlingTests.swift | 8 +- .../PushNotificationMonitorTests.swift | 7 +- .../Remote/UserpilotRemoteSourceTests.swift | 12 +- .../Socket/SocketManagerTests.swift | 546 ++++---- Tests/UserpilotTests/UserpilotTests.swift | 12 +- .../Utilities/Helper/ConstantsTests.swift | 14 +- .../Threading/AtomicReferenceTests.swift | 209 +++ .../Utilities/Threading/DelayUtilsTests.swift | 2 +- .../Utilities/Threading/EventQueueTests.swift | 374 ++++++ .../Threading/ReadWriteLockTests.swift | 105 -- 63 files changed, 4635 insertions(+), 1561 deletions(-) create mode 100644 Sources/Userpilot/Analytics/OfflineEventsHandler.swift rename Sources/Userpilot/Analytics/{Models/ScreenViewEntity.swift => stateMachines/ScreenSessionStateMachine.swift} (50%) create mode 100644 Sources/Userpilot/Analytics/stateMachines/UserSessionStateMachine.swift create mode 100644 Sources/Userpilot/Database/DatabaseStats.swift create mode 100644 Sources/Userpilot/Database/EventDatabaseStorage.swift create mode 100644 Sources/Userpilot/Database/EventStorage.swift rename Sources/Userpilot/Experiences/{ExperienceStateManager.swift => ExperienceStateMachine.swift} (98%) create mode 100644 Sources/Userpilot/Monitor/NetworkMonitor.swift rename Sources/Userpilot/{Analytics => Monitor}/SessionMonitor.swift (92%) create mode 100644 Sources/Userpilot/Utilities/Helper/AnyCodable.swift create mode 100644 Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift create mode 100644 Sources/Userpilot/Utilities/Helper/JsonCoder.swift create mode 100644 Sources/Userpilot/Utilities/Threading/EventQueue.swift delete mode 100644 Sources/Userpilot/Utilities/Threading/ReadWriteLock.swift rename Tests/UserpilotTests/Analytics/{ScreenViewEntityTests.swift => ScreenSessionStateMachineTests.swift} (76%) rename Tests/UserpilotTests/Experiences/{ExperienceStateManagerTests.swift => ExperienceStateMachineTests.swift} (98%) create mode 100644 Tests/UserpilotTests/Utilities/Threading/AtomicReferenceTests.swift create mode 100644 Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift delete mode 100644 Tests/UserpilotTests/Utilities/Threading/ReadWriteLockTests.swift diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index d2bfb92d..d5dd7b56 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -7,8 +7,10 @@ // // [Brief Description] // `AnalyticsPublisher` handles the processing and dispatching of events to the backend. -// It manages event lifecycle, queuing, and socket communication for sending events -// such as `identify`, `screen`, and custom user events. +// Events flow through a serialized, ACK-gated queue: exactly one analytics event is in +// flight at a time and the next one is sent only after the socket resolves the previous +// push (ok, error, or timeout). Events are persisted to local storage while offline and +// replayed as a `batch_events` payload when connectivity returns. // // swiftlint:disable file_length @@ -33,22 +35,22 @@ internal protocol AnalyticsPublishing: AnyObject { func reset() /// Logout user from socket - func logout( - socketState: SocketManager.SocketState, - shouldClearCachedIdentifyEvent: Bool - ) + func logout(clearCachedIdentifyEvent: Bool) /// check socket state var canRequestEvent: Bool { get } /// publish experience event - func publishInternalSDKEvent( - _ sdkEvent: SDKEvent, - socketSubscription: SocketSubscription? - ) + func publishInternalSDKEvent(_ sdkEvent: SDKEvent) /// publish fake reload event - func publishFakeReloadScreenEvent(_ experienceType: ExperienceType, _ experienceId: Int?) + /// - Returns: true when a screen push actually went out + @discardableResult + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType?, + _ experienceId: Int?, + isFakeReload: Bool + ) -> Bool /// update seen experiences func experiencePublished( @@ -59,54 +61,35 @@ internal protocol AnalyticsPublishing: AnyObject { /// For experience which are come from start session var isStartSession: Bool { get } - /// To return current screen entity - var screenEntity: ScreenViewEntity? { get } + /// Current screen-session state. + var screenSessionStateMachine: ScreenSessionStateMachine? { get } +} + +extension AnalyticsPublishing { + + /// Fake reload requested by experience close/dismiss flows. + @discardableResult + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType?, + _ experienceId: Int? + ) -> Bool { + publishFakeReloadScreenEvent(experienceType, experienceId, isFakeReload: true) + } } /** * AnalyticsPublisher is responsible for managing and publishing analytics events through WebSocket connections. * * This class handles: - * - Event queuing and throttling + * - Serialized, ACK-gated event queuing and throttling + * - Offline persistence and batch restore of events * - Socket connection management * - User identification and session management * - Screen tracking with experience state management * - Background/foreground state handling - * - * The publisher ensures events are sent reliably by caching them when the socket is unavailable - * and flushing them once the connection is re-established. */ internal class AnalyticsPublisher { - // MARK: - Constants - - /** Property key for event metadata */ - static let metaDataProperty = "metadata" - - /** Property key for event screen */ - static let screen = "screen" - - /** Property key for company information in identify events */ - static let identifyCompanyProperty = "company" - - /** Property key for screen title in screen events */ - static let screenTitleProperty = "title" - - /** Property key indicating if this is the start of a new session */ - static let isSessionStartedProperty = "is_session_start" - - /** Property key for fake reload state */ - static let fakeReload = "fake_reload" - - /** Property key for tracking seen flows/experiences */ - static let seenContents = "seen_contents" - - /** Property key for tracking seen surveys */ - static let seenSurveys = "seen_surveys" - - /** Property key for custom event names */ - private static let eventNameProperty = "event_name" - // MARK: - Properties /// A weak reference to ExperienceRendering would be expected here to avoid a retain cycle with AnalyticsPublisher @@ -134,41 +117,59 @@ internal class AnalyticsPublisher { return container?.resolve(SessionMonitoring.self) } - /// Decorator used to modify event properties before sending. - private let autoPropertyDecorator: AutoPropertyDecoratoring - /// The screen name tracker. private let screenNameTracker: ScreenNameTracking /// Manages socket connections and event publishing over web socket. - private let socketManager: SocketEvents + private let socketManager: SocketManaging - // MARK: - Properties + /// Offline events handler for managing local storage and batch sending. + private let offlineEventsHandler: OfflineEventsHandling + + /// Network monitor to check initial network readiness. + private let networkMonitor: NetworkMonitoring - /// Queue to hold events waiting to be sent - private lazy var eventsToFlush = [Event]() + /// The user session state machine. + private let userSessionStateMachine: UserSessionStateManaging - /// Read-write lock for thread-safe event queue operations - private lazy var readWriteLock = ReadWriteLock() + // MARK: - Queues & State + + /// Internal SDK events cached while the socket is reconnecting. Responses are + /// delivered to their senders via the multicast subscription, identified by + /// `message.resolvedEvent` — no per-event callback is kept. + private var cachedSDKEvents = [SDKEvent]() /// Event throttling mechanism to prevent spam private lazy var eventThrottle = EventThrottle(throttleDuration: 1.0) /** - * Tracks the last screen viewed and its state including seen experiences. - * Contains boolean flag for fake reload state. + * Tracks the last screen viewed and the content seen during that screen session. */ - private(set) var screenViewEntity: ScreenViewEntity? - - /// Cached identify event, to be sent when the socket is ready - private var cachedIdentifyEvent: Event? - - /// Tracks the first event to open the socket - private var cachedEvent: Event? + private(set) var screenSessionStateMachine: ScreenSessionStateMachine? /// Holds session start state - true indicates a new session should be started private var startSession = true + /// Serialized live analytics event queue. The head stays enqueued until its + /// socket ACK arrives (peek-then-dequeue-on-ACK), so a watchdog restart can + /// re-attempt the same head without duplicating an acknowledged send. + private lazy var eventsQueue = EventQueue() + + /// Holds events until the network monitor produces its first reliable state. + private lazy var initialQueue = EventQueue() + + /// Single-flight gate: exactly one processing cycle may be in flight. + private lazy var isProcessingEvent: AtomicReference = AtomicReference(false) + + /// Watchdog for a stuck processing cycle (no socket callback arrived at all). + private var processingWatchdog: DispatchWorkItem? + + /// Real-time queue for the watchdog, decoupled from event processing. + private let watchdogQueue = DispatchQueue( + label: Constants.DispatchQueues.analyticsWatchdog, + qos: .utility + ) + // MARK: - Initialization /** @@ -182,21 +183,28 @@ internal class AnalyticsPublisher { self.userpilot = container.owner self.config = container.resolve(Userpilot.Config.self) self.storage = container.resolve(DataStoring.self) - self.autoPropertyDecorator = container.resolve(AutoPropertyDecoratoring.self) - self.socketManager = container.resolve(SocketEvents.self) + self.socketManager = container.resolve(SocketManaging.self) + self.offlineEventsHandler = container.resolve(OfflineEventsHandling.self) + self.networkMonitor = container.resolve(NetworkMonitoring.self) + self.userSessionStateMachine = container.resolve(UserSessionStateManaging.self) self.screenNameTracker = container.resolve(ScreenNameTracking.self) self.logger = container.resolve(Userpilot.Config.self).logger // Register socket event callback self.socketManager.registerCallback(self) + // Register network monitor delegate + self.networkMonitor.delegate = self + // Restore any previously cached user from storage if let temporaryUserString = storage.temporaryUser { let temporaryUser = User.fromJson(temporaryUserString) - cachedIdentifyEvent = Event( - type: EventType.identify(temporaryUser.userId), - properties: temporaryUser.properties, - company: temporaryUser.company) + eventsQueue.enqueue( + Event( + type: EventType.identify(temporaryUser.userId), + properties: temporaryUser.properties, + company: temporaryUser.company) + ) } } @@ -213,31 +221,47 @@ extension AnalyticsPublisher: AnalyticsPublishing { * This ensures no events are lost when the app is backgrounded. */ func flush() { - socketManager.updateSocketState(.shuttingDown, forceUpdateState: true) - flushQueue(shouldCloseSocket: true) + tryCatch { + let events = eventsQueue.getAndClear() + // In case of a new user, clear all queue and cache the identify new user event only + let newUserEvents = events.filter { $0.isIdentifyEvent && $0.userId != storage.userId } + if let firstNewUserEvent = newUserEvents.first { + storage.temporaryUser = firstNewUserEvent.toUser().toJson() + eventsQueue.enqueue(firstNewUserEvent) + } else { + events.forEach { event in + switch event.type { + case .identify: + _ = identify(event) + case .screen: + _ = screen(event) + case .event, .autoCaptureEvent: + _ = trackEvent(event) + } + } + } + closeSocket() + userSessionStateMachine.markUserBackFromBackground() + resetProcessingEventStatus() + } } /** * Clears all cached data and closes the socket connection. * - * - Parameter socketState: The state to set for the socket after logout - * - Parameter shouldClearCachedIdentifyEvent: If true, indicates this logout comes from app level, + * - Parameter clearCachedIdentifyEvent: If true, indicates this logout comes from app level, * meaning the user is logged out and there is no new login. This will clear the * push token from the backend. On user switch, the backend handles clearing the * push token from the old user. */ - func logout( - socketState: SocketManager.SocketState, - shouldClearCachedIdentifyEvent: Bool = false - ) { - if shouldClearCachedIdentifyEvent && canRequestEvent { + func logout(clearCachedIdentifyEvent: Bool = false) { + if clearCachedIdentifyEvent && canRequestEvent { if let token = storage.pushToken { publishInternalSDKEvent( UserLogoutEvent( appToken: config.token, userId: storage.userId, - token: token), - socketSubscription: nil + token: token) ) } } @@ -245,15 +269,15 @@ extension AnalyticsPublisher: AnalyticsPublishing { startSession = true // Reset content states experiencesPublisher?.logout() - // Clear seen contents from screenViewEntity - screenViewEntity?.resetState() - // Update socket state: SHUTTING_DOWN for app logout, otherwise SWITCHING_USER state - socketManager.updateSocketState(socketState, forceUpdateState: true) - // Clear all content for app logout, otherwise keep them to re-establish connection - // after closing old user channel - clearAllCachedProperties(shouldClearCachedIdentifyEvent) + // Clear seen contents from screenSessionStateMachine + screenSessionStateMachine?.resetState() + // Clear all queues for app logout; on user switch keep them so the new + // user's identify re-establishes the connection after the old channel closes + clearAllCachedProperties(clearCachedIdentifyEvent) + // Old-user offline events must never replay under a new user + offlineEventsHandler.clearLocalEvents() // Close socket connection - socketManager.close() + closeSocket() } /** @@ -262,12 +286,9 @@ extension AnalyticsPublisher: AnalyticsPublishing { */ func resume() { updateSessionState() - if let userId = cachedIdentifyEvent?.userId, !userId.isEmpty { - storage.userId = userId - } - if storage.userId.isNotEmpty && !socketManager.isSocketOpened && !socketManager.isJoiningSocket { - openSocket() - } + if let userId = getUserIdFromQueue() { storage.userId = userId } + // connect() gates itself on the socket state - always safe to call + if storage.userId.isNotEmpty { openSocket() } } /** @@ -287,49 +308,92 @@ extension AnalyticsPublisher: AnalyticsPublishing { guard let sessionDate = storage.sessionDate else { return } storage.sessionDate = nil let difference = Date().timeIntervalSince(sessionDate) - startSession = difference > GeneralConstants.SESSION_DURATION + startSession = difference > Constants.Analytics.sessionDuration } - // MARK: - Publish Helper Methods + // MARK: - Publish Routing /** * Publishes an event to the server through the WebSocket connection. - * If the socket is not opened or still in the process of joining, the event is cached for later. * - * - Parameter event: The event to be published. The type of the event determines the action to be taken. + * Routing order: + * 1. Drop while the app is not active. + * 2. Ignore same-user identify; cache new-user identify. + * 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. + * 6. Enqueue (throttled), then process or open the socket. + * + * - Parameter event: The event to be published. */ func publish(_ event: Event) { + publish(event, isInternalEvent: false) + } + + func publish(_ event: Event, isInternalEvent: Bool) { tryCatch { - // Handle app state - cache events when app is not in active state - guard sessionMonitorer?.isAppActive ?? false else { - cacheEvent(event) - return + // Keep experience targeting on the current screen immediately — events + // queue now, and targeting must not lag behind navigation. + if let screenTitle = event.screenTitle { + experiencesPublisher?.updateScreen(screenTitle) } + // Handle app state - drop events when app is not in active state + guard sessionMonitorer?.isAppActive ?? false else { return } + + // Cache identify event or return when its same user guard !didHandleIdentifyEvent(event) else { return } + // Hold events while network monitor is still resolving initial state + if !networkMonitor.isReady { + initialQueue.enqueue(event, isInternalEvent: isInternalEvent) + return + } + + // Network monitor is ready and reports no network: persist locally + if offlineEventsHandler.shouldSaveOffline { + handleOfflineEvent(event) + return + } + // Check if socket is in shutdown state // For example: getting event while logging out, ignore the event guard !socketManager.isShutdownState else { return } - // Handle socket joining state - // If socket is joining, cache the event to process it after establishing socket connection - guard !socketManager.isJoiningSocket else { - cacheEvent(event) - return - } + // Valid state to process the event - add it to the events queue + cacheEvent(event, isInternalEvent: isInternalEvent) + + // If socket is joining, the event waits in the queue until the + // connection is established (drained from onSocketOpened) + guard !socketManager.isJoiningSocket else { return } - // Handle closed socket state - // If socket is closed, cache event and verify there is a valid - // user ID, then establish connection and return - guard socketManager.isSocketOpened else { + if canRequestEvent { + processEvent() + } else { handleClosedSocket(event) - return } + } + } + + /** + * Persists an event to local storage while offline. + * On an offline user switch the old user's stored events are cleared first, + * because batch items carry no user id. + */ + private func handleOfflineEvent(_ event: Event) { + let isOfflineUserSwitch = isUserSwitchIdentifyEvent(event) - // Socket is opened, process the event immediately - processEvent(event) + // Android parity: once a different user is identified, every following + // offline event belongs to that user, and old persisted events are cleared + // before this identify is saved. + if event.isIdentifyEvent, let userId = event.userId, !userId.isEmpty { + storage.userId = userId } + + offlineEventsHandler.saveEventToLocalStorage( + event: event, + clearStoredEventsFirst: isOfflineUserSwitch + ) } /** @@ -346,27 +410,25 @@ extension AnalyticsPublisher: AnalyticsPublishing { return true } - // Update cached user + // Update temporary cached user in storage storage.temporaryUser = event.toUser().toJson() - cachedIdentifyEvent = event return false } /** - * Handles events when socket is closed by caching the event and attempting to open connection. + * Handles events when socket is closed by attempting to open connection. * * - Parameter event: The event to handle - * - Returns: true if processing should stop, false if it should continue */ private func handleClosedSocket(_ event: Event) { - cacheEvent(event) - // Update userId from cached identify event or current event updateUserIdFromEvent(event) - // Only proceed if we have a valid userId - if storage.userId.isEmpty { return } + // Only proceed if we have a valid userId. + // This could be a valid case when user logged out and sent track or screen + // event; in this case return and don't process the event, ignore it. + guard !storage.userId.isEmpty else { return } openSocket() } @@ -377,92 +439,236 @@ extension AnalyticsPublisher: AnalyticsPublishing { * - Parameter event: The event containing potential user ID */ private func updateUserIdFromEvent(_ event: Event) { - // Priority: current event userId > cached identify event userId + // When socket is closed then handle user session state + if event.isIdentifyEvent { + if storage.userId == event.userId { + userSessionStateMachine.markAwaitingInitialScreen() + } else { + userSessionStateMachine.markUserSwitch() + } + } + if let userId = event.userId, !userId.isEmpty { storage.userId = userId - } else if let cachedUserId = cachedIdentifyEvent?.userId, !cachedUserId.isEmpty { + } else if let cachedUserId = getUserIdFromQueue() { storage.userId = cachedUserId } } + private func isUserSwitchIdentifyEvent(_ event: Event?) -> Bool { + guard let event else { return false } + return event.isIdentifyEvent + && storage.userId.isNotEmpty + && event.userId != storage.userId + } + + // MARK: - Serialized Event Processing + /** - * Processes an event based on its type. + * Processes the next unit of work, exactly one at a time. + * + * Priority order: + * 1. Persisted offline events (restored and sent as one batch). + * 2. Cached internal SDK events. + * 3. The head of the live analytics queue. * - * - Parameter event: The event to process + * The head event is sent with a peek — it is dequeued only when its socket + * ACK arrives in `onSocketEventSent`. The cycle is released by the socket + * callbacks (ok/error/timeout), socket close, flush, or the watchdog. */ - private func processEvent(_ event: Event) { + private func processEvent() { + tryCatch { + // Single-flight gate: only one processing cycle may run + guard isProcessingEvent.compareAndSet(expected: false, new: true) else { return } + scheduleProcessingWatchdog() + + // Bail out when the socket can't accept events (e.g. a watchdog retry + // after a disconnect) — publishing into a dead channel never resolves. + // A half-open transport is recovered by SocketManager on the next + // connect attempt. onSocketOpened restarts processing. + guard canRequestEvent else { + resetProcessingEventStatus() + return + } + + if isUserSwitchIdentifyEvent(eventsQueue.getFirst()) { + offlineEventsHandler.clearLocalEvents() + } + + if restoreOfflineEventsIfNeeded() { + return + } + + // Priority 2: sync internal SDK events directly + processSDKEvent() + + // Priority 3: process the live queue head + guard let event = eventsQueue.getFirst() else { + handleEmptyEventQueue() + return + } + + // Double check user - queued identify could have become same-user + if didHandleIdentifyEvent(event) { + skipHeadEvent() + return + } + + // Nothing was pushed (throttled, invalid, or blocked): drop the head + // and continue, otherwise the gate would wait for an ACK that will + // never arrive. + if !publishQueuedEvent(event) { + skipHeadEvent() + } + } + } + + /// Restores persisted offline events before live queue processing. + private func restoreOfflineEventsIfNeeded() -> Bool { + guard offlineEventsHandler.hasCachedEvents else { return false } + offlineEventsHandler.restoreEventsFromLocalStorage { [weak self] in + self?.resetProcessingEventStatus() + self?.processEvent() + } + return true + } + + /// Releases processing or sends the background fake reload when no live event is queued. + private func handleEmptyEventQueue() { + guard userSessionStateMachine.getCurrentState() == .backgroundToInitialScreen else { + resetProcessingEventStatus() + return + } + + userSessionStateMachine.markNormal() + if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) { + resetProcessingEventStatus() + } + } + + /// Publishes the queued event using the matching event-specific path. + private func publishQueuedEvent(_ event: Event) -> Bool { switch event.type { case .identify: - identify(event) + return identify(event) case .screen: - screen(event) + return screen(event) case .event, .autoCaptureEvent: - trackEvent(event) + return trackEvent(event) } } + /// Drops the queue head, releases the gate, and continues with the next event. + private func skipHeadEvent() { + eventsQueue.deleteFirst() + resetProcessingEventStatus() + processEvent() + } + /** - * Caches an event when socket is not opened or is joining. - * Different event types are cached in different properties. + * Caches an event into the serialized queue, throttling screen and track + * events before they are enqueued. * * - Parameter event: The event to cache */ - private func cacheEvent(_ event: Event) { + private func cacheEvent(_ event: Event, isInternalEvent: Bool = false) { + if storage.userId.isEmpty { + eventsQueue.clear() + } + if event.isScreenEvent, + eventThrottle.shouldThrottleScreenEvent(screenTitle: event.screenTitle ?? "") { + return + } switch event.type { - case .identify: - cachedIdentifyEvent = event - case .screen: - setupScreenEvent(event) case .event, .autoCaptureEvent: - cachedEvent = event + if eventThrottle.shouldThrottle(eventTitle: trackEventThrottleKey(event)) { return } + default: + break } + eventsQueue.enqueue(event, isInternalEvent: isInternalEvent) } - // MARK: - Private Methods + // MARK: - Event Senders /** * Identifies the user and handles the identify event. - * If a new user ID is detected, it closes the socket and cleans up for user switching. + * If a new user ID is detected, it closes the socket and cleans up for user switching; + * the queued identify is re-published from `onSocketClosed`. * * - Parameter event: The identify event containing user information + * - Returns: true when a push went out or the switch is being handled asynchronously */ - private func identify(_ event: Event) { - tryCatch { - guard let userId = event.userId else { return } + private func identify(_ event: Event) -> Bool { + guard let userId = event.userId else { return false } + + // If new user ID detected, close socket and clean up while it's connected + if storage.userId.isNotEmpty && userId != storage.userId { + // Mark as switch so the post-identify fake reload will be false + userSessionStateMachine.markUserSwitch() + userpilot?.clean() + logout(clearCachedIdentifyEvent: false) + return true + } - // If new user ID detected, close socket and clean up - if storage.userId.isNotEmpty && userId != storage.userId { - userpilot?.clean() - logout(socketState: .switchingUser) - } else { - flushPriorityEvents(fakeReloadScreenEvent: true) - } + // Socket is connected with the same user id + userSessionStateMachine.markAwaitingInitialScreen() + var payload: [String: Any] = [ + Constants.Analytics.metaDataProperty: event.properties ?? [:] + ] + if let company = event.company, !company.isEmpty { + payload[Constants.Analytics.identifyCompanyProperty] = company } + socketManager.publish(event.eventName, payload: payload) + return true } /** * Processes and sends screen view events. - * Handles screen throttling and determines when to trigger screen events. * * - Parameter event: The screen event to process + * - Returns: true when a screen push went out */ - private func screen(_ event: Event) { - tryCatch { - // Returns true if this is a new screen, which triggers screen event and throttling - if setupScreenEvent(event) { - _ = eventThrottle.shouldThrottleScreenEvent(screenTitle: event.screenTitle ?? "") - flushPriorityEvents(isRequestIdentify: false) - return - } - // Not a new screen, check if valid to trigger screen event - if experiencesPublisher?.canRequestScreenEvent() == false || - eventThrottle.shouldThrottleScreenEvent(screenTitle: event.screenTitle ?? "") { - return - } - flushPriorityEvents(isRequestIdentify: false) + private func screen(_ event: Event) -> Bool { + // Returns true if this is a new screen, which triggers screen event + if setupScreenEvent(event) { + return publishScreenEvent(isFakeReload: false) + } + // Not a new screen, check if valid to trigger screen event + if experiencesPublisher?.canRequestScreenEvent() == true { + return publishScreenEvent(isFakeReload: false) + } + return false + } + + /** + * Sends track and auto-capture events with their full production payload + * (metadata, screen context, and interaction event name). + * + * - Parameter event: The custom event to track + * - Returns: true when a push went out + */ + private func trackEvent(_ event: Event) -> Bool { + var payload: [String: Any] = [:] + payload[Constants.Analytics.eventNameProperty] = + event.type == .autoCaptureEvent + ? event.interactionEventName + : event.eventTitle + payload[Constants.Analytics.metaDataProperty] = event.properties ?? [:] + if let screen = event.screen { + payload[Constants.Analytics.screenProperty] = screen + } + if event.type == .autoCaptureEvent && event.screen == nil { + logger.error("❗ Event Error, Auto capture event must have screen") + return false } + + broadcastEvent(event, event.eventTitle, properties: payload) + socketManager.publish(event.eventName, payload: payload) + return true } + // MARK: - Throttle Keys + /** * Stable key for event throttling. * Non-AutoCapture events use `eventTitle`, or `eventName` when @@ -481,14 +687,14 @@ extension AnalyticsPublisher: AnalyticsPublishing { let screenName = trackEventThrottleScreenName(from: event.screen) if screenName.isEmpty { return trackTitle } - let tabName = trackEventThrottleString(from: properties[AutoCaptureConstants.tabName]) + let tabName = trackEventThrottleString(from: properties[Constants.AutoCapture.tabName]) if tabName.isEmpty { - let hierarchy = trackEventThrottleString(from: properties[AutoCaptureConstants.hierarchy]) - let row = trackEventThrottleString(from: properties[AutoCaptureConstants.selectedIndex]) + let hierarchy = trackEventThrottleString(from: properties[Constants.AutoCapture.hierarchy]) + let row = trackEventThrottleString(from: properties[Constants.AutoCapture.selectedIndex]) let interaction = trackEventThrottleString(from: event.interactionEventName) return "\(screenName)|\(trackTitle)|\(interaction)|\(hierarchy)|\(row)" } else { - let tabIndex = properties[AutoCaptureConstants.tabIndex] ?? 0 + let tabIndex = properties[Constants.AutoCapture.tabIndex] ?? 0 return "\(screenName)|\(trackTitle)|\(tabName)|\(tabIndex)" } } @@ -496,9 +702,9 @@ extension AnalyticsPublisher: AnalyticsPublishing { /// Resolves a display class for throttling from `Event.screen` (set on autocapture events via `makeEvent`). private func trackEventThrottleScreenName(from screen: Payload) -> String { guard let screen, !screen.isEmpty else { return "" } - if let name = screen[AutoCaptureConstants.screenClass] as? String, !name.isEmpty { return name } - if let name = screen[AutoCaptureConstants.screenTitle] as? String, !name.isEmpty { return name } - if let name = screen[AutoCaptureConstants.screenName] as? String, !name.isEmpty { return name } + if let name = screen[Constants.AutoCapture.screenClass] as? String, !name.isEmpty { return name } + if let name = screen[Constants.AutoCapture.screenTitle] as? String, !name.isEmpty { return name } + if let name = screen[Constants.AutoCapture.screenName] as? String, !name.isEmpty { return name } return "" } @@ -509,37 +715,17 @@ extension AnalyticsPublisher: AnalyticsPublishing { return String(describing: value) } - /** - * Tracks general user events by adding them to the flush queue. - * Events are throttled to prevent spam. - * - * - Parameter event: The custom event to track - */ - private func trackEvent(_ event: Event) { - tryCatch { - readWriteLock.write { [weak self] in - guard let self else { return } - let throttleKey = trackEventThrottleKey(event) - if self.eventThrottle.shouldThrottle(eventTitle: throttleKey) { return } - self.eventsToFlush.append(event) - if self.eventsToFlush.count == 1 { - flushQueue() - } - } - } - } - // MARK: - Screen Management /** - * Sets up the screen event by updating the screenViewEntity based on the given event. + * Sets up the screen event by updating the screen session state machine. * - * If the screen title of the incoming event differs from the current screenViewEntity's event: + * If the screen title of the incoming event differs from the current screenSessionStateMachine's event: * - The startSession flag is set to false - * - A new ScreenViewEntity is created with an empty set of seen experiences + * - A new ScreenSessionStateMachine is created with an empty set of seen experiences * - * If the screen title matches the current screenViewEntity's event: - * - A new ScreenViewEntity is created, retaining the existing set of seen experiences + * If the screen title matches the current screenSessionStateMachine's event: + * - A new ScreenSessionStateMachine is created, retaining the existing set of seen experiences * * - Parameter event: The new screen event to process * - Returns: true if this is a new screen, false if it's the same screen @@ -549,29 +735,29 @@ extension AnalyticsPublisher: AnalyticsPublishing { var isNewScreen = false tryCatch { // Check if the screen title has changed - let isScreenTitleChanged = screenViewEntity?.event.screenTitle != event.screenTitle + let isScreenTitleChanged = screenSessionStateMachine?.event.screenTitle != event.screenTitle // Update session state if the screen title has changed - if screenViewEntity != nil && socketManager.isSocketOpened && isScreenTitleChanged { + if screenSessionStateMachine != nil && canRequestEvent && isScreenTitleChanged { startSession = false } - // Update the screenViewEntity with the new event and handle seen experiences accordingly + // Update the screen session state and retain seen content when the screen did not change. if isScreenTitleChanged { isNewScreen = true // New screen: start with an empty set of seen experiences - screenViewEntity = ScreenViewEntity( + screenSessionStateMachine = ScreenSessionStateMachine( event: event, seenExperiences: Set(), seenSurveys: Set() ) } else { // Same screen: retain the existing seen experiences - screenViewEntity = ScreenViewEntity( + screenSessionStateMachine = ScreenSessionStateMachine( event: event, - seenExperiences: screenViewEntity?.seenExperiences ?? Set(), - seenSurveys: screenViewEntity?.seenSurveys ?? Set() + seenExperiences: screenSessionStateMachine?.seenExperiences ?? Set(), + seenSurveys: screenSessionStateMachine?.seenSurveys ?? Set() ) } } @@ -580,146 +766,51 @@ extension AnalyticsPublisher: AnalyticsPublishing { } -// MARK: - Socket Subscription Management +// MARK: - Watchdog & Processing Gate extension AnalyticsPublisher { - /* - * Flushes high-priority events, such as identify or screen events, through the socket. - * These are events that open the socket or are critical for user tracking. + /** + * Schedules the stuck-cycle watchdog. Scheduled at the top of every claimed + * processing cycle; cancelled by `resetProcessingEventStatus()` from every + * socket resolution path. * - * - Parameter isRequestIdentify: When socket opens or identifies new user, request identify. - * If from screen event, don't request identify to avoid duplicates. - * - Parameter canRequestScreenEvent: Request screen from screen event, from identify new user - * and we have cached screen. Check ExperiencePublisher state for validation. - * - Parameter fakeReloadScreenEvent: When from identify (update user properties), request - * identify with fake reload true, otherwise false. + * The interval is deliberately kept above the socket push timeout so socket + * ok/error/timeout callbacks always resolve the in-flight event first — the + * watchdog only fires when NO callback arrived at all (e.g. the push never + * left because the channel wasn't ready). */ - // swiftlint:disable:next function_body_length - private func flushPriorityEvents( - isRequestIdentify: Bool = true, - canRequestScreenEvent: Bool = true, - fakeReloadScreenEvent: Bool = false - ) { - tryCatch { - if !socketManager.isSocketOpened || socketManager.isJoiningSocket { - checkFallbackState() - return - } - - // Handle identify event - if isRequestIdentify { - if let cachedIdentifyEvent { - var payload: [String: Any] = [:] - guard let userId = cachedIdentifyEvent.userId else { return } - // If identify event is called again when joining socket channel - if storage.userId.isNotEmpty && userId != storage.userId { - identify(cachedIdentifyEvent) - return - } - payload[AnalyticsPublisher.metaDataProperty] = cachedIdentifyEvent.properties ?? [:] - if let company = cachedIdentifyEvent.company, !company.isEmpty { - payload[AnalyticsPublisher.identifyCompanyProperty] = company - } - socketManager.publish(cachedIdentifyEvent.eventName, payload: payload) - } - } - - // Handle screen event - if let screenViewEntity, canRequestScreenEvent { - let screenEvent = screenViewEntity.event - if let screenTitle = screenEvent.screenTitle { - if !config.enableScreenAutoCapture && config.enableInteractionAutoCapture { - screenNameTracker.updateScreen( - with: ScreenTrackingPayload( - screenTitle: screenTitle, - appFramework: config.appFramework - ) - ) - } - experiencesPublisher?.updateSceen(screenTitle) - } - var payload: [String: Any] = [:] - payload[AnalyticsPublisher.screenTitleProperty] = screenEvent.screenTitle ?? "" - - let existingMetadata = screenViewEntity.event.properties ?? [:] - let newMetadata: [String: Any] = [ - AnalyticsPublisher.isSessionStartedProperty: startSession, - AnalyticsPublisher.fakeReload: fakeReloadScreenEvent, - AnalyticsPublisher.seenContents: Array(screenViewEntity.seenExperiences), - AnalyticsPublisher.seenSurveys: Array(screenViewEntity.seenSurveys) - ] - payload[AnalyticsPublisher.metaDataProperty] = existingMetadata.merging(newMetadata) { _, new in new } - - socketManager.publish(screenEvent.eventName, payload: payload) - broadcastEvent(screenEvent, screenEvent.screenTitle ?? "", properties: nil) - } - - // Handle cached track event - if let cachedEvent { - trackEvent(cachedEvent) - clearCachedEvent() - } + private func scheduleProcessingWatchdog() { + processingWatchdog?.cancel() + + let watchdog = DispatchWorkItem { [weak self] in + guard let self, self.isProcessingEvent.value else { return } + // Watchdog recovery for a stuck processing cycle. + // If no socket resolution callback arrives (success, error, timeout, or close), + // reset the processing gate and retry the current queue head. + // This prevents the queue from stalling, but it is not a strict no-duplicate guarantee. + self.logger.error("⏱️ Event processing stuck with no socket callback, restarting queue") + self.resetProcessingEventStatus() + self.processEvent() } - } - /* - * Flushes the event queue by sending events one by one through the socket. - * - * - Parameter shouldCloseSocket: When move app to background - */ - private func flushQueue(shouldCloseSocket: Bool = false) { - tryCatch { - readWriteLock.write { [weak self] in - guard let self else { return } - if !self.socketManager.isSocketOpened || self.socketManager.isJoiningSocket { - if shouldCloseSocket { - self.socketManager.close() - } else { - self.checkFallbackState() - } - return - } - if self.eventsToFlush.isEmpty { - if shouldCloseSocket { self.closeSocket() } - return - } - - if let eventToSend = self.eventsToFlush.first { - self.eventsToFlush.removeFirst() - - var payload: [String: Any] = [:] - payload[AnalyticsPublisher.eventNameProperty] = - eventToSend.type == .autoCaptureEvent - ? eventToSend.interactionEventName - : eventToSend.eventTitle - payload[AnalyticsPublisher.metaDataProperty] = eventToSend.properties ?? [:] - if let screen = eventToSend.screen { - payload[AnalyticsPublisher.screen] = screen - } - if eventToSend.type == .autoCaptureEvent && eventToSend.screen == nil { - self.logger.error("❗ Event Error, Auto capture event must have screen") - return - } - - self.broadcastEvent(eventToSend, eventToSend.eventTitle, properties: payload) - self.socketManager.publish(eventToSend.eventName, payload: payload) - self.flushQueue(shouldCloseSocket: shouldCloseSocket) - } - } - } + processingWatchdog = watchdog + watchdogQueue.asyncAfter( + deadline: .now() + Constants.Analytics.stuckProcessingWatchdog, + execute: watchdog + ) } - /** - * Fallback state handler to close socket if channel is not opened properly. - */ - private func checkFallbackState() { - if socketManager.isSocketConnectedWithUnknownChannel { closeSocket() } + /// Releases the single-flight gate and cancels the pending watchdog. + private func resetProcessingEventStatus() { + processingWatchdog?.cancel() + processingWatchdog = nil + isProcessingEvent.value = false } } -// MARK: - Socket Management +// MARK: - Socket Subscription extension AnalyticsPublisher: SocketSubscription { @@ -738,39 +829,42 @@ extension AnalyticsPublisher: SocketSubscription { /** * Socket opened callback. - * Clears cached events and flushes priority events. + * Starts draining offline, SDK, and live queued events. */ func onSocketOpened() { - clearCachedEvents() - flushPriorityEvents(canRequestScreenEvent: experiencesPublisher?.canRequestScreenEvent() == true) + tryCatch { + processEvent() + } } /** * Socket closed callback. - * Handles reconnection logic or clears cached properties based on closure reason. + * Handles user-switch reconnection: when the close came from switching users the + * new user's identify is still at the head of the queue and is re-published here. */ func onSocketClosed() { tryCatch { - if socketManager.isShutdownState || socketManager.didErrorOccurred { - clearAllCachedProperties() - return - } - if let eventToPublish = cachedIdentifyEvent { - publish(eventToPublish) - } else { - clearAllCachedProperties() + resetProcessingEventStatus() + // Socket closed from error state, don't reopen, keep events for next open + if socketManager.didCloseFromError { return } + // Background close: keep the queue as-is (a queued new-user identify is + // picked up by resume() on foreground). Re-publishing here would hit the + // app-inactive guard and silently drop the event. + guard sessionMonitorer?.isAppActive ?? false else { return } + // Events still queued: the close came from a user switch, reopen the + // socket keeping the identify event as the first event to process + if let event = eventsQueue.dequeue() { + userSessionStateMachine.markUserSwitch() + publish(event, isInternalEvent: true) } } } /** - * Callback method triggered when a socket event has been sent. - * Ensures any remaining queued events are flushed and handles identify event completion. - * - * - Parameter eventName: The name of the event sent - * - Parameter payload: The payload that was sent - * - Parameter message: The socket message response - * - Parameter eventSent: Whether the event was successfully sent + * Callback triggered when a socket push resolves (ok, error, or timeout). + * Dequeues the in-flight head, performs identify/screen bookkeeping, and + * advances the queue. Error and timeout are at-most-once: the event is + * dropped and the queue continues. */ func onSocketEventSent( _ eventName: String, @@ -779,17 +873,75 @@ extension AnalyticsPublisher: SocketSubscription { _ eventSent: Bool ) { tryCatch { - if let cachedIdentifyEvent { - if eventName == EventType.identifyEvent && - cachedIdentifyEvent.userId == storage.userId { - var newUser = User.fromJson(storage.user) - storage.user = newUser.updateUser(event: cachedIdentifyEvent).toJson() ?? "" - logger.info("👤 USER %{public}@", storage.user) - clearCachedIdentifyEvent() - broadcastEvent(cachedIdentifyEvent, cachedIdentifyEvent.userId ?? "", properties: payload) + // Only analytics events advance the queue - SDK/content events resolve + // through their own direct subscriptions + guard eventName.isAnalyticsEvent() else { return } + + // Remove the in-flight head - it stayed enqueued until this resolution + let event = eventsQueue.dequeue() + + guard eventSent else { + logger.error( + "⚠️ Event not acknowledged (%{public}@), dropping and continuing queue", + eventName) + resetProcessingEventStatus() + processEvent() + return + } + + // Update cached user object + if let event, eventName == Constants.Event.identifyEvent && event.userId == storage.userId { + var newUser = User.fromJson(storage.user) + storage.user = newUser.updateUser(event: event).toJson() ?? "" + logger.info("👤 USER %{public}@", storage.user) + clearCachedIdentifyEvent() + broadcastEvent(event, event.userId ?? "", properties: payload) + } + + if eventName == Constants.Event.screenEvent { + userSessionStateMachine.markNormal() + } + + // Handle request screen event after user identify event + if userSessionStateMachine.isPostIdentificationContext(eventName) + && userSessionStateMachine.shouldRequestInitialScreenEvent( + eventsQueue.isEmpty(), + experiencesPublisher?.getCurrentScreen.isNotEmpty == true) { + // Keep the gate claimed for the post-identify screen push, with a + // fresh watchdog window for the new in-flight push + scheduleProcessingWatchdog() + let published = publishScreenEvent( + isFakeReload: userSessionStateMachine.getPostIdentificationFakeReloadConfig()) + if !published { + resetProcessingEventStatus() + processEvent() } + } else { + // Continue processing the next event in the queue + resetProcessingEventStatus() + processEvent() } - flushQueue() + } + } + +} + +// MARK: - Network Monitor + +extension AnalyticsPublisher: NetworkMonitoringDelegate { + + func networkMonitorDidUpdate(isReady: Bool, isNetworkAvailable: Bool) { + guard isReady else { return } + flushInitialQueue() + } + + /// Re-routes events held before the first network readiness through the + /// normal publish routing (online queue or offline storage). + private func flushInitialQueue() { + let pendingEvents = initialQueue.getAndClear() + guard !pendingEvents.isEmpty else { return } + pendingEvents.forEach { event in + publish(event) } } @@ -800,51 +952,25 @@ extension AnalyticsPublisher: SocketSubscription { private extension AnalyticsPublisher { /** - * Clears all cached properties when receiving closed callback from socket. + * Clears all cached properties on app-level logout; user switch keeps them. * - * - Parameter shouldClearCachedIdentifyEvent: Whether to clear the cached identify event + * - Parameter clearCachedIdentifyEvent: Whether to clear the cached identify event */ - private func clearAllCachedProperties(_ shouldClearCachedIdentifyEvent: Bool = false) { - clearCachedEvents() - clearCachedEvent() - if shouldClearCachedIdentifyEvent { clearCachedIdentifyEvent() } - } - - /** Clears the cached events on new identify event or when socket opens */ - private func clearCachedEvents() { - tryCatch { - readWriteLock.write { [weak self] in - guard let self else { return } - self.eventsToFlush.removeAll() - } - } + private func clearAllCachedProperties(_ clearCachedIdentifyEvent: Bool = false) { + guard clearCachedIdentifyEvent else { return } + eventsQueue.clear() + initialQueue.clear() + cachedSDKEvents.removeAll() + self.clearCachedIdentifyEvent() } /** Clears the cached identify event after it has been successfully sent */ private func clearCachedIdentifyEvent() { - cachedIdentifyEvent = nil storage.temporaryUser = nil } - - /** Clears the cached event after it has been successfully sent */ - private func clearCachedEvent() { - cachedEvent = nil - } -} - -#if DEBUG -extension AnalyticsPublisher { - func mockGetCachedEvent() -> Event? { - return cachedEvent - } - - func mockGetEventsToFlush() -> [Event] { - return eventsToFlush - } } -#endif -// MARK: - Experience Publisher Integration +// MARK: - Internal SDK Events extension AnalyticsPublisher { @@ -862,67 +988,173 @@ extension AnalyticsPublisher { startSession } - /// To return current screen entity - var screenEntity: ScreenViewEntity? { - screenViewEntity - } - /** * Publishes internal SDK events through the socket. * + * When the socket is closed — for example returning from background the SDK + * takes 1-2 seconds to reconnect — the event is cached and re-sent from + * `processEvent` once the socket reopens. Responses reach their senders via + * the multicast subscription, identified by `message.resolvedEvent`. + * * - Parameter sdkEvent: The SDK event to publish - * - Parameter socketSubscription: Optional listener for socket events */ - func publishInternalSDKEvent( - _ sdkEvent: SDKEvent, - socketSubscription: SocketSubscription? - ) { + func publishInternalSDKEvent(_ sdkEvent: SDKEvent) { tryCatch { - guard canRequestEvent else { return } + guard canRequestEvent else { + cachedSDKEvents.append(sdkEvent) + // connect() gates itself on the socket state - always safe to call + openSocket() + return + } socketManager.publish( sdkEvent.eventName, - payload: sdkEvent.eventPayload, - socketSubscription: socketSubscription + payload: sdkEvent.eventPayload ) } } + /** Sends any cached SDK events while the socket can accept them */ + private func processSDKEvent() { + tryCatch { + while !cachedSDKEvents.isEmpty && canRequestEvent { + let sdkEvent = cachedSDKEvents.removeFirst() + publishInternalSDKEvent(sdkEvent) + } + } + } + /** - * Publishes a fake reload screen event when an experience is shown. + * Publishes a fake reload screen event when an experience is shown/closed. * This ensures proper state tracking for experiences. * * - Parameter experienceType: The type of experience (FLOW or SURVEY) * - Parameter experienceId: The ID of the experience being shown + * - Parameter isFakeReload: false when this is a real screen refresh (back from background) */ - func publishFakeReloadScreenEvent(_ experienceType: ExperienceType, _ experienceId: Int?) { + @discardableResult + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType?, + _ experienceId: Int?, + isFakeReload: Bool + ) -> Bool { + var published = false tryCatch { - guard - experienceId != nil, - canRequestEvent - else { return } - if let screenViewEntity { - // update the seen content to make sure it contains the dismissed content that - // trigger this fake reload - experiencePublished(experienceType, experienceId!) - if eventThrottle.shouldThrottleScreenEvent(screenTitle: screenViewEntity.event.screenTitle ?? "") { - return - } - var payload: [String: Any] = [:] - payload[AnalyticsPublisher.screenTitleProperty] = screenViewEntity.event.screenTitle - - let existingMetadata = screenViewEntity.event.properties ?? [:] - let newMetadata: [String: Any] = [ - AnalyticsPublisher.isSessionStartedProperty: startSession, - AnalyticsPublisher.fakeReload: true, - AnalyticsPublisher.seenContents: Array(screenViewEntity.seenExperiences), - AnalyticsPublisher.seenSurveys: Array(screenViewEntity.seenSurveys) - ] - payload[AnalyticsPublisher.metaDataProperty] = existingMetadata.merging(newMetadata) { _, new in new } - - socketManager.publish(screenViewEntity.event.eventName, payload: payload) - suppressScreenAutocaptureAfterFakeReload() + // Never bypass queue ordering: a fake reload only goes out when no + // live analytics event is queued or in flight + guard canRequestEvent, eventsQueue.isEmpty() else { return } + guard let screenSessionStateMachine else { return } + + // Update the seen content to make sure it contains the dismissed + // content that triggered this fake reload + if let experienceType, let experienceId { + experiencePublished(experienceType, experienceId) + } + if eventThrottle.shouldThrottleScreenEvent( + screenTitle: screenSessionStateMachine.event.screenTitle ?? "") { + return + } + published = publishScreenEvent(isFakeReload: isFakeReload) + } + return published + } + + /** + * Updates the seen experiences when an experience is published/shown. + * + * - Parameter experienceType: The type of experience that was shown + * - Parameter experienceId: The ID of the experience that was shown + */ + func experiencePublished( + _ experienceType: ExperienceType, + _ experienceId: Int + ) { + if experienceType == .flow { + screenSessionStateMachine?.updateSeenFlowExperiences(experienceId) + } else { + screenSessionStateMachine?.updateSeenSurveyExperiences(experienceId) + } + } + +} + +// MARK: - Screen Event Publishing + +extension AnalyticsPublisher { + + /** + * Publishes the current screen session state as a screen event, carrying + * session-start state, fake-reload flag, and seen experiences/surveys. + * + * - Returns: true when a screen push went out + */ + @discardableResult + private func publishScreenEvent(isFakeReload: Bool = false) -> Bool { + ensureScreenSessionStateMachine() + guard let screenSessionStateMachine else { return false } + + let screenEvent = screenSessionStateMachine.event + if let screenTitle = screenEvent.screenTitle { + if !config.enableScreenAutoCapture && config.enableInteractionAutoCapture { + screenNameTracker.updateScreen( + with: ScreenTrackingPayload( + screenTitle: screenTitle, + appFramework: config.appFramework + ) + ) } + experiencesPublisher?.updateScreen(screenTitle) + } + + // For a user switch the post-identification screen must force a new session + startSession = userSessionStateMachine.getPostIdentificationStartSessionConfig( + currentStartSession: startSession) + + var payload: [String: Any] = [:] + payload[Constants.Analytics.screenTitleProperty] = screenEvent.screenTitle ?? "" + + let existingMetadata = screenEvent.properties ?? [:] + let newMetadata: [String: Any] = [ + Constants.Analytics.isSessionStartedProperty: startSession, + Constants.Analytics.fakeReload: isFakeReload, + Constants.Analytics.seenContents: Array(screenSessionStateMachine.seenExperiences), + Constants.Analytics.seenSurveys: Array(screenSessionStateMachine.seenSurveys) + ] + payload[Constants.Analytics.metaDataProperty] = + existingMetadata.merging(newMetadata) { _, new in new } + + socketManager.publish(screenEvent.eventName, payload: payload) + + if isFakeReload { + suppressScreenAutocaptureAfterFakeReload() + } else { + broadcastEvent(screenEvent, screenEvent.screenTitle ?? "", properties: nil) } + return true + } + + /** + A special case needed when coming from a logout state. + In logout the app didn't execute setupScreenEvent, so after identify + we have to request a screen event to get experiences. + */ + private func ensureScreenSessionStateMachine() { + // Early exit if we already have a screen session state machine. + guard screenSessionStateMachine == nil else { return } + + // Ensure user ID exists + guard storage.userId.isNotEmpty else { return } + + // Get the current screen safely + guard let currentScreen = experiencesPublisher?.getCurrentScreen, + !currentScreen.isEmpty + else { return } + + // Initialize the screen session state machine. + screenSessionStateMachine = ScreenSessionStateMachine( + event: Event(type: .screen(currentScreen)), + seenExperiences: Set(), + seenSurveys: Set() + ) } /// Suppresses automatic screen capture after sending a fake reload screen event. @@ -941,21 +1173,9 @@ extension AnalyticsPublisher { InstanceResolver.shared.suppressScreenAutoCaptureAfterSDKContent() } - /** - * Updates the seen experiences when an experience is published/shown. - * - * - Parameter experienceType: The type of experience that was shown - * - Parameter experienceId: The ID of the experience that was shown - */ - func experiencePublished( - _ experienceType: ExperienceType, - _ experienceId: Int - ) { - if experienceType == .flow { - screenViewEntity?.updateSeenFlowExperiences(experienceId) - } else { - screenViewEntity?.updateSeenSurveyExperiences(experienceId) - } + /// Acts as the cached identify event: the pending identify still in the queue. + func getUserIdFromQueue() -> String? { + eventsQueue.find(where: { $0.isIdentifyEvent })?.userId } } @@ -985,4 +1205,16 @@ extension AnalyticsPublisher { } } + +#if DEBUG +extension AnalyticsPublisher { + func mockGetEventsToFlush() -> [Event] { + return eventsQueue.getAll() + } + + func mockGetInitialQueue() -> [Event] { + return initialQueue.getAll() + } +} +#endif // swiftlint:enable file_length diff --git a/Sources/Userpilot/Analytics/Models/Event.swift b/Sources/Userpilot/Analytics/Models/Event.swift index b5764d78..893c9095 100644 --- a/Sources/Userpilot/Analytics/Models/Event.swift +++ b/Sources/Userpilot/Analytics/Models/Event.swift @@ -5,66 +5,55 @@ // Created by Motasem Hamed on 18/08/2024. // Copyright © 2024 Userpilot. All rights reserved. // -// [Brief Description] -// The `Event` struct is a holder for event details in the Userpilot SDK. -// It tracks information about various user actions, analytics events, and their associated metadata. -// import Foundation +/// Analytics event plus optional metadata used by publishing and offline storage. internal struct Event { // MARK: - Properties - /// The type of event, described by the `EventType` enum. - /// This determines the nature of the event (e.g., screen view, custom action). + /// Event kind. let type: EventType - /// A dictionary of optional properties that provide additional - /// metadata for the event (e.g., button clicked, item purchased). + /// Event metadata. var properties: Payload = nil - /// A dictionary of optional company-related properties. This can be - /// used to track events related to specific organizations or entities. + /// Company metadata. var company: Payload = nil - /// A dictionary of optional properties that provide screen + /// Screen metadata used by auto-capture. var screen: Payload = nil - /// For automatic interaction capture (`mobile_autocapture`), the backend-facing interaction category - /// (e.g. tap, text change). Sent on the track payload as `InteractionEventName` when present. + /// Auto-capture interaction category sent as `InteractionEventName`. var interactionEventName: String? - // MARK: - Variables from `EventType` + // MARK: - EventType helpers - var caseName: String { - return type.caseName + var isIdentifyEvent: Bool { + return type.isIdentifyEvent } - var eventName: String { - return type.eventName + var isScreenEvent: Bool { + return type.isScreenEvent } - var eventTitle: String { - return type.eventTitle ?? "" + var isTrackEvent: Bool { + return type.isTrackEvent } - var isEvent: Bool { - return type.isEvent + var eventName: String { + return type.eventName } - var isIdentifyEvent: Bool { - return type.isIdentifyEvent + var eventTitle: String { + return type.eventTitle ?? "" } var screenTitle: String? { return type.screenTitle } -// var screenTrackingPayload: ScreenTrackingPayload? { -// return type.screenTrackingPayload -// } - var userId: String? { return type.userId } @@ -81,6 +70,81 @@ internal struct Event { } } +// MARK: - Codable Conformance + +extension Event: Codable { + enum CodingKeys: String, CodingKey { + case type + case properties + case company + case screen + case interactionEventName = "interaction_event_name" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + + // Encode Payload ([String: Any]?) as JSON data + if let properties = properties { + let jsonData = try JSONSerialization.data(withJSONObject: properties, options: []) + if let jsonObject = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + try container.encode(jsonObject.mapValues { AnyCodable($0) }, forKey: .properties) + } + } + + if let company = company { + let jsonData = try JSONSerialization.data(withJSONObject: company, options: []) + if let jsonObject = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + try container.encode(jsonObject.mapValues { AnyCodable($0) }, forKey: .company) + } + } + + // Auto-capture events must survive the offline round-trip: their batch + // payload needs the screen context and the interaction event name. + if let screen = screen { + let jsonData = try JSONSerialization.data(withJSONObject: screen, options: []) + if let jsonObject = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + try container.encode(jsonObject.mapValues { AnyCodable($0) }, forKey: .screen) + } + } + + try container.encodeIfPresent(interactionEventName, forKey: .interactionEventName) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(EventType.self, forKey: .type) + + // Decode properties as [String: AnyCodable]? and convert to [String: Any]? + if let propertiesDict = try container.decodeIfPresent( + [String: AnyCodable].self, forKey: .properties) { + properties = propertiesDict.mapValues { $0.value } + } else { + properties = nil + } + + // Decode company as [String: AnyCodable]? and convert to [String: Any]? + if let companyDict = try container.decodeIfPresent( + [String: AnyCodable].self, forKey: .company) { + company = companyDict.mapValues { $0.value } + } else { + company = nil + } + + // Decode screen as [String: AnyCodable]? and convert to [String: Any]? + if let screenDict = try container.decodeIfPresent( + [String: AnyCodable].self, forKey: .screen) { + screen = screenDict.mapValues { $0.value } + } else { + screen = nil + } + + interactionEventName = try container.decodeIfPresent( + String.self, forKey: .interactionEventName) + } +} + extension Event { func toUser() -> User { return User(userId: userId ?? "", diff --git a/Sources/Userpilot/Analytics/Models/EventType.swift b/Sources/Userpilot/Analytics/Models/EventType.swift index fb04e247..9209e478 100644 --- a/Sources/Userpilot/Analytics/Models/EventType.swift +++ b/Sources/Userpilot/Analytics/Models/EventType.swift @@ -5,63 +5,25 @@ // Created by Motasem Hamed on 18/08/2024. // Copyright © 2024 Userpilot. All rights reserved. // -// [Brief Description] -// The `EventType` enum represents different types of events that can be tracked in the Userpilot SDK. -// These include custom events, screen views, and user identity grouping. -// import Foundation -/** - The `EventType` enum is used to categorize and manage different types of events within the Userpilot SDK. - - It defines three types of events: - - `event`: Tracks custom events, such as button clicks or other user interactions. - - `screen`: Tracks screen views, logging when a particular screen is opened. - - `identify`: Tracks a user being identified, either as part of a company or based on their unique ID. - - The enum also provides utility methods to retrieve metadata (such as event name and user ID) and check event types. - */ +/// Type-safe analytics event kinds and their wire names. internal enum EventType: Equatable { - /// Custom event for tracking a user action (e.g., a button click). + /// Custom track event. case event(String) - /// AutoCapture event for tracking a user action (e.g., a button click). + /// Auto-captured interaction event. case autoCaptureEvent - /// Event for tracking when a screen is opened (e.g., a specific view or page). + /// Screen view event. case screen(String) - /// Event for tracking a user's identity or association with a company. + /// Identify event. case identify(String) - // MARK: - Event Case Names - - /** - Returns the constant name of the event case (e.g., "event", "screen", "identify"). - This is used to categorize events in a standardized way. - */ - var caseName: String { - switch self { - case .event: - return EventType.trackEvent - case .autoCaptureEvent: - return EventType.trackAutoCaptureEvent - case .screen: - return EventType.screenEvent - case .identify: - return EventType.identifyCaseEvent - } - } - - // MARK: - Event Names - - /** - Returns the event name, which can be either: - - A custom event name for `event` cases. - - A predefined constant for `screen` and `identify` cases. - */ + /// Socket/database event name for this type. var eventName: String { switch self { case .event: @@ -77,27 +39,46 @@ internal enum EventType: Equatable { // MARK: - Type Checking - /// Checks if the event is a custom event (`event` case). + /// True only for custom track events. var isEvent: Bool { - return self.caseName == EventType.trackEvent + if case .event = self { + return true + } + return false } - /// Checks if the event is related to a screen view (`screen` case). - var isScreenEvent: Bool { - return self.caseName == EventType.screenEvent + /// True only for auto-captured interaction events. + var isAutoCaptureEvent: Bool { + if case .autoCaptureEvent = self { + return true + } + return false } - /// Checks if the event is an identification event (`identify` case). + /// True for identify events. var isIdentifyEvent: Bool { - return self.caseName == EventType.identifyCaseEvent + if case .identify = self { + return true + } + return false + } + + /// True for screen events. + var isScreenEvent: Bool { + if case .screen = self { + return true + } + return false + } + + /// True for events that use the track pipeline. + var isTrackEvent: Bool { + return isEvent || isAutoCaptureEvent } // MARK: - Associated Values - /** - Returns the user Id if the event is an `identify` event. If the event type is not `identify`, it returns `nil`. - This is useful when determining the user associated with an identity-related event. - */ + /// User id carried by identify events. var userId: String? { if case let .identify(userId) = self { return userId @@ -106,10 +87,7 @@ internal enum EventType: Equatable { } } - /** - Returns the screen name if the event is a `screen` event. If the event type is not `screen`, it returns `nil`. - This helps in identifying the screen associated with a screen-view event. - */ + /// Screen title carried by screen events. var screenTitle: String? { if case let .screen(title) = self { return title @@ -118,19 +96,7 @@ internal enum EventType: Equatable { } } -// /// Auto-capture screen metadata when the event is a `screen` event with an associated payload. -// var screenTrackingPayload: ScreenTrackingPayload? { -// if case let .screen(_, payload) = self { -// return payload -// } else { -// return nil -// } -// } - - /** - Returns the screen name if the event is a `screen` event. If the event type is not `screen`, it returns `nil`. - This helps in identifying the screen associated with a screen-view event. - */ + /// Event title carried by custom track events. var eventTitle: String? { if case let .event(title) = self { return title @@ -142,10 +108,62 @@ internal enum EventType: Equatable { extension EventType { - // Static constants - static let identifyEvent = "user_identify" - private static let identifyCaseEvent = "identify" - static let screenEvent = "screen" - private static let trackEvent = "track" - private static let trackAutoCaptureEvent = "mobile_autocapture" + // Wire values are defined once in `Constants.Event`; these are internal aliases + // for the `eventName` mapping above. + static let identifyEvent = Constants.Event.identifyEvent + static let screenEvent = Constants.Event.screenEvent + private static let trackEvent = Constants.Event.trackEvent + private static let trackAutoCaptureEvent = Constants.Event.autoCaptureEvent +} + +// MARK: - Codable Conformance + +extension EventType: Codable { + + /// Coding keys for the persisted offline-storage representation. + enum CodingKeys: String, CodingKey { + case type + case value + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + let value = try container.decode(String.self, forKey: .value) + + switch type { + case Constants.Event.trackEvent: + self = .event(value) + case Constants.Event.autoCaptureEvent: + self = .autoCaptureEvent + case Constants.Event.screenEvent: + self = .screen(value) + case Constants.Event.identifyEvent: + self = .identify(value) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown event type: \(type)" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .event(let eventName): + try container.encode(Constants.Event.trackEvent, forKey: .type) + try container.encode(eventName, forKey: .value) + case .autoCaptureEvent: + try container.encode(Constants.Event.autoCaptureEvent, forKey: .type) + try container.encode("", forKey: .value) + case .screen(let screenTitle): + try container.encode(Constants.Event.screenEvent, forKey: .type) + try container.encode(screenTitle, forKey: .value) + case .identify(let userId): + try container.encode(Constants.Event.identifyEvent, forKey: .type) + try container.encode(userId, forKey: .value) + } + } } diff --git a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift new file mode 100644 index 00000000..fd9e512b --- /dev/null +++ b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift @@ -0,0 +1,367 @@ +// +// OfflineEventsHandler.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 02/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// Manages offline event storage and retrieval for the Userpilot SDK. +// This handler is responsible for saving events to local storage when network is unavailable, +// restoring events when connection is re-established, and batch-sending them. +// + +import Foundation + +// MARK: - OfflineEventsHandling Protocol + +// swiftlint:disable all +internal protocol OfflineEventsHandling: AnyObject { + /// Checks if events should be saved offline due to network unavailability + var shouldSaveOffline: Bool { get } + + /// Fast check to determine if there are cached events in local storage + var hasCachedEvents: Bool { get } + + /// Saves an event to local storage when network is unavailable + /// - Parameter clearStoredEventsFirst: Set on an offline user switch — batch items + /// carry no user id, so the old user's stored events must not be replayed + /// under the new user. + func saveEventToLocalStorage(event: Event, clearStoredEventsFirst: Bool) + + /// Restores events from local storage and publishes them as a batch + /// - Parameter completion: Optional callback invoked when restoration is complete + func restoreEventsFromLocalStorage(completion: (() -> Void)?) + + /// Clears all events from local storage + func clearLocalEvents() +} + +// MARK: - OfflineEventsHandler + +/// Manages offline event storage and retrieval for the Userpilot SDK. +/// +/// This handler is responsible for: +/// - Saving events to local storage when network is unavailable +/// - Restoring events from local storage when connection is re-established +/// - Clearing local events when necessary (user switch, logout, etc.) +/// +/// The handler ensures events are not lost during network outages by persisting them locally and +/// batch-sending them once the connection is available. +internal class OfflineEventsHandler: OfflineEventsHandling { + + // MARK: - Properties + + /// A weak reference to ExperienceRendering would be expected here to avoid a retain cycle with AnalyticsPublisher + private weak var container: DIContainer? + private let config: Userpilot.Config + private let storage: DataStoring + private let networkMonitor: NetworkMonitoring + private let socketManager: SocketManaging + private let eventDatabaseStorage: EventStoring + private let logger: Logging + + /// Serial background queue for processing offline events + private let offlineEventsQueue = DispatchQueue( + label: "com.userpilot.offlineevents", + qos: .utility + ) + + /// Completion callback to be invoked after offline events are sent + private var offlineRestoreCompletion: (() -> Void)? + + // MARK: - Initialization + + init(container: DIContainer) { + self.container = container + self.config = container.resolve(Userpilot.Config.self) + self.storage = container.resolve(DataStoring.self) + self.networkMonitor = container.resolve(NetworkMonitoring.self) + self.socketManager = container.resolve(SocketManaging.self) + self.eventDatabaseStorage = container.resolve(EventStoring.self) + self.logger = config.logger + + // Register as socket subscription listener + self.socketManager.registerCallback(self) + } + + // MARK: - OfflineEventsHandling + + /// Checks if network is available for sending events. + /// - Returns: true if network is unavailable and events should be saved offline + var shouldSaveOffline: Bool { + return !networkMonitor.isNetworkAvailable && networkMonitor.isReady + } + + /// Fast check to determine if there are cached events in local storage. + /// This is optimized for performance using a simple count query. + /// - Returns: true if there are stored events, false otherwise + var hasCachedEvents: Bool { + return eventDatabaseStorage.hasEvents() + } + + /** + * Saves an event to local storage when network is unavailable. + * + * This method serializes the event and stores it in the local database for later retrieval when + * network becomes available. + * + * - Parameter event: The event to save to local storage + */ + func saveEventToLocalStorage(event: Event, clearStoredEventsFirst: Bool = false) { + tryCatch { + // Prefer the stored user id, fall back to the event's own (covers an + // offline identify arriving before storage.userId is set). + let userId = storage.userId.isNotEmpty ? storage.userId : (event.userId ?? "") + guard !userId.isEmpty else { return } + + // Offline user switch: drop the previous user's stored events first. + if clearStoredEventsFirst { + eventDatabaseStorage.deleteAllEvents() + } + + // Create event storage from event + guard let eventStorage = EventStorage(event, config.token, userId) else { + logger.error("⚠️ Failed to encode event to JSON") + return + } + + // Save this item to local storage so we can retry later if needed + eventDatabaseStorage.saveEvent( + eventStorage, + completion: { [weak self] saved in + if saved { + self?.logger.info( + "🗃️ Event saved to local storage: %{public}@", event.eventName) + } else { + self?.logger.error("⚠️ Event not saved - storage limit exceeded") + } + }) + } + } + + /* + * Restores events from local storage and publishes them as a batch. + * + * This method is called when: + * - Socket connection is established + * - Network becomes available + * - Event queue is empty + * + * Events are sent as a single batch request to minimize socket overhead. + * The entire operation runs on a dedicated serial background queue. + * + * - Parameter completion: Optional callback invoked when restoration and sending is complete + */ + func restoreEventsFromLocalStorage(completion: (() -> Void)? = nil) { + offlineEventsQueue.async(flags: .barrier) { [weak self] in + guard let self else { + completion?() + return + } + tryCatch { + // getAllEventsAndDelete already runs on its own background queue + self.eventDatabaseStorage.getAllEventsAndDelete { [weak self] localEvents in + guard let self else { + completion?() + return + } + + if localEvents.isEmpty { + completion?() + return + } + + self.logger.info( + "🗃️ Restoring %{public}d events from local storage", localEvents.count) + + // Process events on our serial queue (heavy operation) + self.offlineEventsQueue.async { [weak self] in + guard let self else { + completion?() + return + } + + var eventsList: [[String: Any]] = [] + + for eventStorage in localEvents { + guard let event = eventStorage.toEvent() else { + self.logger.error("⚠️ Failed to decode event from local storage") + continue + } + + var eventData: [String: Any]? + + switch event.type { + case .identify: + eventData = self.buildIdentifyEventData( + event: event, eventStorage: eventStorage) + case .screen: + eventData = self.buildScreenEventData( + event: event, eventStorage: eventStorage) + case .event, .autoCaptureEvent: + eventData = self.buildTrackEventData( + event: event, eventStorage: eventStorage) + } + + if let eventData = eventData { + eventsList.append(eventData) + } + } + + if !eventsList.isEmpty { + // Store completion to be called after socket sends the batch + self.offlineRestoreCompletion = completion + + let batchPayload: [String: Any] = [ + Constants.OfflineEvents.eventsProperty: eventsList + ] + self.socketManager.publish( + Constants.Event.batchEventsEvent, + payload: batchPayload + ) + self.logger.info( + "🗃️ Restored %{public}d events from local storage as batch", + eventsList.count + ) + } else { + completion?() + } + } + } + } + } + } + + /** + * Clears all events from local storage. + * + * This method is called when: + * - User logs out + * - User switches to a different account + * - SDK is reset + */ + func clearLocalEvents() { + eventDatabaseStorage.deleteAllEvents() + } + + // MARK: - Private Methods + + /** + * Builds event data map for identify events. + * + * - Parameters: + * - event: The identify event + * - eventStorage: The stored event metadata + * - Returns: Map containing event data for batch sending + */ + private func buildIdentifyEventData( + event: Event, + eventStorage: EventStorage + ) -> [String: Any] { + var eventData: [String: Any] = [:] + eventData[Constants.OfflineEvents.eventTypeProperty] = event.eventName + eventData[Constants.OfflineEvents.createdAtProperty] = formatTimestampWithTimezone( + eventStorage.createdAt) + eventData[Constants.Analytics.metaDataProperty] = event.properties ?? [:] + + if let company = event.company, !company.isEmpty { + eventData[Constants.Analytics.identifyCompanyProperty] = company + } + + return eventData + } + + /** + * Builds event data map for screen events. + * + * - Parameters: + * - event: The screen event + * - eventStorage: The stored event metadata + * - Returns: Map containing event data for batch sending + */ + private func buildScreenEventData( + event: Event, + eventStorage: EventStorage + ) -> [String: Any] { + var eventData: [String: Any] = [:] + eventData[Constants.OfflineEvents.eventTypeProperty] = event.eventName + eventData[Constants.OfflineEvents.createdAtProperty] = formatTimestampWithTimezone( + eventStorage.createdAt) + eventData[Constants.Analytics.screenTitleProperty] = event.screenTitle ?? "" + eventData[Constants.Analytics.metaDataProperty] = [Constants.Analytics.fakeReload: false] + return eventData + } + + /** + * Builds event data map for track events. + * + * - Parameters: + * - event: The track event + * - eventStorage: The stored event metadata + * - Returns: Map containing event data for batch sending + */ + private func buildTrackEventData( + event: Event, + eventStorage: EventStorage + ) -> [String: Any] { + var eventData: [String: Any] = [:] + eventData[Constants.OfflineEvents.eventTypeProperty] = event.eventName + eventData[Constants.OfflineEvents.createdAtProperty] = formatTimestampWithTimezone( + eventStorage.createdAt) + // Auto-capture events carry the backend-facing interaction name; plain + // track events use their title (Android batch parity). + eventData[Constants.Analytics.eventNameProperty] = + event.interactionEventName ?? event.eventTitle + eventData[Constants.Analytics.metaDataProperty] = event.properties ?? [:] + if let screen = event.screen { + eventData[Constants.Analytics.screenProperty] = screen + } + + return eventData + } + + /** + * Formats a timestamp (in milliseconds) to ISO-8601 format with timezone information. + * + * - Parameter timestampMillis: The timestamp in milliseconds since epoch + * - Returns: ISO-8601 formatted string with timezone (e.g., "2025-10-28T14:30:00.000+03:00") + */ + private func formatTimestampWithTimezone(_ timestampMillis: TimeInterval) -> String { + // Convert milliseconds to seconds for Date initialization + let date = Date(timeIntervalSince1970: timestampMillis / 1_000.0) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + +} + +// MARK: - SocketSubscription + +extension OfflineEventsHandler: SocketSubscription { + + /// Handles socket event sent and manages offline batch event completion + func onSocketEventSent( + _ eventName: String, + _ payload: Payload, + _ message: Message, + _ eventSent: Bool + ) { + if eventName == Constants.Event.batchEventsEvent { + // Locked policy (release/Android parity): events were already deleted + // before the send, so the restore completes on ANY resolution — + // ok, error, or timeout. A failed batch is accepted at-most-once loss. + if eventSent { + logger.info("✅ Offline batch events sent successfully") + } else { + logger.error("⚠️ Offline batch events send failed or timed out") + } + if let completion = offlineRestoreCompletion { + offlineRestoreCompletion = nil + completion() + } + } + } + +} +// swiftlint:enable all diff --git a/Sources/Userpilot/Analytics/Models/ScreenViewEntity.swift b/Sources/Userpilot/Analytics/stateMachines/ScreenSessionStateMachine.swift similarity index 50% rename from Sources/Userpilot/Analytics/Models/ScreenViewEntity.swift rename to Sources/Userpilot/Analytics/stateMachines/ScreenSessionStateMachine.swift index 6b25dd9a..ba248ef1 100644 --- a/Sources/Userpilot/Analytics/Models/ScreenViewEntity.swift +++ b/Sources/Userpilot/Analytics/stateMachines/ScreenSessionStateMachine.swift @@ -1,53 +1,49 @@ // -// ScreenViewEntity.swift +// ScreenSessionStateMachine.swift // Userpilot SDK // // Created by Motasem Hamed on 24/11/2024. // Copyright © 2024 Userpilot. All rights reserved. // -// [ScreenViewEntity] -// The `ScreenViewEntity` class represents the state and behavior associated with a screen view -// in the Userpilot SDK. It tracks the current event, handles fake reload logic, manages seen -// experiences, and provides utility methods to update and reset its state. +// [ScreenSessionStateMachine] +// Tracks the current screen event and the content seen during that screen session. // import Foundation -/// Represents the state and behavior of a screen view in the Userpilot SDK. -internal class ScreenViewEntity { +/// Holds screen-session state used when publishing screen and fake-reload events. +internal class ScreenSessionStateMachine { /// The current event associated with the screen view. var event: Event - /// A set of IDs representing the experiences seen during this screen view. + /// IDs for flow and survey experiences seen during this screen session. var seenExperiences: Set var seenSurveys: Set - /// Initializes a new `ScreenViewEntity` instance. + /// Initializes a new `ScreenSessionStateMachine` instance. /// /// - Parameters: /// - event: The current event associated with the screen view. - /// - fakeReload: A flag indicating if the screen view was triggered by a fake reload event. Defaults to `false`. /// - seenExperiences: A set of IDs representing seen experiences. Defaults to an empty set. - /// - didTriggerEvent: A flag indicating whether an event was triggered during the screen view. - /// Defaults to `false`. + /// - seenSurveys: A set of IDs representing seen surveys. Defaults to an empty set. init(event: Event, seenExperiences: Set = [], seenSurveys: Set = []) { self.event = event self.seenExperiences = seenExperiences self.seenSurveys = seenSurveys } - /// Resets the state of the screen view entity. + /// Clears tracked content for the active screen session. func resetState() { seenExperiences.removeAll() seenSurveys.removeAll() } - /// Adds an experience Id to the set of seen experiences. + /// Adds a flow experience ID to the seen set. func updateSeenFlowExperiences(_ experienceId: Int) { seenExperiences.insert(experienceId) } - /// Adds an experience Id to the set of seen experiences. + /// Adds a survey experience ID to the seen set. func updateSeenSurveyExperiences(_ experienceId: Int) { seenSurveys.insert(experienceId) } diff --git a/Sources/Userpilot/Analytics/stateMachines/UserSessionStateMachine.swift b/Sources/Userpilot/Analytics/stateMachines/UserSessionStateMachine.swift new file mode 100644 index 00000000..40e5df16 --- /dev/null +++ b/Sources/Userpilot/Analytics/stateMachines/UserSessionStateMachine.swift @@ -0,0 +1,344 @@ +// +// UserSessionStateMachine.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 23/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Manages user session lifecycle states for analytics and screen tracking. +// + +import Foundation + +// MARK: - UserSessionState + +/// Represents the various states of a user session in the Userpilot SDK. +/// This enum models the lifecycle of user identification and screen tracking. +internal enum UserSessionState { + /// Normal operation state - user is identified and initial screen has been sent. + case normal + + /// User switching state - a user switch operation is in progress, socket closed with queued + /// events. Waiting for identify event to be processed. + case userSwitching + + /// Awaiting initial screen state - user has been identified but initial screen event has not + /// been sent yet. This is for the first-time user identification. + case awaitingInitialScreen + + /// User switching with awaiting initial screen state - combines both conditions: + /// 1. User switch is in progress (affects startSession=true, fake_reload=false) + /// 2. Awaiting initial screen for the new user (affects screen event triggering) + case userSwitchingAwaitingScreen + + /// Coming from background state - need to request fake reload screen event. + case backgroundToInitialScreen + + /// Checks if a user switch operation is currently in progress. + func isUserSwitching() -> Bool { + switch self { + case .userSwitching, .userSwitchingAwaitingScreen: + return true + default: + return false + } + } + + /// Checks if an initial screen event needs to be sent. + func needsInitialScreen() -> Bool { + switch self { + case .awaitingInitialScreen, .userSwitching, .userSwitchingAwaitingScreen: + return true + default: + return false + } + } +} + +// MARK: - CustomStringConvertible + +extension UserSessionState: CustomStringConvertible { + var description: String { + switch self { + case .normal: + return "Normal" + case .userSwitching: + return "UserSwitching" + case .awaitingInitialScreen: + return "AwaitingInitialScreen" + case .userSwitchingAwaitingScreen: + return "UserSwitchingAwaitingScreen" + case .backgroundToInitialScreen: + return "BackgroundToInitialScreen" + } + } +} + +// MARK: - UserSessionStateManaging + +/// Protocol defining user session state management behavior. +/// Objects that conform to `UserSessionStateManaging` are responsible for managing +/// user session state transitions and providing thread-safe access to the current state. +internal protocol UserSessionStateManaging: AnyObject { + + // MARK: - State Access + + /// Gets the current state + func getCurrentState() -> UserSessionState + + /// Checks if a user switch operation is currently in progress + func isUserSwitching() -> Bool + + /// Checks if an initial screen event needs to be sent + func needsInitialScreen() -> Bool + + /// Checks if currently in Normal state + func isNormal() -> Bool + + /// Checks if currently in AwaitingInitialScreen state + func isAwaitingInitialScreen() -> Bool + + /// Checks if currently in UserSwitchingAwaitingScreen state + func isUserSwitchingAwaitingScreen() -> Bool + + // MARK: - State Transition Methods + + /// Transitions to Normal state after initial screen event is sent + func markNormal() + + /// Transitions to BackgroundToInitialScreen state when the app returns from background + func markUserBackFromBackground() + + /// Transitions to UserSwitching state when a user switch is detected + func markUserSwitch() + + /// Transitions to AwaitingInitialScreen or UserSwitchingAwaitingScreen state + func markAwaitingInitialScreen() + + // MARK: - High-Level Operations + + /// Determines if this is a post-identification scenario requiring a screen event + func isPostIdentificationContext(_ eventName: String) -> Bool + + /// Determines if an initial screen event should be requested + func shouldRequestInitialScreenEvent(_ eventsQueueEmpty: Bool, _ hasCurrentScreen: Bool) -> Bool + + /// Gets the configuration for post-identification screen event + func getPostIdentificationScreenConfig(currentStartSession: Bool) + -> UserSessionStateMachine.PostIdentificationScreenConfig + + /// Gets the post-identification start session configuration + func getPostIdentificationStartSessionConfig(currentStartSession: Bool) -> Bool + + /// Gets the post-identification fake reload configuration + func getPostIdentificationFakeReloadConfig() -> Bool +} + +// MARK: - UserSessionStateMachine + +/// Manages the user session state transitions and provides thread-safe access to the current state. +/// Encapsulates all state transition logic in one place. +/// +/// This class is responsible for: +/// - Managing the atomic state reference +/// - Providing thread-safe state transitions +/// - Logging all state changes for debugging +/// - Determining post-identification context and screen requirements +internal class UserSessionStateMachine { + + // MARK: - Properties + + /// SDK logger. + private let logger: Logging + + /** + * The current user session state using thread-safe atomic reference. + * Starts in AwaitingInitialScreen state as no user has been identified yet. + */ + private let state: AtomicReference + + // MARK: - Initialization + + init(container: DIContainer) { + self.logger = container.resolve(Userpilot.Config.self).logger + self.state = AtomicReference(.awaitingInitialScreen) + } + + // MARK: - State Access + + /// Gets the current state + func getCurrentState() -> UserSessionState { + return state.value + } + + /// Checks if a user switch operation is currently in progress + func isUserSwitching() -> Bool { + return state.value.isUserSwitching() + } + + /// Checks if an initial screen event needs to be sent + func needsInitialScreen() -> Bool { + return state.value.needsInitialScreen() + } + + /// Checks if currently in Normal state + func isNormal() -> Bool { + if case .normal = state.value { + return true + } + return false + } + + /// Checks if currently in AwaitingInitialScreen state + func isAwaitingInitialScreen() -> Bool { + if case .awaitingInitialScreen = state.value { + return true + } + return false + } + + /// Checks if currently in UserSwitchingAwaitingScreen state + func isUserSwitchingAwaitingScreen() -> Bool { + if case .userSwitchingAwaitingScreen = state.value { + return true + } + return false + } + + // MARK: - State Transition Methods + + /** + * Transitions to Normal state after initial screen event is sent. + * This indicates normal operation where user is fully identified and tracking. + */ + func markNormal() { + state.value = .normal + logger.info("📝 User session state: Normal") + } + + /** + * Transitions to BackgroundToInitialScreen state when the app returns from background. + * This state ensures proper session handling with a fake reload screen event. + */ + func markUserBackFromBackground() { + state.value = .backgroundToInitialScreen + logger.info("📝 User session state: BackgroundToInitialScreen") + } + + /** + * Transitions to UserSwitching state when a user switch is detected. + * This state ensures proper session handling and initial screen event for the new user. + */ + func markUserSwitch() { + state.value = .userSwitching + logger.info("📝 User session state: UserSwitching") + } + + /** + * Transitions to AwaitingInitialScreen state after user identification. + * If currently in UserSwitching state, transitions to UserSwitchingAwaitingScreen + * instead to preserve the user switch context. + */ + func markAwaitingInitialScreen() { + let currentState = state.value + let newState: UserSessionState + + if case .userSwitching = currentState { + newState = .userSwitchingAwaitingScreen + } else { + newState = .awaitingInitialScreen + } + + state.value = newState + logger.info("📝 User session state: %@", String(describing: newState)) + } + + // MARK: - High-Level Operations + + /** + * Determines if this is a post-identification scenario requiring a screen event. + * Returns true if we're in a state that needs an initial screen or if this is an identify event. + * + * @param eventName The name of the event being processed + * @return true if screen event should be requested after this event + */ + func isPostIdentificationContext(_ eventName: String) -> Bool { + return (eventName == Constants.Event.identifyEvent || needsInitialScreen()) + } + + /** + * Determines if an initial screen event should be requested based on current state and conditions. + * + * @param eventsQueueEmpty Whether the events queue is empty + * @param hasCurrentScreen Whether there's a current screen available + * @return true if initial screen event should be requested + */ + func shouldRequestInitialScreenEvent( + _ eventsQueueEmpty: Bool, + _ hasCurrentScreen: Bool + ) -> Bool { + return eventsQueueEmpty && hasCurrentScreen + } + + /** + * Handles the post-identification screen event logic and returns the configuration + * for how to publish the screen event. + * + * @param currentStartSession The current value of startSession flag + * @return PostIdentificationScreenConfig with startSession and fakeReload settings + */ + func getPostIdentificationScreenConfig( + currentStartSession: Bool + ) -> PostIdentificationScreenConfig { + let isUserSwitch = isUserSwitching() + + return PostIdentificationScreenConfig( + // For user switch, ensure startSession is true + startSession: isUserSwitch ? true : currentStartSession, + // User switch = no fake reload (true screen change) + // Normal post-identify = fake reload + isFakeReload: !isUserSwitch + ) + } + + /** + * Gets the post-identification start session configuration. + * + * @param currentStartSession The current value of startSession flag + * @return true if this should be marked as a session start + */ + func getPostIdentificationStartSessionConfig( + currentStartSession: Bool + ) -> Bool { + return isUserSwitching() ? true : currentStartSession + } + + /** + * Gets the post-identification fake reload configuration. + * + * @return true if this is a fake reload (post-identify), false for real screen change + */ + func getPostIdentificationFakeReloadConfig() -> Bool { + return !isUserSwitching() + } +} + +// MARK: - UserSessionStateManaging Conformance + +extension UserSessionStateMachine: UserSessionStateManaging {} + +// MARK: - PostIdentificationScreenConfig + +extension UserSessionStateMachine { + /** + * Configuration for how to handle post-identification screen events. + * + * @param startSession Whether this should be marked as a session start + * @param isFakeReload Whether this is a fake reload (post-identify) or real screen change + */ + struct PostIdentificationScreenConfig { + let startSession: Bool + let isFakeReload: Bool + } +} diff --git a/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift b/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift index 98e4d680..7ffd9746 100644 --- a/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift +++ b/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift @@ -231,7 +231,7 @@ extension AutoCaptureCoordinater: AutoCaptureCoordinating { let leaf = screenClass.trimmingCharacters(in: .whitespacesAndNewlines) if !leaf.isEmpty { let escaped = leaf.replacingOccurrences(of: "\"", with: "\\\"") - properties[AutoCaptureConstants.hierarchy] = "\(escaped):attr__index=\"\(tabIndex)\"" + properties[Constants.AutoCapture.hierarchy] = "\(escaped):attr__index=\"\(tabIndex)\"" appendScreenNameSegmentToHierarchy(&properties) } @@ -438,7 +438,7 @@ private extension AutoCaptureCoordinater { String(describing: UICollectionView.self), String(describing: UIStackView.self), String(describing: UIView.self), - AutoCaptureConstants.swiftUIView + Constants.AutoCapture.swiftUIView ] /// SwiftUI framework-internal layout / hosting scaffolding classes that surface as the resolved @@ -500,9 +500,9 @@ private extension AutoCaptureCoordinater { /// True when any identifying string is present (including redacted placeholder). private func windowTouchHasMetadata(_ properties: [String: Any]) -> Bool { let keys: [String] = [ - AutoCaptureConstants.targetText, - AutoCaptureConstants.accessibilityLabel, - AutoCaptureConstants.accessibilityIdentifier + Constants.AutoCapture.targetText, + Constants.AutoCapture.accessibilityLabel, + Constants.AutoCapture.accessibilityIdentifier ] for key in keys { guard let string = properties[key] as? String else { continue } @@ -515,13 +515,13 @@ private extension AutoCaptureCoordinater { /// Drops taps on structural containers, private `_UI…` internals, and bare SwiftUI views when there is no signal. private func shouldPublishWindowLevelTouch(_ properties: [String: Any]) -> Bool { - guard let elementType = properties[AutoCaptureConstants.targetClass] as? String else { + guard let elementType = properties[Constants.AutoCapture.targetClass] as? String else { return false } if windowTouchIsPrivateUIKitElementType(elementType) { return false } - let hierarchy = properties[AutoCaptureConstants.hierarchy] as? String + let hierarchy = properties[Constants.AutoCapture.hierarchy] as? String if windowTouchIsSystemKeyboardChrome(elementType: elementType, hierarchy: hierarchy) { return false } @@ -539,23 +539,23 @@ private extension AutoCaptureCoordinater { /// Maps window `sendEvent` dictionaries into the same payload shape as other interaction paths. private func interactionPayload(fromWindowClick properties: [String: Any]) -> InteractionPayload? { - guard let elementType = properties[AutoCaptureConstants.targetClass] as? String else { + guard let elementType = properties[Constants.AutoCapture.targetClass] as? String else { return nil } var payload = InteractionPayload( interactionType: .tap, elementType: elementType ) - if let targetText = properties[AutoCaptureConstants.targetText] as? String { + if let targetText = properties[Constants.AutoCapture.targetText] as? String { payload.elementText = targetText } - if let accessibilityLabel = properties[AutoCaptureConstants.accessibilityLabel] as? String { + if let accessibilityLabel = properties[Constants.AutoCapture.accessibilityLabel] as? String { payload.accessibilityLabel = accessibilityLabel } - if let targetResourceId = properties[AutoCaptureConstants.accessibilityIdentifier] as? String { + if let targetResourceId = properties[Constants.AutoCapture.accessibilityIdentifier] as? String { payload.accessibilityIdentifier = targetResourceId } - payload.hierarchy = properties[AutoCaptureConstants.hierarchy] as? String + payload.hierarchy = properties[Constants.AutoCapture.hierarchy] as? String return payload } @@ -568,18 +568,18 @@ private extension AutoCaptureCoordinater { /// When hierarchy was built with no owning VC, the root is /// `unknownScreenHierarchyPlaceholder`; swap in the tracked screen class. private func replaceUnknownScreenPlaceholderInHierarchy(_ properties: inout [String: Any]) { - let placeholder = AutoCaptureConstants.unknownScreenHierarchyPlaceholder - guard var hierarchy = properties[AutoCaptureConstants.hierarchy] as? String, + let placeholder = Constants.AutoCapture.unknownScreenHierarchyPlaceholder + guard var hierarchy = properties[Constants.AutoCapture.hierarchy] as? String, hierarchy.contains(placeholder) else { return } guard let screenClass = screenNameTracker.getCurrentPayload()?.screenClass, !screenClass.isEmpty else { return } hierarchy = hierarchy.replacingOccurrences(of: placeholder, with: screenClass) - properties[AutoCaptureConstants.hierarchy] = hierarchy + properties[Constants.AutoCapture.hierarchy] = hierarchy } /// Appends `;SCREEN_NAME` to the view hierarchy using `screenNameTracker`. private func appendScreenNameSegmentToHierarchy(_ properties: inout [String: Any]) { - guard var hierarchy = properties[AutoCaptureConstants.hierarchy] as? String, + guard var hierarchy = properties[Constants.AutoCapture.hierarchy] as? String, !hierarchy.isEmpty, let payload = screenNameTracker.getCurrentPayload() else { return } @@ -588,7 +588,7 @@ private extension AutoCaptureCoordinater { let escaped = screenClass.replacingOccurrences(of: "\"", with: "\\\"") hierarchy += ";\(escaped)" - properties[AutoCaptureConstants.hierarchy] = hierarchy + properties[Constants.AutoCapture.hierarchy] = hierarchy } /// Shared `mobile_autocapture` merge and publish. `publishInteractionPayload` @@ -634,7 +634,7 @@ private extension AutoCaptureCoordinater { guard config.enableInteractionAutoCapture else { return } var payload = InteractionPayload( interactionType: .viewPresented, - elementType: AutoCaptureConstants.elementTypeUIAlertController + elementType: Constants.AutoCapture.elementTypeUIAlertController ) payload.dialogTitle = screen.alertTitle payload.dialogMessage = screen.alertMessage @@ -647,7 +647,7 @@ private extension AutoCaptureCoordinater { // `updateScreen(...)` is called). let dialogClass = screen.screenClass.trimmingCharacters(in: .whitespacesAndNewlines) let leaf = dialogClass.isEmpty - ? AutoCaptureConstants.elementTypeUIAlertController + ? Constants.AutoCapture.elementTypeUIAlertController : dialogClass.replacingOccurrences(of: "\"", with: "\\\"") payload.hierarchy = "\(leaf):attr__index=\"0\"" @@ -659,8 +659,8 @@ private extension AutoCaptureCoordinater { /// Builds the properties dictionary for a tab-selection event. func buildTabProperties(name: String, index: Int) -> [String: Any] { let props: [String: Any] = [ - AutoCaptureConstants.tabName: name, - AutoCaptureConstants.tabIndex: index + Constants.AutoCapture.tabName: name, + Constants.AutoCapture.tabIndex: index ] return props } @@ -669,10 +669,10 @@ private extension AutoCaptureCoordinater { /// Always includes the raw interaction type and the UI framework tag. func buildInternalProperties(for interactionType: InteractionType) -> [String: Any] { var result: [String: String] = [ - AutoCaptureConstants.rawInteractionType: interactionType.rawValue + Constants.AutoCapture.rawInteractionType: interactionType.rawValue ] if let framework = config.appFramework?.rawValue { - result[AutoCaptureConstants.uiFramework] = framework + result[Constants.AutoCapture.uiFramework] = framework } return result } diff --git a/Sources/Userpilot/AutoCapture/Extensions/ScreenNameResolver.swift b/Sources/Userpilot/AutoCapture/Extensions/ScreenNameResolver.swift index f3de434e..6dc496ae 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/ScreenNameResolver.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/ScreenNameResolver.swift @@ -255,7 +255,7 @@ extension UIView { func userpilotResolvedScreenName() -> String { guard let viewController = closestViewController() else { - return AutoCaptureConstants.unknownScreenHierarchyPlaceholder + return Constants.AutoCapture.unknownScreenHierarchyPlaceholder } return viewController.screenClassName } diff --git a/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift index 5e6dad17..e7976c74 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift @@ -50,11 +50,11 @@ internal extension UICollectionViewCell { payload.targetClass = labelViewType } payload.elementText = (touchedView ?? self).shouldRedactText() - ? AutoCaptureConstants.reductText + ? Constants.AutoCapture.reductText : userpilotLabel } else if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = AutoCaptureConstants.reductText + payload.elementText = Constants.AutoCapture.reductText } else { payload.elementText = touchedView?.getTextContent() payload.accessibilityIdentifier = accessibilityIdentifier diff --git a/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift index d6f83131..b0e8af96 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift @@ -92,7 +92,7 @@ internal extension UIControl { elementType: elementType ) if config.enableInteractionValueCapture { - payload.sourceProperties[AutoCaptureConstants.isChecked] = switchControl.isOn + payload.sourceProperties[Constants.AutoCapture.isChecked] = switchControl.isOn } case let slider as UISlider: @@ -101,7 +101,7 @@ internal extension UIControl { elementType: elementType ) if config.enableInteractionValueCapture { - payload.sourceProperties[AutoCaptureConstants.selectedValue] = slider.value + payload.sourceProperties[Constants.AutoCapture.selectedValue] = slider.value } case let segmentedControl as UISegmentedControl: @@ -110,11 +110,11 @@ internal extension UIControl { elementType: elementType ) if config.enableInteractionValueCapture { - payload.sourceProperties[AutoCaptureConstants.selectedIndex] = segmentedControl.selectedSegmentIndex + payload.sourceProperties[Constants.AutoCapture.selectedIndex] = segmentedControl.selectedSegmentIndex let raw = segmentedControl.titleForSegment(at: segmentedControl.selectedSegmentIndex) - let title: String? = shouldRedactText() ? (raw != nil ? AutoCaptureConstants.reductText : nil) : raw + let title: String? = shouldRedactText() ? (raw != nil ? Constants.AutoCapture.reductText : nil) : raw if let title { - payload.sourceProperties[AutoCaptureConstants.selectedValue] = title + payload.sourceProperties[Constants.AutoCapture.selectedValue] = title } } @@ -128,7 +128,7 @@ internal extension UIControl { // bound value. Drop `selected_value` for SwiftUI rather than emit a misleading 0. In UIKit // `UIStepper.value` is authoritative, so it's still captured there. if config.enableInteractionValueCapture, config.appFramework != .SwiftUI { - payload.sourceProperties[AutoCaptureConstants.selectedValue] = stepper.value + payload.sourceProperties[Constants.AutoCapture.selectedValue] = stepper.value } case let datePicker as UIDatePicker: @@ -137,7 +137,7 @@ internal extension UIControl { elementType: elementType ) if config.enableInteractionValueCapture { - payload.sourceProperties[AutoCaptureConstants.selectedDate] = + payload.sourceProperties[Constants.AutoCapture.selectedDate] = ISO8601DateFormatter().string(from: datePicker.date) } @@ -147,7 +147,7 @@ internal extension UIControl { elementType: elementType ) if config.enableInteractionValueCapture { - payload.sourceProperties[AutoCaptureConstants.selectedIndex] = pageControl.currentPage + payload.sourceProperties[Constants.AutoCapture.selectedIndex] = pageControl.currentPage } default: @@ -174,7 +174,7 @@ internal extension UIControl { payload.hierarchy = UIKitViewResolver.resolvePath(view: effectiveView, leafIndexOverride: leafIndexOverride) if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = AutoCaptureConstants.reductText + payload.elementText = Constants.AutoCapture.reductText } if let userpilotLabel = resolveUserpilotLabel() { @@ -182,7 +182,7 @@ internal extension UIControl { payload.targetClass = labelViewType } payload.elementText = shouldRedactText() - ? AutoCaptureConstants.reductText + ? Constants.AutoCapture.reductText : userpilotLabel } diff --git a/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift index 32855fe3..cc647077 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift @@ -99,7 +99,7 @@ internal extension UIPickerView { ) // Row index - payload.sourceProperties[AutoCaptureConstants.selectedIndex] = row + payload.sourceProperties[Constants.AutoCapture.selectedIndex] = row // Selected title from the delegate. UIKit hosts typically implement // `pickerView(_:titleForRow:forComponent:)`; SwiftUI's `UIKitWheelPicker` instead @@ -108,8 +108,8 @@ internal extension UIPickerView { // so `selected_value` is populated for both UIKit and SwiftUI pickers. if config.enableInteractionValueCapture, let title = userpilotResolvedSelectedTitle(forRow: row, component: component) { - payload.sourceProperties[AutoCaptureConstants.selectedValue] = - shouldRedactText() ? AutoCaptureConstants.reductText : title + payload.sourceProperties[Constants.AutoCapture.selectedValue] = + shouldRedactText() ? Constants.AutoCapture.reductText : title } // View path and accessibility diff --git a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift index d818e030..fcfd5afe 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift @@ -50,11 +50,11 @@ internal extension UITableViewCell { payload.targetClass = labelViewType } payload.elementText = (touchedView ?? self).shouldRedactText() - ? AutoCaptureConstants.reductText + ? Constants.AutoCapture.reductText : userpilotLabel } else if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = AutoCaptureConstants.reductText + payload.elementText = Constants.AutoCapture.reductText } else { payload.elementText = touchedView?.getTextContent() payload.accessibilityIdentifier = accessibilityIdentifier diff --git a/Sources/Userpilot/AutoCapture/Extensions/UITextInput+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UITextInput+AutoCapture.swift index 25a21c33..d2b7446b 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UITextInput+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UITextInput+AutoCapture.swift @@ -34,8 +34,8 @@ internal extension UITextField { elementType: "UITextField" ) - payload.sourceProperties[AutoCaptureConstants.hasText] = !(text?.isEmpty ?? true) - payload.sourceProperties[AutoCaptureConstants.textLength] = text?.count ?? 0 + payload.sourceProperties[Constants.AutoCapture.hasText] = !(text?.isEmpty ?? true) + payload.sourceProperties[Constants.AutoCapture.textLength] = text?.count ?? 0 payload.placeholder = placeholder let effectiveView = userpilotEffectiveViewForCapture() @@ -58,7 +58,7 @@ internal extension UITextField { InteractionEventCache.sendDebouncedInteraction( payload, for: self, - textLengthForDedupe: payload.sourceProperties[AutoCaptureConstants.textLength] as? Int + textLengthForDedupe: payload.sourceProperties[Constants.AutoCapture.textLength] as? Int ) } } @@ -82,8 +82,8 @@ internal extension UITextView { elementType: "UITextView" ) - payload.sourceProperties[AutoCaptureConstants.hasText] = !text.isEmpty - payload.sourceProperties[AutoCaptureConstants.textLength] = text.count + payload.sourceProperties[Constants.AutoCapture.hasText] = !text.isEmpty + payload.sourceProperties[Constants.AutoCapture.textLength] = text.count let effectiveView = userpilotEffectiveViewForCapture() // SwiftUI sibling text views otherwise resolve to identical hierarchy strings; override the @@ -105,7 +105,7 @@ internal extension UITextView { InteractionEventCache.sendDebouncedInteraction( payload, for: self, - textLengthForDedupe: payload.sourceProperties[AutoCaptureConstants.textLength] as? Int + textLengthForDedupe: payload.sourceProperties[Constants.AutoCapture.textLength] as? Int ) } } diff --git a/Sources/Userpilot/AutoCapture/Models/InteractionEvent.swift b/Sources/Userpilot/AutoCapture/Models/InteractionEvent.swift index cefd51b6..03ac04c7 100644 --- a/Sources/Userpilot/AutoCapture/Models/InteractionEvent.swift +++ b/Sources/Userpilot/AutoCapture/Models/InteractionEvent.swift @@ -65,7 +65,7 @@ internal struct InteractionPayload { var row: Int? /// Control-specific and privacy-safe values (switch on/off, slider value, selected index, text length, etc.). - /// Prefer keys from `AutoCaptureConstants`; merged into the published payload by `toSourceDictionary()`. + /// Prefer keys from `Constants.AutoCapture`; merged into the published payload by `toSourceDictionary()`. var sourceProperties: [String: Any] = [:] // MARK: - Initialization @@ -85,47 +85,47 @@ internal struct InteractionPayload { // swiftlint:disable:next function_body_length cyclomatic_complexity superfluous_disable_command func toDictionary() -> [String: Any] { var dict: [String: Any] = [ - AutoCaptureConstants.targetClass: targetClass + Constants.AutoCapture.targetClass: targetClass ] // Element properties if let elementText = elementText { - dict[AutoCaptureConstants.targetText] = elementText + dict[Constants.AutoCapture.targetText] = elementText } if let accessibilityLabel = accessibilityLabel { - dict[AutoCaptureConstants.accessibilityLabel] = accessibilityLabel + dict[Constants.AutoCapture.accessibilityLabel] = accessibilityLabel } if let accessibilityIdentifier = accessibilityIdentifier { - dict[AutoCaptureConstants.accessibilityIdentifier] = accessibilityIdentifier + dict[Constants.AutoCapture.accessibilityIdentifier] = accessibilityIdentifier } if let hierarchy = hierarchy { - dict[AutoCaptureConstants.hierarchy] = hierarchy + dict[Constants.AutoCapture.hierarchy] = hierarchy } if let targetAction = targetAction { - dict[AutoCaptureConstants.targetAction] = targetAction + dict[Constants.AutoCapture.targetAction] = targetAction } if let targetClass = ownerTargetClass { - dict[AutoCaptureConstants.ownerTargetClass] = targetClass + dict[Constants.AutoCapture.ownerTargetClass] = targetClass } if let targetViewName = targetViewName { - dict[AutoCaptureConstants.targetViewName] = targetViewName + dict[Constants.AutoCapture.targetViewName] = targetViewName } if let placeholder = placeholder { - dict[AutoCaptureConstants.placeholder] = placeholder + dict[Constants.AutoCapture.placeholder] = placeholder } if let dialogTitle = dialogTitle { - dict[AutoCaptureConstants.dialogTitle] = dialogTitle + dict[Constants.AutoCapture.dialogTitle] = dialogTitle } if let dialogMessage = dialogMessage { - dict[AutoCaptureConstants.dialogMessage] = dialogMessage + dict[Constants.AutoCapture.dialogMessage] = dialogMessage } // Index path properties if let section = section { - dict[AutoCaptureConstants.section] = section + dict[Constants.AutoCapture.section] = section } if let row = row { - dict[AutoCaptureConstants.selectedIndex] = row + dict[Constants.AutoCapture.selectedIndex] = row } return dict diff --git a/Sources/Userpilot/AutoCapture/Models/ScreenTrackingPayload.swift b/Sources/Userpilot/AutoCapture/Models/ScreenTrackingPayload.swift index a3071c0c..dc54f077 100644 --- a/Sources/Userpilot/AutoCapture/Models/ScreenTrackingPayload.swift +++ b/Sources/Userpilot/AutoCapture/Models/ScreenTrackingPayload.swift @@ -55,31 +55,31 @@ internal struct ScreenTrackingPayload: Equatable { /// - Returns: Dictionary representation of the payload func toDictionary() -> [String: Any] { var dict: [String: Any] = [ - AutoCaptureConstants.screenName: currentScreen, - AutoCaptureConstants.screenClass: screenClass, - AutoCaptureConstants.screenType: screenType, - AutoCaptureConstants.isUserpilotContainerClass: isUserpilotContainerClass, - AutoCaptureConstants.source: AutoCaptureConstants.autoCaptureSourceValue + Constants.AutoCapture.screenName: currentScreen, + Constants.AutoCapture.screenClass: screenClass, + Constants.AutoCapture.screenType: screenType, + Constants.AutoCapture.isUserpilotContainerClass: isUserpilotContainerClass, + Constants.AutoCapture.source: Constants.AutoCapture.autoCaptureSourceValue ] if let navigationTitle = navigationTitle { - dict[AutoCaptureConstants.navigationTitle] = navigationTitle + dict[Constants.AutoCapture.navigationTitle] = navigationTitle } if let vcAccessibilityIdentifier = vcAccessibilityIdentifier { - dict[AutoCaptureConstants.vcAccessibilityIdentifier] = vcAccessibilityIdentifier + dict[Constants.AutoCapture.vcAccessibilityIdentifier] = vcAccessibilityIdentifier } if let vcAccessibilityLabel = vcAccessibilityLabel { - dict[AutoCaptureConstants.vcAccessibilityLabel] = vcAccessibilityLabel + dict[Constants.AutoCapture.vcAccessibilityLabel] = vcAccessibilityLabel } if let screenNameMatchesPreviousScreen { - dict[AutoCaptureConstants.screenNameMatchesPreviousScreen] = screenNameMatchesPreviousScreen + dict[Constants.AutoCapture.screenNameMatchesPreviousScreen] = screenNameMatchesPreviousScreen } if let appFramework = appFramework { - dict[AutoCaptureConstants.uiFramework] = appFramework.rawValue + dict[Constants.AutoCapture.uiFramework] = appFramework.rawValue } return dict } diff --git a/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift b/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift index 6679f0fb..bc8e8bb5 100644 --- a/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift +++ b/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift @@ -85,11 +85,11 @@ internal final class ScreenNameTracker: ScreenNameTracking { // Common (always included) var screen: [String: String] = [ - AutoCaptureConstants.screenTitle: payload.screenClass, - AutoCaptureConstants.screenName: payload.currentScreen + Constants.AutoCapture.screenTitle: payload.screenClass, + Constants.AutoCapture.screenName: payload.currentScreen ] if let navigationTitle = payload.navigationTitle, !navigationTitle.isEmpty { - screen[AutoCaptureConstants.navigationTitle] = navigationTitle + screen[Constants.AutoCapture.navigationTitle] = navigationTitle } return screen diff --git a/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift b/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift index 41024f0a..9eda41f3 100644 --- a/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift +++ b/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift @@ -44,7 +44,7 @@ internal enum InteractionEventCache { private static var lastDeliveredTextLengthByDebounceKey: [String: Int] = [:] private static let debouncer = EventDebounce( - delay: AutoCaptureConstants.interactionDebounceInterval, + delay: Constants.AutoCapture.interactionDebounceInterval, deliveryQueue: .main ) { envelope in guard Userpilot.isInitialized else { return } diff --git a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift index b3dd6f62..b5b7460c 100644 --- a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift +++ b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift @@ -97,7 +97,7 @@ internal enum UIKitViewResolver { if let label = node.accessibilityLabel? .trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty, - label != AutoCaptureConstants.reductText, + label != Constants.AutoCapture.reductText, !accessibilityRedacted { attributes += "attr__accessibility_label=\"\(label.replacingOccurrences(of: "\"", with: "\\\""))\"" } @@ -105,7 +105,7 @@ internal enum UIKitViewResolver { if let id = node.accessibilityIdentifier? .trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, - id != AutoCaptureConstants.reductText, + id != Constants.AutoCapture.reductText, !accessibilityRedacted { attributes += "attr__id=\"\(id.replacingOccurrences(of: "\"", with: "\\\""))\"" } @@ -391,7 +391,7 @@ internal extension UIView { /// - Returns: The text content or redacted placeholder func getTextContent() -> String? { if shouldRedactText() { - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } if let direct = userpilotRawDirectText( @@ -425,7 +425,7 @@ internal extension UIView { /// - Returns: The accessibility label or redacted placeholder func getAccessibilityLabelContent() -> String? { if shouldRedactAccessibilityLabel() { - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } guard let label = accessibilityLabel, !label.isEmpty else { diff --git a/Sources/Userpilot/DI/DIContainer.swift b/Sources/Userpilot/DI/DIContainer.swift index 52e65f3f..8f06863c 100644 --- a/Sources/Userpilot/DI/DIContainer.swift +++ b/Sources/Userpilot/DI/DIContainer.swift @@ -22,7 +22,7 @@ internal class DIContainer { /// A queue used for concurrent reads and synchronized writes to the container's components. private let componentQueue = DispatchQueue( - label: DispatchQueueConstants.DI_CONTAINER_QUEUE, + label: Constants.DispatchQueues.diContainerQueue, qos: .userInitiated, attributes: .concurrent ) diff --git a/Sources/Userpilot/DI/Registry.swift b/Sources/Userpilot/DI/Registry.swift index cf2c307b..43e45501 100644 --- a/Sources/Userpilot/DI/Registry.swift +++ b/Sources/Userpilot/DI/Registry.swift @@ -80,7 +80,7 @@ extension Userpilot { /// Concurrent queue used for synchronized reads / barriered writes. private let queue = DispatchQueue( - label: DispatchQueueConstants.REGISTRY_QUEUE, + label: Constants.DispatchQueues.registryQueue, qos: .userInitiated, attributes: .concurrent ) diff --git a/Sources/Userpilot/Database/DatabaseStats.swift b/Sources/Userpilot/Database/DatabaseStats.swift new file mode 100644 index 00000000..c250f0dc --- /dev/null +++ b/Sources/Userpilot/Database/DatabaseStats.swift @@ -0,0 +1,52 @@ +// +// DatabaseStats.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 13/10/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Holds storage statistics for monitoring event database state including count, +// size, limits, and utilization percentage. +// + +import Foundation + +internal struct DatabaseStats { + let eventCount: Int + let totalSizeBytes: Int64 + let maxEventCount: Int + let maxSizeBytes: Int64 + let isCountLimitReached: Bool + let isSizeLimitReached: Bool + + var totalSizeFormatted: String { + formatBytes(totalSizeBytes) + } + + var maxSizeFormatted: String { + formatBytes(maxSizeBytes) + } + + var utilizationPercent: Int { + guard maxSizeBytes > 0 else { return 0 } + return Int((Double(totalSizeBytes) / Double(maxSizeBytes)) * 100.0) + } + + private func formatBytes(_ bytes: Int64) -> String { + let bytesInKilobyte: Int64 = 1024 + let bytesInMegabyte = bytesInKilobyte * 1024 + let bytesInGigabyte = bytesInMegabyte * 1024 + + switch bytes { + case bytesInGigabyte...: + return String(format: "%.2f GB", Double(bytes) / Double(bytesInGigabyte)) + case bytesInMegabyte...: + return String(format: "%.2f MB", Double(bytes) / Double(bytesInMegabyte)) + case bytesInKilobyte...: + return String(format: "%.2f KB", Double(bytes) / Double(bytesInKilobyte)) + default: + return "\(bytes) B" + } + } +} diff --git a/Sources/Userpilot/Database/EventDatabaseStorage.swift b/Sources/Userpilot/Database/EventDatabaseStorage.swift new file mode 100644 index 00000000..9b4e01f7 --- /dev/null +++ b/Sources/Userpilot/Database/EventDatabaseStorage.swift @@ -0,0 +1,600 @@ +// +// EventSQLiteStorage.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 13/10/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// EventSQLiteStorage manages the SQLite database for storing events locally when +// the app is offline or socket is disconnected. It handles size limits, record counts, +// and provides thread-safe operations for event persistence. +// + +// swiftlint:disable file_length + +import Foundation +import SQLite3 + +// MARK: - EventStoring Protocol + +internal protocol EventStoring: AnyObject { + /// Saves an event to storage if limits are not exceeded + /// - Parameters: + /// - activity: The event to save + /// - completion: Completion handler called with true if saved successfully, false if limits were exceeded + func saveEvent(_ activity: EventStorage, completion: @escaping (Bool) -> Void) + + /// Retrieves all events from storage and deletes them atomically + /// - Parameter completion: Completion handler called with the list of all stored events + func getAllEventsAndDelete(completion: @escaping ([EventStorage]) -> Void) + + /// Deletes a single event from storage + /// - Parameter activity: The event to delete + func deleteEvent(_ activity: EventStorage) + + /// Deletes all events from storage + func deleteAllEvents() + + /// Gets current storage statistics + /// - Returns: Storage stats including count, size, and limits + func getStorageStats() -> DatabaseStats + + /// Fast check to determine if there are any events in storage + /// - Returns: true if there are stored events, false otherwise + func hasEvents() -> Bool +} + +// MARK: - EventSQLiteStorage + +// swiftlint:disable:next type_body_length +internal class EventDatabaseStorage: EventStoring { + + // MARK: - Properties + + private let database: OpaquePointer? + private let databaseURL: URL + private let queue = DispatchQueue( + label: Constants.DispatchQueues.database, + qos: .userInitiated + ) + private let decoder = JSONDecoder() + + /// Logger used for internal logging of operations and errors. + private let logger: Logging + + // MARK: - Initialization + + init(container: DIContainer) { + let config = container.resolve(Userpilot.Config.self) + self.logger = config.logger + + // Application Support, not Documents: Documents is user-visible (Files app) + // and semantically for user-created content. The events DB is SDK-internal. + let userpilotRoot = FileManager.default.applicationSupportDirectory + .appendingPathComponent("userpilot", isDirectory: true) + let dbURL = userpilotRoot + .appendingPathComponent("events/\(config.token)/events.sqlite") + self.databaseURL = dbURL + + // Create directory with a protection class that still allows background + // writes after reboot, before first unlock. + do { + try FileManager.default.createDirectory( + at: dbURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ] + ) + } catch { + logger.error( + "❗ Failed to create event storage directory: %{public}@", error.localizedDescription + ) + } + + // Analytics events must never enter iCloud/iTunes backups. + EventDatabaseStorage.excludeFromBackup(userpilotRoot, logger: logger) + + // Remove the legacy DB that older builds kept in Documents. Events are + // ephemeral analytics; deleting beats migrating backed-up data forward. + EventDatabaseStorage.deleteLegacyDocumentsDatabase(logger: logger) + + var dbPointer: OpaquePointer? + guard sqlite3_open(dbURL.path, &dbPointer) == SQLITE_OK else { + logger.error("❗ Unable to open event storage database") + self.database = nil + return + } + self.database = dbPointer + + // Must run BEFORE the CREATE below so a dropped table is recreated in + // the same launch. + dropTableOnSchemaChange() + + // Create table with size tracking + let createTableQuery = """ + CREATE TABLE IF NOT EXISTS Events( + requestId TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at INTEGER DEFAULT (strftime('%s','now')), + size_bytes INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_created_at ON Events(created_at); + """ + + var errorMsg: UnsafeMutablePointer? + if sqlite3_exec(database, createTableQuery, nil, nil, &errorMsg) != SQLITE_OK { + let error = String(cString: errorMsg!) + sqlite3_free(errorMsg) + logger.error("❗ Failed to create event storage table: %{public}@", error) + + } + + } + + // MARK: - Schema Versioning (drop-on-change, no migrations) + + /** + * Compares the stored `PRAGMA user_version` with `Constants.Database.schemaVersion`. + * No-migration policy: offline events are ephemeral analytics, so on ANY version + * mismatch the Events table is dropped and recreated instead of migrated. Bump + * the constant whenever the Events schema changes. + */ + private func dropTableOnSchemaChange() { + guard let database else { return } + + var stmt: OpaquePointer? + var currentVersion: Int32 = 0 + if sqlite3_prepare_v2(database, "PRAGMA user_version;", -1, &stmt, nil) == SQLITE_OK, + sqlite3_step(stmt) == SQLITE_ROW { + currentVersion = sqlite3_column_int(stmt, 0) + } + sqlite3_finalize(stmt) + + guard currentVersion != Constants.Database.schemaVersion else { return } + + if currentVersion > 0 { + logger.info( + "🗃️ Events schema changed (%d -> %d): dropping table", + currentVersion, Constants.Database.schemaVersion) + _ = sqlite3_exec(database, "DROP TABLE IF EXISTS Events;", nil, nil, nil) + } + + let stampQuery = "PRAGMA user_version = \(Constants.Database.schemaVersion);" + if sqlite3_exec(database, stampQuery, nil, nil, nil) != SQLITE_OK { + logger.error("❗ Failed to stamp event storage schema version") + } + } + + // MARK: - Storage Location Hardening + + private static func excludeFromBackup(_ url: URL, logger: Logging) { + var url = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + logger.error( + "❗ Failed to exclude event storage from backup: %{public}@", + error.localizedDescription) + } + } + + private static func deleteLegacyDocumentsDatabase(logger: Logging) { + let legacyRoot = FileManager.default.documentsDirectory + .appendingPathComponent("userpilot/events", isDirectory: true) + guard FileManager.default.fileExists(atPath: legacyRoot.path) else { return } + do { + try FileManager.default.removeItem(at: legacyRoot) + logger.info("🗃️ Removed legacy events database from Documents") + } catch { + logger.error( + "❗ Failed to remove legacy events database: %{public}@", + error.localizedDescription) + } + } + + deinit { + tryCatch { + _ = queue.sync { + sqlite3_close(database) + } + } + } + + // MARK: - EventStoring + + /** + * Saves an event to the database if limits are not exceeded. + * + * This method checks current storage limits before saving: + * - If event count >= MAX_EVENT_COUNT, the event is rejected + * - If total size >= MAX_SIZE_BYTES, the event is rejected + * - Otherwise, the event is saved + * + * Thread-safety: Uses serial queue to ensure atomic read-check-write operations to prevent race + * conditions when saving events concurrently. + * + * - Parameters: + * - activity: The event to save + * - completion: Completion handler called with true if saved successfully, false if limits were exceeded + */ + func saveEvent(_ activity: EventStorage, completion: @escaping (Bool) -> Void) { + tryCatch { + queue.async { [weak self] in + guard let self = self else { + completion(false) + return + } + let result = self.performSaveEvent(activity) + completion(result) + } + } + } + + /** + * Retrieves all events from the database and deletes them. + * + * This method atomically: + * 1. Fetches all events from storage + * 2. Deletes all events from storage + * + * - Parameter completion: Completion handler called with the list of all stored events + */ + func getAllEventsAndDelete(completion: @escaping ([EventStorage]) -> Void) { + tryCatch { + queue.async { [weak self] in + guard let self = self else { + completion([]) + return + } + + let events = self.performRead() + if !events.isEmpty { + self.performDeleteAll() + self.logger.info( + "✅ Retrieved and deleted %{public}d events from storage", events.count) + } + completion(events) + } + } + } + + /// Deletes a single event from the database. + /// - Parameter activity: The event to delete + func deleteEvent(_ activity: EventStorage) { + tryCatch { + queue.async { [weak self] in + guard let self = self else { return } + self.performDeleteEvent(activity) + } + } + } + + /// Deletes all events from the database. + func deleteAllEvents() { + tryCatch { + queue.async { [weak self] in + guard let self = self else { return } + + let count = self.performGetRecordCount() + self.performDeleteAll() + self.logger.info("✅ Deleted %{public}d events from storage", count) + } + } + } + + /** + * Gets storage statistics for monitoring purposes. + * + * - Returns: Storage statistics including count, size, and limit information + */ + func getStorageStats() -> DatabaseStats { + return queue.sync { [weak self] in + guard let self = self else { + return DatabaseStats( + eventCount: 0, + totalSizeBytes: 0, + maxEventCount: Constants.Database.maxEventCount, + maxSizeBytes: Constants.Database.maxSizeBytes, + isCountLimitReached: false, + isSizeLimitReached: false + ) + } + + let count = self.getEventCount() + let size = self.getTotalSize() + + return DatabaseStats( + eventCount: count, + totalSizeBytes: size, + maxEventCount: Constants.Database.maxEventCount, + maxSizeBytes: Constants.Database.maxSizeBytes, + isCountLimitReached: count >= Constants.Database.maxEventCount, + isSizeLimitReached: size >= Constants.Database.maxSizeBytes + ) + } + } + + /** + * Fast check to determine if there are any events in storage. + * This is optimized for performance by only checking if count > 0. + * + * - Returns: true if there are stored events, false otherwise + */ + func hasEvents() -> Bool { + return queue.sync { [weak self] in + guard let self = self else { return false } + return self.getEventCount() > 0 + } + } + + // MARK: - Private Methods + + /* + * Internal save implementation with limit checking + */ + // swiftlint:disable:next function_body_length + private func performSaveEvent(_ activity: EventStorage) -> Bool { + guard let database else { return false } + + guard let jsonData = tryCatch(code: { try self.encoder.encode(activity) }), + let jsonString = String(data: jsonData, encoding: .utf8) + else { + logger.error("❗ Failed to encode event for storage") + return false + } + + let dataSize = jsonString.utf8.count + + // Check current limits + let currentCount = performGetRecordCount() + let currentSize = performGetTotalSizeBytes() + + // Check if limits are exceeded + if currentCount >= Constants.Database.maxEventCount { + logger.error( + "⚠️ Event storage limit reached: %{public}d events. Event will not be saved.", + currentCount + ) + return false + } + + if currentSize >= Constants.Database.maxSizeBytes { + logger.error( + "⚠️ Event storage size limit reached: %{public}@. Event will not be saved.", + formatBytes(currentSize) + ) + return false + } + + // Check if adding this event would exceed size limit + let newTotalSize = currentSize + Int64(dataSize) + if newTotalSize > Constants.Database.maxSizeBytes { + logger.error( + "⚠️ Event would exceed storage limit. Current: %{public}@, Event: %{public}@, Limit: %{public}@.", + formatBytes(currentSize), + formatBytes(Int64(dataSize)), + formatBytes(Constants.Database.maxSizeBytes) + ) + return false + } + + // Limits not exceeded, save the event + let requestId = activity.requestId.uuidString + + let query = """ + INSERT OR REPLACE INTO Events (requestId, data, created_at, size_bytes) VALUES (?, ?, ?, ?); + """ + + var stmt: OpaquePointer? + defer { sqlite3_finalize(stmt) } + + guard sqlite3_prepare_v2(database, query, -1, &stmt, nil) == SQLITE_OK else { + logger.error("❗ Failed to prepare save statement") + return false + } + + sqlite3_bind_text(stmt, 1, (requestId as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (jsonString as NSString).utf8String, -1, nil) + sqlite3_bind_int64(stmt, 3, Int64(activity.createdAt)) + sqlite3_bind_int(stmt, 4, Int32(dataSize)) + + if sqlite3_step(stmt) != SQLITE_DONE { + let error = String(cString: sqlite3_errmsg(database)) + logger.error("❗ Event save failed: %{public}@", error) + return false + } + + logger.debug( + "✅ Event saved. Count: %{public}d/%{public}d, Size: %{public}@/%{public}@", + currentCount + 1, + Constants.Database.maxEventCount, + formatBytes(newTotalSize), + formatBytes(Constants.Database.maxSizeBytes) + ) + + return true + } + + private func performRead() -> [EventStorage] { + guard let database else { return [] } + + let query = "SELECT data FROM Events ORDER BY created_at, rowid;" + var stmt: OpaquePointer? + var activities: [EventStorage] = [] + + defer { sqlite3_finalize(stmt) } + + guard sqlite3_prepare_v2(database, query, -1, &stmt, nil) == SQLITE_OK else { + logger.error("❗ Failed to prepare read statement") + return [] + } + + while sqlite3_step(stmt) == SQLITE_ROW { + if let cString = sqlite3_column_text(stmt, 0), + let data = String(cString: cString).data(using: .utf8), + let activity = tryCatch(code: { + try self.decoder.decode(EventStorage.self, from: data) + }) { + activities.append(activity) + } + } + + return activities + } + + private func performDeleteEvent(_ activity: EventStorage) { + guard let database else { return } + + let query = "DELETE FROM Events WHERE requestId = ?;" + var stmt: OpaquePointer? + defer { sqlite3_finalize(stmt) } + + guard sqlite3_prepare_v2(database, query, -1, &stmt, nil) == SQLITE_OK else { + logger.error("❗ Failed to prepare delete statement") + return + } + + let requestId = activity.requestId.uuidString + sqlite3_bind_text(stmt, 1, (requestId as NSString).utf8String, -1, nil) + + if sqlite3_step(stmt) == SQLITE_DONE { + logger.debug("✅ Event deleted: %{public}@", requestId) + } else { + let error = String(cString: sqlite3_errmsg(database)) + logger.error("❗ Event delete failed: %{public}@", error) + } + } + + private func performDeleteAll() { + guard let database else { return } + + if sqlite3_exec(database, "DELETE FROM Events;", nil, nil, nil) != SQLITE_OK { + logger.error("❗ Failed to delete all events") + } + + vacuumDatabase() + } + + // MARK: - Size Management + + private func performGetRecordCount() -> Int { + guard let database else { return 0 } + + let query = "SELECT COUNT(*) FROM Events;" + var stmt: OpaquePointer? + defer { sqlite3_finalize(stmt) } + + guard sqlite3_prepare_v2(database, query, -1, &stmt, nil) == SQLITE_OK, + sqlite3_step(stmt) == SQLITE_ROW + else { + return 0 + } + + return Int(sqlite3_column_int(stmt, 0)) + } + + private func performGetTotalSizeBytes() -> Int64 { + guard let database else { return 0 } + + let query = "SELECT COALESCE(SUM(size_bytes), 0) FROM Events;" + var stmt: OpaquePointer? + defer { sqlite3_finalize(stmt) } + + guard sqlite3_prepare_v2(database, query, -1, &stmt, nil) == SQLITE_OK, + sqlite3_step(stmt) == SQLITE_ROW + else { + return 0 + } + + return sqlite3_column_int64(stmt, 0) + } + + /** + * Gets the current count of events in storage. + * + * - Returns: The number of events currently stored + */ + private func getEventCount() -> Int { + return tryCatch { + performGetRecordCount() + } ?? 0 + } + + /** + * Gets the current total size of events in storage. + * + * - Returns: The total size in bytes of all stored events + */ + private func getTotalSize() -> Int64 { + return tryCatch { + performGetTotalSizeBytes() + } ?? 0 + } + + private func vacuumDatabase() { + guard let database else { return } + + let beforeSize = performGetDatabaseSize() + + if sqlite3_exec(database, "VACUUM;", nil, nil, nil) == SQLITE_OK { + let afterSize = performGetDatabaseSize() + let reclaimed = beforeSize - afterSize + logger.debug( + "🛑 VACUUM completed. Reclaimed: %{public}@", + formatBytes(reclaimed) + ) + } + } + + private func performGetDatabaseSize() -> Int64 { + return tryCatch { + let attributes = try FileManager.default.attributesOfItem(atPath: databaseURL.path) + return attributes[.size] as? Int64 ?? 0 + } ?? 0 + } + + // MARK: - Helper Methods + + private var encoder: JSONEncoder { + return UserpilotEncoder.shared + } + + /** + * Formats bytes to human-readable string. + * + * - Parameter bytes: The number of bytes to format + * - Returns: Formatted string (e.g., "1.5 MB") + */ + private func formatBytes(_ bytes: Int64) -> String { + let bytesInKilobyte: Int64 = 1024 + let bytesInMegabyte = bytesInKilobyte * 1024 + let bytesInGigabyte = bytesInMegabyte * 1024 + + switch bytes { + case bytesInGigabyte...: + return String(format: "%.2f GB", Double(bytes) / Double(bytesInGigabyte)) + case bytesInMegabyte...: + return String(format: "%.2f MB", Double(bytes) / Double(bytesInMegabyte)) + case bytesInKilobyte...: + return String(format: "%.2f KB", Double(bytes) / Double(bytesInKilobyte)) + default: + return "\(bytes) B" + } + } +} + +// MARK: - FileManager Extension + +internal extension FileManager { + fileprivate var documentsDirectory: URL { + urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + fileprivate var applicationSupportDirectory: URL { + urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + } +} diff --git a/Sources/Userpilot/Database/EventStorage.swift b/Sources/Userpilot/Database/EventStorage.swift new file mode 100644 index 00000000..60b5c17d --- /dev/null +++ b/Sources/Userpilot/Database/EventStorage.swift @@ -0,0 +1,45 @@ +// +// EventStorage.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 08/10/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Represents an event stored in the local database with all necessary metadata +// for tracking and syncing with the backend. +// + +import Foundation + +internal struct EventStorage: Codable { + let requestId: UUID + let token: String + let userId: String + let data: Data + /// Timestamp in milliseconds since epoch (Unix timestamp * 1000) + let createdAt: TimeInterval + let sizeBytes: Int + + init?(_ event: Event, _ token: String, _ userId: String) { + guard let data = try? UserpilotEncoder.shared.encode(event) else { + return nil + } + self.requestId = UUID() + self.token = token + self.userId = userId + self.data = data + self.createdAt = Date().timeIntervalSince1970 * 1_000.0 + self.sizeBytes = data.count + } + + /// Decodes the stored event data back to an Event object + func toEvent() -> Event? { + do { + return try UserpilotDecoder.shared.decode(Event.self, from: data) + } catch { + print("❌ Failed to decode Event:", error) + return nil + } + } +} diff --git a/Sources/Userpilot/Experiences/Events/SDKEvent.swift b/Sources/Userpilot/Experiences/Events/SDKEvent.swift index d6db28d4..f838afa6 100644 --- a/Sources/Userpilot/Experiences/Events/SDKEvent.swift +++ b/Sources/Userpilot/Experiences/Events/SDKEvent.swift @@ -42,7 +42,7 @@ extension SDKEvent { } -/// Used to pass seen content for cached ScreenViewEntity +/// Used to pass seen content for cached ScreenSessionStateMachine extension SDKEvent { func getContentType() -> ExperienceType { diff --git a/Sources/Userpilot/Experiences/ExperienceStateManager.swift b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift similarity index 98% rename from Sources/Userpilot/Experiences/ExperienceStateManager.swift rename to Sources/Userpilot/Experiences/ExperienceStateMachine.swift index b380358c..4c918eaf 100644 --- a/Sources/Userpilot/Experiences/ExperienceStateManager.swift +++ b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift @@ -1,5 +1,5 @@ // -// ExperienceStateManager.swift +// ExperienceStateMachine.swift // Userpilot SDK // // Created by Motasem Hamed on 23/11/2025. @@ -206,10 +206,10 @@ internal protocol ExperienceStateManaging: AnyObject { func markActiveFromCurrentState(content: ExperienceContent) } -// MARK: - ExperienceStateManager +// MARK: - ExperienceStateMachine /// Manages experience flow state transitions and provides thread-safe access to current state. -internal final class ExperienceStateManager { +internal final class ExperienceStateMachine { // MARK: - Properties @@ -227,7 +227,7 @@ internal final class ExperienceStateManager { // MARK: - ExperienceStateManaging -extension ExperienceStateManager: ExperienceStateManaging { +extension ExperienceStateMachine: ExperienceStateManaging { // MARK: - State Access diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 86e57309..b150298f 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -36,8 +36,11 @@ internal protocol ExperiencesPublishing: AnyObject { /// Preview experience func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) - /// Manually trigger experience - func updateSceen(_ screenName: String) + /// Updates the current screen used for experience targeting + func updateScreen(_ screenName: String) + + /// The current screen used for experience targeting + var getCurrentScreen: String { get } /// Manually end experience func endExperience(manualClose: Bool) @@ -83,7 +86,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { private weak var userpilot: Userpilot? /// Manages socket connections and listens for socket events. - private let socketManager: SocketEvents + private let socketManager: SocketManaging /// Analytics publisher to manage events triggering. private let analyticsPublisher: AnalyticsPublishing @@ -104,7 +107,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { private let linkOpener: LinkOpening /// Manages experience flow state transitions. - private let experienceStateManager: ExperienceStateManaging + private let experienceStateMachine: ExperienceStateManaging /// Logger used for internal logging of operations and errors. private let logger: Logging @@ -125,7 +128,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Thread-safe lock for managing experience content operations private let experienceQueue = DispatchQueue( - label: DispatchQueueConstants.EXPERIENCE_QUEUE, + label: Constants.DispatchQueues.experienceQueue, qos: .userInteractive ) @@ -137,7 +140,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Determines if there are currently active experiences being displayed private var hasActiveExperience: Bool { - return activeExperience != nil || experienceStateManager.isActivelyRendered() + return activeExperience != nil || experienceStateMachine.isActivelyRendered() } /// Expereinces presentation style. @@ -161,13 +164,13 @@ internal class ExperiencesPublisher: ExperiencesPublishing { self.userpilot = container.owner self.storage = container.resolve(DataStoring.self) self.config = container.resolve(Userpilot.Config.self) - self.socketManager = container.resolve(SocketEvents.self) + self.socketManager = container.resolve(SocketManaging.self) self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) self.userpilotRemoteSource = container.resolve(UserpilotRemoteSourcing.self) self.themeHandler = container.resolve(ThemeHandling.self) self.logger = container.resolve(Userpilot.Config.self).logger self.linkOpener = container.resolve(LinkOpening.self) - self.experienceStateManager = container.resolve(ExperienceStateManaging.self) + self.experienceStateMachine = container.resolve(ExperienceStateManaging.self) socketManager.registerCallback(self) } @@ -195,8 +198,8 @@ internal class ExperiencesPublisher: ExperiencesPublishing { func canRequestScreenEvent() -> Bool { return requestFakeScreenReloadEventDate?.isMoreThanOneSecond(from: Date()) ?? true && !hasActiveExperience - && !experienceStateManager.isActive() - && !experienceStateManager.hasCachedExperience() + && !experienceStateMachine.isActive() + && !experienceStateMachine.hasCachedExperience() } /** @@ -209,16 +212,16 @@ internal class ExperiencesPublisher: ExperiencesPublishing { experienceQueue.async { [weak self] in guard let self else { return } if self.hasActiveExperience - || self.experienceStateManager.isActive() - || self.experienceStateManager.hasCachedExperience() + || self.experienceStateMachine.isActive() + || self.experienceStateMachine.hasCachedExperience() || !self.pendingExperiences.isEmpty { - self.experienceStateManager.markCachedManual(experienceId) + self.experienceStateMachine.markCachedManual(experienceId) self.logger.info("Experience cached - active experience in progress") return } self.delayUtils.cancelDelay() - self.experienceStateManager.markManualTrigger(experienceId) + self.experienceStateMachine.markManualTrigger(experienceId) self.publishInternalSDKEvent(ExperienceContentEvent(experienceId: experienceId)) } } @@ -279,12 +282,12 @@ internal class ExperiencesPublisher: ExperiencesPublishing { return } let activeViewController = self.activeExperience - let activeComponent = self.experienceStateManager.getActiveComponent() + let activeComponent = self.experienceStateMachine.getActiveComponent() self.activeExperience = nil guard let experience = (activeViewController as? UPExperience) ?? activeComponent else { - if !self.experienceStateManager.hasCachedExperience() { - self.experienceStateManager.markIdle() + if !self.experienceStateMachine.hasCachedExperience() { + self.experienceStateMachine.markIdle() } self.hideExperienceOverlayIfIdle() completion?() @@ -296,8 +299,8 @@ internal class ExperiencesPublisher: ExperiencesPublishing { completion?() return } - if !self.experienceStateManager.hasCachedExperience() { - self.experienceStateManager.markIdle() + if !self.experienceStateMachine.hasCachedExperience() { + self.experienceStateMachine.markIdle() } self.experienceDidFinishDismissing() completion?() @@ -344,7 +347,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Preview mode suppresses normal analytics while draft content is being rendered. func isPreviewExperienceMode() -> Bool { - experienceStateManager.isPreviewMode() + experienceStateMachine.isPreviewMode() } // MARK: - Deep Link Handling @@ -386,10 +389,11 @@ internal class ExperiencesPublisher: ExperiencesPublishing { return } - // Process the event through analytics publisher - analyticsPublisher.publishInternalSDKEvent(sdkEvent, socketSubscription: self) + // Process the event through analytics publisher; the response comes + // back through the multicast subscription as `message.resolvedEvent` + analyticsPublisher.publishInternalSDKEvent(sdkEvent) - // Update seen content for ScreenViewEntity tracking + // Update seen content for ScreenSessionStateMachine tracking if sdkEvent.isSeenContentEvent(), let contentId = sdkEvent.getContentId() { analyticsPublisher.experiencePublished(sdkEvent.getContentType(), contentId) } @@ -407,15 +411,15 @@ internal class ExperiencesPublisher: ExperiencesPublishing { // Don't trigger fake reload for NPS experiences or experiences with deep links // NPS is the last content, and deep links will open new screen if sdkEvent.isEventForCloseNPSExperience() || sdkEvent.hasDeepLink { - if !experienceStateManager.hasCachedExperience() { - experienceStateManager.markIdle() + if !experienceStateMachine.hasCachedExperience() { + experienceStateMachine.markIdle() } return } // Trigger fake reload when closing experience that wasn't manually triggered - if sdkEvent.isEventForCloseExperience() && !experienceStateManager.hasCachedExperience() { - experienceStateManager.markIdle() + if sdkEvent.isEventForCloseExperience() && !experienceStateMachine.hasCachedExperience() { + experienceStateMachine.markIdle() analyticsPublisher.publishFakeReloadScreenEvent( sdkEvent.getContentType(), sdkEvent.getContentId() ) @@ -439,7 +443,7 @@ extension ExperiencesPublisher: SocketSubscription { * * - Parameter screenName: The new screen title being navigated to */ - func updateSceen(_ screenName: String) { + func updateScreen(_ screenName: String) { tryCatch { if currentScreen == screenName { return } currentScreen = screenName @@ -447,6 +451,11 @@ extension ExperiencesPublisher: SocketSubscription { } } + /// The current screen used for experience targeting. + var getCurrentScreen: String { + currentScreen + } + // MARK: - Socket Event Handling /* @@ -468,8 +477,8 @@ extension ExperiencesPublisher: SocketSubscription { guard let self, // If there's an active experience, ignore new content !hasActiveExperience, - !experienceStateManager.isActive(), - !experienceStateManager.isPreviewMode(), + !experienceStateMachine.isActive(), + !experienceStateMachine.isPreviewMode(), !message.payload.isEmpty, let response = message.payload.toJSONString() else { @@ -483,7 +492,7 @@ extension ExperiencesPublisher: SocketSubscription { } // Process content from screen events and fetch experience content events - if eventName == EventType.screenEvent || + if eventName == Constants.Event.screenEvent || eventName == SDKEventsName.fetchExperienceContent.rawValue { // Determine the new experience based on response @@ -500,11 +509,11 @@ extension ExperiencesPublisher: SocketSubscription { if let experience { if eventName == SDKEventsName.fetchExperienceContent.rawValue { - self.experienceStateManager.markManualTrigger( + self.experienceStateMachine.markManualTrigger( experience.experienceId().toString() ) } else { - self.experienceStateManager.markAutomaticTrigger(experience) + self.experienceStateMachine.markAutomaticTrigger(experience) } self.pendingExperiences.append(experience) } @@ -531,7 +540,7 @@ extension ExperiencesPublisher: SocketSubscription { if let payload = message.payload["payload"] as? [String: Any] { experienceQueue.async { [weak self] in guard let self, - !experienceStateManager.isPreviewMode(), + !experienceStateMachine.isPreviewMode(), payload.keys.contains("request_id"), payload["request_id"] as? Int == nil else { return } @@ -554,12 +563,12 @@ extension ExperiencesPublisher: SocketSubscription { }() if let experience { - if self.hasActiveExperience || self.experienceStateManager.isActive() { - self.experienceStateManager.markCachedAutomatic(experience) + if self.hasActiveExperience || self.experienceStateMachine.isActive() { + self.experienceStateMachine.markCachedAutomatic(experience) self.logger.info("Active experience in progress, caching incoming experience") } else { self.delayUtils.cancelDelay() - self.experienceStateManager.markManualTrigger( + self.experienceStateMachine.markManualTrigger( experience.experienceId().toString() ) self.pendingExperiences.append(experience) @@ -769,7 +778,7 @@ extension ExperiencesPublisher { _ surveyTheme: SurveyTheme, _ submissionId: Int64 ) { - experienceStateManager.markShowingThankYou() + experienceStateMachine.markShowingThankYou() performOn(.main) { [weak self] in guard let self = self, @@ -840,14 +849,14 @@ extension ExperiencesPublisher { ) { tryCatch { let triggerType: TriggerType - if experienceStateManager.isManualTrigger() { + if experienceStateMachine.isManualTrigger() { triggerType = .manual - } else if experienceStateManager.isPreviewMode() { + } else if experienceStateMachine.isPreviewMode() { triggerType = .preview } else { triggerType = .automatic } - experienceStateManager.markWaitingDelay(triggerType) + experienceStateMachine.markWaitingDelay(triggerType) delayUtils.delayAction(delayTime: resolvedDelay(experienceContent)) { [weak self] in guard @@ -872,9 +881,9 @@ extension ExperiencesPublisher { viewController.modalPresentationStyle = .fullScreen } self.activeExperience = viewController - self.experienceStateManager.markActiveFromCurrentState(content: experienceContent) + self.experienceStateMachine.markActiveFromCurrentState(content: experienceContent) if let upExperience = viewController as? UPExperience { - self.experienceStateManager.setActiveComponent(upExperience) + self.experienceStateMachine.setActiveComponent(upExperience) } if viewController.isKind(of: NPSBottomSheetViewController.self) { self.npsTrackedScreen = self.currentScreen @@ -923,7 +932,7 @@ extension ExperiencesPublisher { return false } - if experienceStateManager.shouldBypassScreenValidation() { + if experienceStateMachine.shouldBypassScreenValidation() { logger.info("Can show experience: bypassing screen validation") return true } @@ -983,7 +992,7 @@ extension ExperiencesPublisher { } else { if let lastContent = self.pendingExperiences.last, self.pendingExperiences.count > 1 { self.pendingExperiences = [lastContent] - self.experienceStateManager.markAutomaticTrigger(lastContent) + self.experienceStateMachine.markAutomaticTrigger(lastContent) self.openExperienceFlow() } } @@ -1006,11 +1015,11 @@ extension ExperiencesPublisher { clearPendingExperiences() npsTrackedScreen = "" - if hasActiveExperience || experienceStateManager.getActiveComponent() != nil { + if hasActiveExperience || experienceStateMachine.getActiveComponent() != nil { endExperience(manualClose: true, completion: completion) } else { activeExperience = nil - experienceStateManager.markIdle() + experienceStateMachine.markIdle() hideExperienceOverlayIfIdle() completion?() } @@ -1019,7 +1028,7 @@ extension ExperiencesPublisher { private func resetProcessingExperienceStatus() { activeExperience = nil - experienceStateManager.markIdle() + experienceStateMachine.markIdle() } private func resetProcessingPreviewExperienceStatus() { @@ -1027,17 +1036,17 @@ extension ExperiencesPublisher { } private func processCachedExperienceAfterClose() { - let state = experienceStateManager.getCurrentState() + let state = experienceStateMachine.getCurrentState() switch state { case .cachedPendingAutomatic(let experience): activeExperience = nil pendingExperiences.append(experience) - experienceStateManager.markAutomaticTrigger(experience) + experienceStateMachine.markAutomaticTrigger(experience) checkCachedThemes(experience.experienceThemeId()) case .cachedPendingManual(let experienceId): activeExperience = nil - experienceStateManager.markIdle() + experienceStateMachine.markIdle() triggerExperience(experienceId) default: @@ -1053,10 +1062,10 @@ extension ExperiencesPublisher { func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) { resetState { [weak self] in guard let self else { return } - self.experienceStateManager.markPreviewMode() + self.experienceStateMachine.markPreviewMode() self.userpilotRemoteSource.fetchPreviewExperience( params: PreviewExperienceQueryParams( - baseUrl: RemoteSource.experienceBaseURL, + baseUrl: Constants.RemoteSource.experienceBaseURL, appToken: self.config.token, contentType: queryItems.first(where: { $0.name == "type" })?.value ?? "", contentId: experienceId diff --git a/Sources/Userpilot/Extensions/Socket+Extensions.swift b/Sources/Userpilot/Extensions/Socket+Extensions.swift index 377c773c..2debd22f 100644 --- a/Sources/Userpilot/Extensions/Socket+Extensions.swift +++ b/Sources/Userpilot/Extensions/Socket+Extensions.swift @@ -17,4 +17,7 @@ internal extension Message { self.payload.isEmpty || self.event == "phx_close" || self.topic == "phoenix" } + var resolvedEvent: String? { + return (payload["request_type"] as? String) + } } diff --git a/Sources/Userpilot/Extensions/String+Extensions.swift b/Sources/Userpilot/Extensions/String+Extensions.swift index fe26a637..6ebc3d7c 100644 --- a/Sources/Userpilot/Extensions/String+Extensions.swift +++ b/Sources/Userpilot/Extensions/String+Extensions.swift @@ -121,6 +121,15 @@ internal extension String { return Double(self) != nil } + /// Analytics events are the ones that flow through the serialized event queue. + /// Their socket ACK advances the queue; SDK/content events must not. + func isAnalyticsEvent() -> Bool { + return self == Constants.Event.identifyEvent || + self == Constants.Event.screenEvent || + self == Constants.Event.trackEvent || + self == Constants.Event.autoCaptureEvent + } + } // MARK: - Colors @@ -277,7 +286,7 @@ internal extension String { - Parameter type: The type to decode into, which must conform to `Decodable`. */ func toArray() -> [T]? { - let decoder = JSONDecoder() + let decoder = UserpilotDecoder.shared do { let decodedData = try decoder.decode([T].self, from: Data(self.utf8)) return decodedData diff --git a/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift b/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift index fccd1a93..140f1389 100644 --- a/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift +++ b/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift @@ -254,7 +254,7 @@ extension UIApplication { payload.hierarchy = path if useRedactedInner { - payload.elementText = AutoCaptureConstants.reductText + payload.elementText = Constants.AutoCapture.reductText } else { payload.elementText = view.getTextContent() payload.accessibilityIdentifier = view.accessibilityIdentifier @@ -320,7 +320,7 @@ extension UIApplication { payload.hierarchy = path if useRedactedInner { - payload.elementText = AutoCaptureConstants.reductText + payload.elementText = Constants.AutoCapture.reductText } else { payload.elementText = view.getTextContent() payload.accessibilityIdentifier = view.accessibilityIdentifier diff --git a/Sources/Userpilot/Extensions/UITabBarController+Extensions.swift b/Sources/Userpilot/Extensions/UITabBarController+Extensions.swift index 2365bd72..d9710647 100644 --- a/Sources/Userpilot/Extensions/UITabBarController+Extensions.swift +++ b/Sources/Userpilot/Extensions/UITabBarController+Extensions.swift @@ -38,7 +38,7 @@ extension UITabBarController { let tabIndex = tabBarController.selectedIndex let tabTitle = selectedVC.tabBarItem?.title - ?? "\(AutoCaptureConstants.defaultTabTitlePrefix)\(tabIndex + 1)" + ?? "\(Constants.AutoCapture.defaultTabTitlePrefix)\(tabIndex + 1)" // Resolve via the *selected* view controller, not the container, so the // event attributes to the tenant whose screen the user just navigated to. diff --git a/Sources/Userpilot/Extensions/UIWindow+Extensions.swift b/Sources/Userpilot/Extensions/UIWindow+Extensions.swift index 2647bef3..e8c44829 100644 --- a/Sources/Userpilot/Extensions/UIWindow+Extensions.swift +++ b/Sources/Userpilot/Extensions/UIWindow+Extensions.swift @@ -200,28 +200,28 @@ extension UIWindow { let useRedactedInner = (effectiveView !== view) var eventProperties: [String: Any] = [ - AutoCaptureConstants.targetClass: String(describing: type(of: effectiveView)), - AutoCaptureConstants.hierarchy: path + Constants.AutoCapture.targetClass: String(describing: type(of: effectiveView)), + Constants.AutoCapture.hierarchy: path ] if let capture = view.resolveUserpilotLabelCapture(atWindowPoint: point, in: window) { if let labelViewType = capture.viewType { - eventProperties[AutoCaptureConstants.targetClass] = labelViewType + eventProperties[Constants.AutoCapture.targetClass] = labelViewType } - eventProperties[AutoCaptureConstants.targetText] = capture.labeledView.shouldRedactText() - ? AutoCaptureConstants.reductText + eventProperties[Constants.AutoCapture.targetText] = capture.labeledView.shouldRedactText() + ? Constants.AutoCapture.reductText : capture.label } else if useRedactedInner { - eventProperties[AutoCaptureConstants.targetText] = AutoCaptureConstants.reductText + eventProperties[Constants.AutoCapture.targetText] = Constants.AutoCapture.reductText } else { if let accessibilityIdentifier = view.accessibilityIdentifier, !accessibilityIdentifier.isEmpty { - eventProperties[AutoCaptureConstants.accessibilityIdentifier] = accessibilityIdentifier + eventProperties[Constants.AutoCapture.accessibilityIdentifier] = accessibilityIdentifier } if let accessibilityLabel = view.getAccessibilityLabelContent() { - eventProperties[AutoCaptureConstants.accessibilityLabel] = accessibilityLabel + eventProperties[Constants.AutoCapture.accessibilityLabel] = accessibilityLabel } if let text = view.getTextContent() { - eventProperties[AutoCaptureConstants.targetText] = text + eventProperties[Constants.AutoCapture.targetText] = text } } diff --git a/Sources/Userpilot/Monitor/NetworkMonitor.swift b/Sources/Userpilot/Monitor/NetworkMonitor.swift new file mode 100644 index 00000000..5a4dd57f --- /dev/null +++ b/Sources/Userpilot/Monitor/NetworkMonitor.swift @@ -0,0 +1,450 @@ +// +// NetworkMonitor.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 13/10/2025. +// Updated for real internet connectivity detection on 19/01/2026. +// © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// `NetworkMonitor` monitors network connectivity changes and validates real internet access. +// It uses NWPathMonitor for interface detection and periodic reachability checks to verify actual +// internet connectivity. +// + +import Foundation +import Network + +// swiftlint:disable file_length + +// MARK: - Connection Type + +/// Enum representing connection types +internal enum ConnectionType { + case wifi + case cellular + case wiredEthernet + case unknown +} + +// MARK: - Protocols + +/// Delegate for network monitor updates. +internal protocol NetworkMonitoringDelegate: AnyObject { + func networkMonitorDidUpdate(isReady: Bool, isNetworkAvailable: Bool) +} + +/// `NetworkMonitoring` defines methods and properties for monitoring network connectivity. +internal protocol NetworkMonitoring: AnyObject { + /// Indicates whether the device has an active network connection with real internet access. + var isNetworkAvailable: Bool { get } + + /// Current connection type + var connectionType: ConnectionType { get } + + /// Check if connected via WiFi + var isConnectedViaWiFi: Bool { get } + + /// Check if connected via Cellular + var isConnectedViaCellular: Bool { get } + + /// Indicates whether the monitor has received its first network state update + var isReady: Bool { get } + + /// Delegate to broadcast network updates + var delegate: NetworkMonitoringDelegate? { get set } + + /// Starts monitoring network connectivity changes. + func startMonitoring() + + /// Stops monitoring network connectivity changes. + func stopMonitoring() +} + +// MARK: - NetworkMonitor + +// swiftlint:disable type_body_length +/// `NetworkMonitor` is responsible for monitoring network connectivity changes and validating +/// real internet access through reachability checks. +internal class NetworkMonitor: NetworkMonitoring { + + // MARK: - Properties + + private weak var userpilot: Userpilot? + private let config: Userpilot.Config + private let storage: DataStoring + private let logger: Logging + weak var delegate: NetworkMonitoringDelegate? + + private let networkQueue = DispatchQueue( + label: Constants.DispatchQueues.networkMonitor, + qos: .utility + ) + + private let stateQueue = DispatchQueue( + label: "com.userpilot.network.state", attributes: .concurrent) + + private var pathMonitor: NWPathMonitor? + private var debounceWorkItem: DispatchWorkItem? + private let debounceDelay: TimeInterval = 0.3 + + // Reachability check properties + // Active validation targets first-party Userpilot hosts only — probing public + // hosts (Google/Apple/Cloudflare) from customer apps is a privacy/firewall problem. + // Multiple first-party hosts are rotated so one unreachable endpoint can't + // falsely report "no internet". Side benefit: "Userpilot backend unreachable" + // also routes events to offline storage. + private let reachabilityTimeout: TimeInterval = 5.0 + private var currentReachabilityIndex = 0 + + // Backing state (accessed via concurrent queue) + private var _isNetworkAvailable: Bool = false // Start pessimistic until verified + private var _hasInterfaceConnection: Bool = false // Interface level connectivity + private var _hasInternetAccess: Bool = false // Real internet connectivity + private var _connectionType: ConnectionType = .unknown + private var _isReady: Bool = false + private var _isCheckingReachability: Bool = false + + /// Indicates whether the device has real internet connectivity + var isNetworkAvailable: Bool { + stateQueue.sync { _isNetworkAvailable } + } + + /// Current connection type + var connectionType: ConnectionType { + stateQueue.sync { _connectionType } + } + + /// Check if connected via WiFi with internet access + var isConnectedViaWiFi: Bool { + isNetworkAvailable && connectionType == .wifi + } + + /// Check if connected via Cellular with internet access + var isConnectedViaCellular: Bool { + isNetworkAvailable && connectionType == .cellular + } + + /// Indicates whether the monitor has received its first network state update. + var isReady: Bool { + stateQueue.sync { _isReady } + } + + // MARK: - Initialization + + init(container: DIContainer) { + self.userpilot = container.owner + self.config = container.resolve(Userpilot.Config.self) + self.storage = container.resolve(DataStoring.self) + self.logger = config.logger + } + + deinit { + stopMonitoring() + } + + // MARK: - NetworkMonitoring + + func startMonitoring() { + tryCatch { + guard pathMonitor == nil else { return } + + // Start path monitoring + pathMonitor = NWPathMonitor() + + pathMonitor?.pathUpdateHandler = { [weak self] path in + guard let self = self else { return } + + let hasInterface = path.status == .satisfied && path.availableInterfaces.count > 0 + let connType = self.determineConnectionType(path) + + self.logger.debug( + "🌐 Interface status: %{public}@, Type: %{public}@", + hasInterface ? "Connected" : "Disconnected", + self.connectionTypeString(connType)) + + // Update interface state + self.updateInterfaceState(hasInterface: hasInterface, connectionType: connType) + } + + pathMonitor?.start(queue: networkQueue) + + // Trigger initial reachability check + self.performReachabilityCheck() + + logger.debug("🌐 NetworkMonitor started with internet validation") + } + } + + func stopMonitoring() { + tryCatch { + markNotReadyForBackground() + debounceWorkItem?.cancel() + debounceWorkItem = nil + + pathMonitor?.cancel() + pathMonitor = nil + + logger.debug("🌐 NetworkMonitor stopped") + } + } + + /** + * Background middle-ground: readiness re-arms (foreground events buffer in the + * initial queue until connectivity is re-verified — the network can change while + * suspended), but the last-known AVAILABILITY is preserved so backgrounding never + * falsely reports "offline" and routes events to local storage. Also releases a + * possibly in-flight reachability check so the next one isn't skipped. + */ + private func markNotReadyForBackground() { + var lastKnownAvailability = false + stateQueue.sync(flags: .barrier) { + lastKnownAvailability = _isNetworkAvailable + _isReady = false + _isCheckingReachability = false + _hasInterfaceConnection = false + } + delegate?.networkMonitorDidUpdate(isReady: false, isNetworkAvailable: lastKnownAvailability) + } + + // MARK: - Reachability Checks + // Removed periodic polling to save battery and resources. + // We now rely on NWPathMonitor updates to trigger checks. + + private func performReachabilityCheck() { + // Read current interface state + let hasInterface = stateQueue.sync { _hasInterfaceConnection } + + // Skip check if no interface connection + guard hasInterface else { + return + } + + // Skip if already checking + let isChecking = stateQueue.sync { _isCheckingReachability } + guard !isChecking else { return } + + stateQueue.async(flags: .barrier) { [weak self] in + self?._isCheckingReachability = true + } + + // Rotate through first-party hosts for redundancy + let reachabilityHosts = NetworkMonitor.makeReachabilityHosts(socketURL: storage.socketURL) + guard !reachabilityHosts.isEmpty else { + // Always release the checking flag on early exit, or every future + // reachability check would be skipped forever. + stateQueue.async(flags: .barrier) { [weak self] in + self?._isCheckingReachability = false + } + updateInternetAccessState(hasAccess: false) + return + } + if currentReachabilityIndex >= reachabilityHosts.count { + currentReachabilityIndex = 0 + } + let host = reachabilityHosts[currentReachabilityIndex] + currentReachabilityIndex = (currentReachabilityIndex + 1) % reachabilityHosts.count + + checkInternetReachability(host: host) { [weak self] hasAccess in + guard let self = self else { return } + + self.stateQueue.async(flags: .barrier) { + self._isCheckingReachability = false + } + + self.updateInternetAccessState(hasAccess: hasAccess) + } + } + + /// Builds the first-party probe host list: the configured socket endpoint plus + /// the settings and experience API hosts. Never public hosts. + static func makeReachabilityHosts(socketURL: String) -> [String] { + var hosts: [String] = [] + appendHost(from: socketURL, to: &hosts) + appendHost(from: Constants.RemoteSource.settingsBaseURL, to: &hosts) + appendHost(from: Constants.RemoteSource.experienceBaseURL, to: &hosts) + return hosts + } + + private static func appendHost(from urlString: String, to hosts: inout [String]) { + guard !urlString.isEmpty else { return } + let normalizedURLString = urlString.contains("://") ? urlString : "https://\(urlString)" + guard let host = URL(string: normalizedURLString)?.host, !host.isEmpty else { return } + if !hosts.contains(host) { + hosts.append(host) + } + } + + private func checkInternetReachability(host: String, completion: @escaping (Bool) -> Void) { + // Use NWConnection for a lightweight reachability check + guard let port = NWEndpoint.Port(rawValue: 443) else { + completion(false) + return + } + + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: port, + using: .tcp + ) + + var didComplete = false + let timeoutWorkItem = DispatchWorkItem { [weak connection] in + guard !didComplete else { return } + didComplete = true + connection?.cancel() + completion(false) + } + + connection.stateUpdateHandler = { [weak self] state in + guard !didComplete else { return } + + switch state { + case .ready: + didComplete = true + timeoutWorkItem.cancel() + connection.cancel() + self?.logger.debug("🌐 Reachability check succeeded: %{public}@", host) + completion(true) + + case .failed(let error): + didComplete = true + timeoutWorkItem.cancel() + connection.cancel() + self?.logger.debug( + "🌐 Reachability check failed: %{public}@ - %{public}@", + host, error.localizedDescription) + completion(false) + + case .cancelled: + if !didComplete { + didComplete = true + timeoutWorkItem.cancel() + completion(false) + } + + default: + break + } + } + + connection.start(queue: networkQueue) + networkQueue.asyncAfter(deadline: .now() + reachabilityTimeout, execute: timeoutWorkItem) + } + + // MARK: - State Management + + private func updateInterfaceState(hasInterface: Bool, connectionType: ConnectionType) { + debounceWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + var oldInterfaceState = false + var oldType: ConnectionType = .unknown + + self.stateQueue.sync { + oldInterfaceState = self._hasInterfaceConnection + oldType = self._connectionType + } + + let interfaceChanged = oldInterfaceState != hasInterface + let typeChanged = oldType != connectionType + + guard interfaceChanged || typeChanged else { + // Nothing changed, but readiness was never resolved: drive it to a + // conclusion so startup events don't sit in the initial queue. + let wasReady = self.stateQueue.sync { self._isReady } + if !wasReady { + if hasInterface { + self.performReachabilityCheck() + } else { + self.updateInternetAccessState(hasAccess: false) + } + } + return + } + + self.stateQueue.async(flags: .barrier) { + self._hasInterfaceConnection = hasInterface + self._connectionType = connectionType + } + + if interfaceChanged { + if hasInterface { + self.logger.debug("🌐 Network interface connected, checking internet access...") + // Trigger immediate reachability check + self.performReachabilityCheck() + } else { + self.logger.debug("🌐 Network interface disconnected") + self.updateInternetAccessState(hasAccess: false) + } + } + } + + debounceWorkItem = workItem + networkQueue.asyncAfter(deadline: .now() + debounceDelay, execute: workItem) + } + + private func updateInternetAccessState(hasAccess: Bool) { + var oldAccessState = false + var oldNetworkState = false + var wasReady = false + var connType: ConnectionType = .unknown + stateQueue.sync { + oldAccessState = _hasInternetAccess + oldNetworkState = _isNetworkAvailable + wasReady = _isReady + connType = _connectionType + } + + let accessChanged = oldAccessState != hasAccess + let networkChanged = oldNetworkState != hasAccess + + guard accessChanged || networkChanged || !wasReady else { return } + + stateQueue.async(flags: .barrier) { [weak self] in + self?._hasInternetAccess = hasAccess + self?._isNetworkAvailable = hasAccess + self?._isReady = true + } + + if accessChanged || !wasReady { + let typeString = connectionTypeString(connType) + if hasAccess { + logger.debug("🌐 ✅ Internet access verified - Connection: %{public}@", typeString) + } else { + logger.debug("🌐 ❌ No internet access - Connection: %{public}@", typeString) + } + } + + let shouldNotify = accessChanged || !wasReady + if shouldNotify { + delegate?.networkMonitorDidUpdate(isReady: true, isNetworkAvailable: hasAccess) + } + } + + // MARK: - Helper Methods + + private func determineConnectionType(_ path: NWPath) -> ConnectionType { + if path.usesInterfaceType(.wifi) { + return .wifi + } else if path.usesInterfaceType(.cellular) { + return .cellular + } else if path.usesInterfaceType(.wiredEthernet) { + return .wiredEthernet + } else { + return .unknown + } + } + + private func connectionTypeString(_ type: ConnectionType) -> String { + switch type { + case .wifi: return "WiFi" + case .cellular: return "Cellular" + case .wiredEthernet: return "Ethernet" + case .unknown: return "Unknown" + } + } +} +// swiftlint:enable type_body_length diff --git a/Sources/Userpilot/Analytics/SessionMonitor.swift b/Sources/Userpilot/Monitor/SessionMonitor.swift similarity index 92% rename from Sources/Userpilot/Analytics/SessionMonitor.swift rename to Sources/Userpilot/Monitor/SessionMonitor.swift index b73b7d46..a5620270 100644 --- a/Sources/Userpilot/Analytics/SessionMonitor.swift +++ b/Sources/Userpilot/Monitor/SessionMonitor.swift @@ -30,6 +30,9 @@ internal class SessionMonitor: SessionMonitoring { /// The analytics publisher responsible for flushing and resuming events. private let analyticsPublisher: AnalyticsPublishing + /// Network monitor to pause/resume on lifecycle changes. + private let networkMonitor: NetworkMonitoring + /// The storage used to store user-related data. private let storage: DataStoring @@ -43,6 +46,7 @@ internal class SessionMonitor: SessionMonitoring { /// - Parameter container: The dependency injection container used to resolve the required dependencies. init(container: DIContainer) { self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) + self.networkMonitor = container.resolve(NetworkMonitoring.self) self.storage = container.resolve(DataStoring.self) // Add observer for when the app enters the background. @@ -67,9 +71,7 @@ internal class SessionMonitor: SessionMonitoring { DispatchQueue.main.async { [weak self] in guard let self else { return } if UIApplication.shared.applicationState == .active && !self.hasInitializedForeground { - self._isAppActive = true - self.hasInitializedForeground = true - self.analyticsPublisher.resume() + self.onAppStart() } } } @@ -108,6 +110,7 @@ internal class SessionMonitor: SessionMonitoring { func didEnterBackground(notification: Notification) { _isAppActive = false storage.sessionDate = Date() + networkMonitor.stopMonitoring() analyticsPublisher.flush() } @@ -116,8 +119,14 @@ internal class SessionMonitor: SessionMonitoring { /// - Parameter notification: The notification object containing information about the event. @objc func didEnterForeground(notification: Notification) { + onAppStart() + } + + private func onAppStart() { _isAppActive = true hasInitializedForeground = true + networkMonitor.startMonitoring() analyticsPublisher.resume() } + } diff --git a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift index 8dfeba5f..b3ee6980 100644 --- a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift +++ b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift @@ -58,7 +58,7 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr private let config: Userpilot.Config private let storage: DataStoring private let analyticsPublisher: AnalyticsPublishing - private let socketManager: SocketEvents + private let socketManager: SocketManaging private let linkOpener: LinkOpening // MARK: - Push Token Management @@ -86,7 +86,7 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr self.config = container.resolve(Userpilot.Config.self) self.storage = container.resolve(DataStoring.self) self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) - self.socketManager = container.resolve(SocketEvents.self) + self.socketManager = container.resolve(SocketManaging.self) self.linkOpener = container.resolve(LinkOpening.self) PushNotificationAutoConfig.register(observer: self) @@ -113,8 +113,7 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr PushNotificationTokenEvent( appToken: config.token, userId: storage.userId, - token: newToken), - socketSubscription: self) + token: newToken)) } } @@ -312,8 +311,7 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr if parsedNotification.isTest != "true" { let properties: [String: Any] = ["notification_id": Int(parsedNotification.notificationId) ?? 0] analyticsPublisher.publishInternalSDKEvent( - PushNotificationOpenedEvent(payload: properties), - socketSubscription: self + PushNotificationOpenedEvent(payload: properties) ) } diff --git a/Sources/Userpilot/Remote/UserpilotRemoteSource.swift b/Sources/Userpilot/Remote/UserpilotRemoteSource.swift index a292691b..e397d30b 100644 --- a/Sources/Userpilot/Remote/UserpilotRemoteSource.swift +++ b/Sources/Userpilot/Remote/UserpilotRemoteSource.swift @@ -1,5 +1,5 @@ // -// UserpilotRemoteSource.swift +// UserpilotConstants.RemoteSource.swift // Userpilot SDK // // Created by Motasem Hamed on 17/09/2024. @@ -79,7 +79,7 @@ internal class UserpilotRemoteSource { } let elapsedTime = Date().timeIntervalSince(configurationDate) - return elapsedTime < RemoteSource.configurationDuration + return elapsedTime < Constants.RemoteSource.configurationDuration } // Returns appropriate error message based on HTTP status code. @@ -113,7 +113,7 @@ internal class UserpilotRemoteSource { /// Builds the remote settings URL using the SDK token. private func buildSettingsUrl() -> String { - return RemoteSource.settingsBaseURL + config.token + return Constants.RemoteSource.settingsBaseURL + config.token } /// Builds the URL for fetching preview experience content. @@ -197,7 +197,7 @@ internal class UserpilotRemoteSource { } storage.configurationDate = Date() - storage.socketURL = baseUrl + RemoteSource.socketPath + storage.socketURL = baseUrl + Constants.RemoteSource.socketPath logger.info("🔗 Socket URL updated: %{public}@", storage.socketURL) return .success(()) } catch { diff --git a/Sources/Userpilot/Socket/SocketManager.swift b/Sources/Userpilot/Socket/SocketManager.swift index 0eb5cbdc..0977ded9 100644 --- a/Sources/Userpilot/Socket/SocketManager.swift +++ b/Sources/Userpilot/Socket/SocketManager.swift @@ -14,63 +14,37 @@ import Foundation // MARK: - Protocols /* - `SocketEvents` defines methods and properties for managing WebSocket connections and events. + `SocketManaging` defines methods and properties for managing WebSocket connections and events. */ // swiftlint:disable file_length -internal protocol SocketEvents: AnyObject { - /// Return socket state +internal protocol SocketManaging: AnyObject { + /// Read-only socket state, derived from the Phoenix socket/channel objects. + /// These are routing queries for consumers; all state MANAGEMENT (open/close + /// gating, half-open recovery, callback suppression) is internal to the manager. var isSocketOpened: Bool { get } var isJoiningSocket: Bool { get } - var didErrorOccurred: Bool { get } + var didCloseFromError: Bool { get } var isShutdownState: Bool { get } - var isSocketConnectedWithUnknownChannel: Bool { get } - /// Update socket state - func updateSocketState( - _ socketState: SocketManager.SocketState, - forceUpdateState: Bool - ) - - /// Handle socket open & close + /// Handle socket open & close. `connect()` is always safe to call — it gates + /// itself on the current socket state and recovers a half-open transport. func connect() func close() - /// Publish socket events + /// Publish socket events. Push resolutions are delivered to all registered + /// subscribers with `message.resolvedEvent` (the response `request_type`) as + /// the event name, so no per-call subscription is needed — consumers filter + /// by event name. Resolutions are suppressed internally during teardown or + /// while the app is inactive. func publish( _ eventName: String, - payload: Payload, - socketSubscription: SocketSubscription? + payload: Payload ) /// Register socket subscription func registerCallback(_ socketSubscription: SocketSubscription) } -extension SocketEvents { - - func publish( - _ eventName: String, - payload: Payload, - socketSubscription: SocketSubscription? = nil - ) { - publish( - eventName, - payload: payload, - socketSubscription: socketSubscription - ) - } - - func updateSocketState( - _ socketState: SocketManager.SocketState, - forceUpdateState: Bool = false - ) { - updateSocketState( - socketState, - forceUpdateState: forceUpdateState - ) - } -} - /// `SocketSubscription` defines a callback interface for handling socket event notifications. internal protocol SocketSubscription: AnyObject { /// Listen to socket state @@ -117,39 +91,7 @@ internal class SocketManager { // MARK: - Properties - // Socket state enums - enum SocketState { - /** - When application enter background state, the SDK flush events and then close socket - without need to reopen. - */ - case shuttingDown - - /** - When SDK getting Identify event while it's already identified/opened channel. - */ - case switchingUser - - /** - When socket is opened. - */ - case opened - - /** - When socket closed. - */ - case closed - - /** - When socket channel is setup including getting settings and open the channel. - */ - case connecting - - /** - When getting socket channel error, to prevent retry. - */ - case error - } + typealias SocketFactory = (_ endpoint: String, _ params: SwiftPhoenixClientPayload?) -> Socket /// The WebSocket instance for handling connections. private var phoenixSocket: Socket? @@ -178,12 +120,17 @@ internal class SocketManager { /// socket susbcriber @Multicast var socketSubscription: SocketSubscription - // track socket state - private var socketState: SocketState = .closed + /// True while an intentional close is in progress. Suppresses push callbacks that + /// race the teardown; cleared when a new connection attempt starts. + private var isClosingSocket = false // track fetching socket settings private lazy var isFetchingSocketSettings: AtomicReference = AtomicReference(false) + /// Factory used to create Phoenix sockets. Production uses the real transport; + /// tests can inject a fake transport while still exercising the real manager. + private let socketFactory: SocketFactory + // MARK: - Initialization /** @@ -191,13 +138,29 @@ internal class SocketManager { - Parameter container: The dependency injection container. */ - init(container: DIContainer) { + init( + container: DIContainer, + socketFactory: @escaping SocketFactory = { endpoint, params in + Socket(endpoint, params: params) + } + ) { + self.container = container self.userpilot = container.owner self.config = container.resolve(Userpilot.Config.self) self.storage = container.resolve(DataStoring.self) self.autoPropertyDecorator = container.resolve(AutoPropertyDecoratoring.self) self.userpilotRemoteSource = container.resolve(UserpilotRemoteSourcing.self) self.logger = config.logger + self.socketFactory = socketFactory + } + + /// DI container kept for use-time resolution of lifecycle state; resolving + /// `SessionMonitoring` at init would create a dependency cycle. + private weak var container: DIContainer? + + /// Session monitoring to know when the app is inactive (background teardown). + private weak var sessionMonitorer: SessionMonitoring? { + return container?.resolve(SessionMonitoring.self) } } @@ -221,24 +184,23 @@ extension SocketManager { let appProperties = autoPropertyDecorator.appProperties.toJSONString() else { return } tryCatch { - socketState = .connecting + quarantinePreviousSocket() + + isClosingSocket = false let socketProperties: [String: Any] = [ - SocketManager.tokenKey: Environment.getClientToken(config: config), - SocketManager.userIdKey: storage.userId, - SocketManager.sdkVersionKey: userpilot?.version() ?? "", - SocketManager.autoPropertiesKey: autoProperties, - SocketManager.appPropertiesKey: appProperties + Constants.Socket.tokenKey: Environment.getClientToken(config: config), + Constants.Socket.userIdKey: storage.userId, + Constants.Socket.sdkVersionKey: userpilot?.version() ?? "", + Constants.Socket.autoPropertiesKey: autoProperties, + Constants.Socket.appPropertiesKey: appProperties ] - phoenixSocket = Socket( + phoenixSocket = socketFactory( Environment.getSocketURL(storage: storage), - params: socketProperties + socketProperties ) - guard let phoenixSocket else { - socketState = .closed - return - } + guard let phoenixSocket else { return } // Setup delegates for socket events phoenixSocket.delegateOnOpen(to: self) { (self) in @@ -247,16 +209,14 @@ extension SocketManager { phoenixSocket.delegateOnClose(to: self) { (self) in self.logger.error("🛑 SOCKET closed") - self.updateSocketState(.closed) - if self.socketState != .shuttingDown { - self.$socketSubscription.invoke { $0.onSocketClosed() } - } + // Teardown finished — allow future connection attempts. + self.isClosingSocket = false + self.$socketSubscription.invoke { $0.onSocketClosed() } } phoenixSocket.delegateOnError(to: self) { (self, error) in let (error, _) = error self.logger.error("❗ SOCKET error - details %{public}@", error.localizedDescription) - self.updateSocketState(.error) } phoenixSocket.onMessage(callback: { [weak self] message in @@ -269,40 +229,46 @@ extension SocketManager { } // Setup the channel - always create a new channel instance to avoid join conflicts - let channel = phoenixSocket.channel(SocketManager.channelTopic) + let channel = phoenixSocket.channel(Constants.Socket.channelTopic) // Connect to the channel phoenixChannel = channel phoenixChannel?.join()? .delegateReceive( - SocketManager.successKey, to: self, + Constants.Socket.successKey, to: self, callback: { (self, _) in self.logger.info("🚀 SOCKET channel joined") - self.updateSocketState(.opened) self.$socketSubscription.invoke { $0.onSocketOpened() } self.isFetchingSocketSettings.value = false } ) .delegateReceive( - SocketManager.errorKey, to: self, + Constants.Socket.errorKey, to: self, callback: { (self, message) in self.logger.error( "⚠️ SOCKET channel join failed: %{public}@", message.payload) - self.updateSocketState(.error) + self.closeSocket() + self.isFetchingSocketSettings.value = false + }) + .delegateReceive( + Constants.Socket.timeoutKey, to: self, + callback: { (self, _) in + // A silently timed-out join is the birth of the half-open + // state (socket connected, channel never joined) — kill it + // at the source; onSocketClosed drives normal recovery. + self.logger.error("⏱️ SOCKET channel join timed out") self.closeSocket() self.isFetchingSocketSettings.value = false }) phoenixChannel?.onError { [weak self] message in self?.logger.error("❗ SOCKET Channel error: %{public}@", message.payload) - self?.updateSocketState(.error) self?.closeSocket() self?.isFetchingSocketSettings.value = false } phoenixChannel?.onClose { [weak self] message in self?.logger.debug("🛑 SOCKET Channel close: %{public}@", message.payload) - self?.updateSocketState(.closed) self?.isFetchingSocketSettings.value = false } @@ -316,7 +282,24 @@ extension SocketManager { - Parameter completion: A closure that is called when the disconnection completes. */ + /// Structurally abandon any previous transport (e.g. a half-open socket whose + /// channel never joined): quarantine its callbacks first so tearing it down + /// cannot fire stale close/error events into subscribers, then disconnect it. + /// Captures the instance locally and tears down synchronously — race-free + /// against a replacement, and a no-op for an already-closed socket. + private func quarantinePreviousSocket() { + guard let oldSocket = phoenixSocket else { return } + if let oldChannel = phoenixChannel { + oldSocket.remove(oldChannel) + } + oldSocket.releaseCallbacks() + oldSocket.disconnect() + } + private func closeSocket() { + // Only mark closing when there is a live socket to tear down; the flag is + // cleared by `delegateOnClose` once the socket reports closed. + if phoenixSocket != nil { isClosingSocket = true } performOn(.main) { [weak self] in tryCatch { if let channel = self?.phoenixChannel { @@ -332,13 +315,13 @@ extension SocketManager { } -// MARK: - SocketEvents +// MARK: - SocketManaging -extension SocketManager: SocketEvents { +extension SocketManager: SocketManaging { - /// Logic to determine if the channel state is joining + /// Logic to determine if the socket/channel is in a joining state var isJoiningSocket: Bool { - phoenixChannel?.isJoining == true || socketState == .connecting + phoenixSocket?.isConnecting == true || phoenixChannel?.isJoining == true } /// Logic to check if the socket is currently open @@ -346,41 +329,38 @@ extension SocketManager: SocketEvents { phoenixSocket?.isConnected == true && phoenixChannel?.isJoined == true } - /// Checks if the socket is closed due to error reason - var didErrorOccurred: Bool { - socketState == .error + /// Checks if the channel errored (used to prevent automatic reopen loops) + var didCloseFromError: Bool { + phoenixChannel?.isErrored == true } - /// Checks if the socket in shutting down state + /// Checks if the socket is being intentionally torn down var isShutdownState: Bool { - socketState == .shuttingDown + isClosingSocket + || phoenixSocket?.connectionState == .closing + || phoenixChannel?.isLeaving == true } - /// Update socket state - func updateSocketState( - _ newSocketState: SocketManager.SocketState, - forceUpdateState: Bool = false - ) { - tryCatch { - if forceUpdateState || newSocketState == .error { - socketState = newSocketState - return - } - if socketState == newSocketState || socketState == .error { return } - socketState = newSocketState - } + /// Checks if a new connection attempt is currently allowed + private var isAllowToOpenSocket: Bool { + !isShutdownState && !isSocketOpened && !isJoiningSocket } /// Checks if the socket is currently opened without channel - var isSocketConnectedWithUnknownChannel: Bool { + private var isSocketConnectedWithUnknownChannel: Bool { phoenixSocket?.isConnected == true && phoenixChannel?.isJoined == false } /// Implementation to open a WebSocket connection func connect() { - if config.token.isEmpty || storage.userId.isEmpty || isSocketOpened || isJoiningSocket { + if config.token.isEmpty || storage.userId.isEmpty || !isAllowToOpenSocket { return } + // Half-open transport (connected, channel never joined) passes the gate + // above — recover by abandoning it before dialing fresh. + if isSocketConnectedWithUnknownChannel { + quarantinePreviousSocket() + } if !isFetchingSocketSettings.compareAndSet(expected: false, new: true) { return } @@ -401,62 +381,56 @@ extension SocketManager: SocketEvents { /// Implementation to close the WebSocket connection func close() { - updateSocketState(.closed) closeSocket() } /// Implementation to publish an event over the WebSocket func publish( _ eventName: String, - payload: Payload, - socketSubscription: SocketSubscription? + payload: Payload ) { _ = tryCatch { phoenixChannel? - .push(eventName, payload: payload ?? [:])? - .receive(SocketManager.successKey) { [weak self] message in - if self?.socketState != .shuttingDown { - if let socketSubscription { - socketSubscription.onSocketEventSent(eventName, payload, message, true) - } else { - self?.$socketSubscription.invoke { - $0.onSocketEventSent(eventName, payload, message, true) - } - } - } + .push( + eventName, + payload: payload ?? [:], + timeout: Constants.Socket.pushTimeout + )? + .receive(Constants.Socket.successKey) { [weak self] message in + self?.notifyEventSent(eventName, payload, message, true) } - .receive(SocketManager.errorKey) { [weak self] message in - if self?.socketState != .shuttingDown { - if let socketSubscription { - socketSubscription.onSocketEventSent(eventName, payload, message, false) - } else { - self?.$socketSubscription.invoke { - $0.onSocketEventSent(eventName, payload, message, false) - } - } - } + .receive(Constants.Socket.errorKey) { [weak self] message in + self?.notifyEventSent(eventName, payload, message, false) + } + .receive(Constants.Socket.timeoutKey) { [weak self] message in + // Without this hook a timed-out push resolves silently and the + // ACK-gated analytics queue stalls until the watchdog fires. + self?.logger.error("⏱️ SOCKET push timed out for event: %{public}@", eventName) + self?.notifyEventSent(eventName, payload, message, false) } } } + /// Delivers a push resolution (ok/error/timeout) to all registered subscribers. + /// The response's `request_type` identifies the event, so consumers filter by + /// name; falls back to the pushed event name when the payload carries none + /// (error/timeout resolutions). Suppressed during teardown or while the app is + /// inactive (background flush) — resolutions must not re-drive the event queue + /// mid-teardown. + private func notifyEventSent( + _ eventName: String, + _ payload: Payload, + _ message: Message, + _ eventSent: Bool + ) { + guard !isShutdownState, sessionMonitorer?.isAppActive ?? false else { return } + $socketSubscription.invoke { + $0.onSocketEventSent(message.resolvedEvent ?? eventName, payload, message, eventSent) + } + } + /// Implementation to register a callback for socket events func registerCallback(_ socketSubscription: SocketSubscription) { self.socketSubscription = socketSubscription } } - -// MARK: - Properties name - -extension SocketManager { - - // Static constants - private static let channelTopic = "events:*" - private static let successKey = "ok" - private static let errorKey = "error" - - private static let tokenKey = "app_token" - private static let userIdKey = "user_id" - private static let autoPropertiesKey = "auto_properties" - private static let appPropertiesKey = "app_properties" - private static let sdkVersionKey = "sdk_version" -} diff --git a/Sources/Userpilot/Userpilot.swift b/Sources/Userpilot/Userpilot.swift index acd48d47..ac2c0334 100644 --- a/Sources/Userpilot/Userpilot.swift +++ b/Sources/Userpilot/Userpilot.swift @@ -78,8 +78,8 @@ public class Userpilot: NSObject { /// Lazy loading of the `DataStoring` instance that manages persistent storage (e.g., user data, preferences). private lazy var storage = container.resolve(DataStoring.self) - /// Lazy loading of the `SocketEvents` instance that manages WebSocket connections and event-driven communication. - private lazy var socketManager = container.resolve(SocketEvents.self) + /// Lazy loading of the `SocketManaging` instance that manages WebSocket connections and event-driven communication. + private lazy var socketManager = container.resolve(SocketManaging.self) /// Lazy loading of the `SessionMonitoring` instance that manages app lifecycle. private lazy var sessionMonitor = container.resolve(SessionMonitoring.self) @@ -205,7 +205,7 @@ public class Userpilot: NSObject { Initializes the DI (Dependency Injection) container and registers required services. This method sets up lazy initialization for essential SDK services like `DataStoring`, - `Networking`, `SocketEvents`, and more. + `Networking`, `SocketManaging`, and more. By using lazy registration, the services are only created when they are first used, improving performance. */ internal func initializeContainer() { @@ -218,7 +218,9 @@ public class Userpilot: NSObject { container.register(InstanceRegistering.self, value: Registry.shared) container.registerLazy( AutoPropertyDecoratoring.self, initializer: AutoPropertyDecorator.init) - container.registerLazy(SocketEvents.self, initializer: SocketManager.init) + container.registerLazy(SocketManaging.self) { container in + SocketManager(container: container) + } container.registerLazy(UserpilotRemoteSourcing.self, initializer: UserpilotRemoteSource.init) container.registerLazy(ThemeHandling.self, initializer: ThemeHandler.init) container.registerLazy(ImageLoading.self, initializer: ImageLoader.init) @@ -226,8 +228,12 @@ public class Userpilot: NSObject { container.registerLazy(AutoCaptureCoordinating.self, initializer: AutoCaptureCoordinater.init) container.registerLazy(DeepLinkHandling.self, initializer: DeepLinkHandler.init) container.registerLazy(LinkOpening.self, initializer: LinkOpener.init) - container.registerLazy(ExperienceStateManaging.self, initializer: ExperienceStateManager.init) + container.registerLazy(ExperienceStateManaging.self, initializer: ExperienceStateMachine.init) + container.registerLazy(EventStoring.self, initializer: EventDatabaseStorage.init) + container.registerEager(UserSessionStateManaging.self, initializer: UserSessionStateMachine.init) container.registerEager(DataStoring.self, initializer: Storage.init) + container.registerEager(NetworkMonitoring.self, initializer: NetworkMonitor.init) + container.registerEager(OfflineEventsHandling.self, initializer: OfflineEventsHandler.init) container.registerEager(AnalyticsPublishing.self, initializer: AnalyticsPublisher.init) container.registerEager( PushNotificationMonitoring.self, initializer: PushNotificationMonitor.init) @@ -336,7 +342,7 @@ extension Userpilot { } let event = Event( type: .screen(title), - properties: [AutoCaptureConstants.source: AutoCaptureConstants.manualCaptureSourceValue] + properties: [Constants.AutoCapture.source: Constants.AutoCapture.manualCaptureSourceValue] ) analyticsPublisher.publish(event) } @@ -379,7 +385,7 @@ extension Userpilot { public func logout() { storage.temporaryUser = nil storage.user = "" - analyticsPublisher.logout(socketState: .shuttingDown, shouldClearCachedIdentifyEvent: true) + analyticsPublisher.logout(clearCachedIdentifyEvent: true) clean() } diff --git a/Sources/Userpilot/Utilities/Helper/AnyCodable.swift b/Sources/Userpilot/Utilities/Helper/AnyCodable.swift new file mode 100644 index 00000000..90a4cc48 --- /dev/null +++ b/Sources/Userpilot/Utilities/Helper/AnyCodable.swift @@ -0,0 +1,68 @@ +// +// AnyCodable.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 03/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Utility to encode/decode heterogeneous Any values in JSON. +// +// MARK: - AnyCodable Helper + +import Foundation + +/// Helper struct to encode/decode Any values +internal struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case let value as String: + try container.encode(value) + case let value as Int: + try container.encode(value) + case let value as Double: + try container.encode(value) + case let value as Bool: + try container.encode(value) + case let value as [String: Any]: + try container.encode(value.mapValues { AnyCodable($0) }) + case let value as [Any]: + try container.encode(value.map { AnyCodable($0) }) + default: + try container.encodeNil() + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let dictValue = try? container.decode([String: AnyCodable].self) { + value = dictValue.mapValues { $0.value } + } else if let arrayValue = try? container.decode([AnyCodable].self) { + value = arrayValue.map { $0.value } + } else if container.decodeNil() { + value = NSNull() + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyCodable value cannot be decoded" + ) + } + } +} diff --git a/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift b/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift new file mode 100644 index 00000000..6ed9cbdb --- /dev/null +++ b/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift @@ -0,0 +1,103 @@ +// +// Constants+AutoCapture.swift +// Userpilot SDK +// +// Copyright © 2024 Userpilot. All rights reserved. +// +// [Brief Description] +// Dictionary / JSON keys for Auto Capture payloads (UIKit, SwiftUI helpers, and screen context). +// + +import Foundation + +typealias AutoCaptureConstants = Constants.AutoCapture + +extension Constants { + + /// Dictionary / JSON keys for Auto Capture payloads (UIKit, SwiftUI helpers, and screen context). + enum AutoCapture { + + // MARK: - Event envelope + + static let internalProperties = "internal_properties" + static let nestedProperties = "properties" + static let rawInteractionType = "raw_interaction_type" + static let uiFramework = "ui_framework" + + // MARK: - Screen context (`buildScreenDictionary` / tracking payload) + + static let screen = "screen" + static let screenName = "screen_name" + static let screenClass = "screen_class" + static let screenTitle = "title" + static let screenType = "screen_type" + static let navigationTitle = "navigation_title" + static let currentScreen = "current_screen" + static let isUserpilotContainerClass = "is_userpilot_container_class" + static let vcAccessibilityIdentifier = "vc_accessibility_identifier" + static let vcAccessibilityLabel = "vc_accessibility_label" + + static let source = "screen_source" + static let autoCaptureSourceValue = "auto" + static let manualCaptureSourceValue = "manual" + + static let tabName = "tab_name" + static let tabIndex = "tab_index" + + /// `true` when a SwiftUI autocaptured screen resolved to the same name/title as the + /// previous screen context, usually because UIKit exposed a stale navigation title. + static let screenNameMatchesPreviousScreen = "screen_name_matches_previous_screen" + + // MARK: - Interaction (public properties) + + static let hierarchy = "hierarchy" + static let interactionType = "interaction_type" + static let targetClass = "target_class" + static let targetText = "target_text" + static let accessibilityLabel = "accessibility_label" + static let accessibilityIdentifier = "accessibility_identifier" + static let targetAction = "target_action" + static let ownerTargetClass = "owner_target_class" + static let targetViewName = "target_view_name" + static let placeholder = "placeholder" + static let dialogTitle = "dialog_title" + static let dialogMessage = "dialog_message" + static let section = "section" + + // MARK: - Interaction (source / internal_properties) + + static let isChecked = "is_checked" + static let selectedIndex = "selected_index" + static let selectedValue = "selected_value" + static let selectedDate = "selected_date" + static let hasText = "has_text" + static let textLength = "text_length" + + // MARK: - Element tracking payload + + /// Root segment when no owning `UIViewController` is found for a view (`UIView.userpilotResolvedScreenName()`). + static let unknownScreenHierarchyPlaceholder = "UnknownScreen" + + // MARK: - SwiftUI resolver + + static let swiftUIView = "swiftui_view" + + // MARK: - Common `element_type` values (UIKit) + + static let elementTypeUIAlertController = "UIAlertController" + + // MARK: - Tab bar fallback label + + /// Prefix when `tabBarItem.title` is nil, e.g. `"Tab 1"`. + static let defaultTabTitlePrefix = "Tab " + + // MARK: - High-frequency interaction debounce (text field, text view, slider) + + /// Quiet period after the last change before emitting a debounced `mobile_autocapture` event. + static let interactionDebounceInterval: TimeInterval = 1.0 + + // MARK: - Values + + static let reductText = "****" + } +} diff --git a/Sources/Userpilot/Utilities/Helper/Constants.swift b/Sources/Userpilot/Utilities/Helper/Constants.swift index c25cc1aa..37048191 100644 --- a/Sources/Userpilot/Utilities/Helper/Constants.swift +++ b/Sources/Userpilot/Utilities/Helper/Constants.swift @@ -14,115 +14,112 @@ import Foundation /// dictionary typealias public typealias Payload = [String: Any]? -// swiftlint:disable identifier_name -internal struct DispatchQueueConstants { - static let EVENT_QUEUE = "com.userpilot.event-queue" - static let EXPERIENCE_QUEUE = "com.userpilot.experience-queue" - static let DELAY_QUEUE = "com.userpilot.delay-queue" - static let DI_CONTAINER_QUEUE = "com.userpilot.dicontainer-queue" - static let THROTTLE_QUEUE = "com.userpilot.throttle-queue" - static let DEBOUNCE_QUEUE = "com.userpilot.debounce-queue" - static let REGISTRY_QUEUE = "com.userpilot.registry-queue" +/** + Namespaced SDK constants. + + Case-less enums are used as namespaces: they cannot be instantiated, carry no state, + and group related constants under a single discoverable root (`Constants.`). + + Rules: + - Wire-format strings (socket event names, payload keys, DB columns), cross-cutting + tuning values, and anything with two or more consumers live here. + - Single-consumer implementation details stay as `private static let` on the owning + type (e.g. `ThemeHandler`, `AutoPropertyDecorator`). + - Large domains get their own file via `extension Constants` + (see `Constants+AutoCapture.swift`). + */ +internal enum Constants { + + /// Socket analytics event names (wire format). + enum Event { + static let identifyEvent = "user_identify" + static let screenEvent = "screen" + static let trackEvent = "track" + static let autoCaptureEvent = "mobile_autocapture" + static let batchEventsEvent = "batch_events" + } + + /// Analytics payload keys and tuning values (wire format unless noted). + enum Analytics { + static let metaDataProperty = "metadata" + static let identifyCompanyProperty = "company" + static let screenTitleProperty = "title" + static let isSessionStartedProperty = "is_session_start" + static let fakeReload = "fake_reload" + static let seenContents = "seen_contents" + static let seenSurveys = "seen_surveys" + static let eventNameProperty = "event_name" + static let screenProperty = "screen" + + /// Session gap after which a new session starts. + static let sessionDuration = TimeInterval(30 * 60) + + /// Watchdog for a stuck event-processing cycle. Must stay ABOVE the socket + /// push timeout so socket ok/error/timeout callbacks always resolve the + /// in-flight event first; the watchdog only fires when NO callback arrived. + static let stuckProcessingWatchdog: TimeInterval = Constants.Socket.pushTimeout + 2.0 + } + + /// Offline batch event payload keys (wire format). + enum OfflineEvents { + static let eventTypeProperty = "event_type" + static let createdAtProperty = "created_at" + static let eventsProperty = "events" + } + + /// Socket transport keys and timeouts (wire format unless noted). + enum Socket { + static let successKey = "ok" + static let errorKey = "error" + static let timeoutKey = "timeout" + + static let channelTopic = "events:*" + static let tokenKey = "app_token" + static let userIdKey = "user_id" + static let autoPropertiesKey = "auto_properties" + static let appPropertiesKey = "app_properties" + static let sdkVersionKey = "sdk_version" + + /// Phoenix push timeout (matches vendored `Defaults.timeoutInterval`). + static let pushTimeout: TimeInterval = 10.0 + } + + /// Local offline event database limits and schema. + enum Database { + static let maxEventCount = 5_000 + static let maxSizeBytes: Int64 = 3 * 1024 * 1024 + /// Bump when the Events table schema changes; add migration in `EventDatabaseStorage`. + static let schemaVersion: Int32 = 1 + } + + /// Dispatch queue labels. + enum DispatchQueues { + static let database = "com.userpilot.database-queue" + static let networkMonitor = "com.userpilot.network-monitor-queue" + static let analyticsWatchdog = "com.userpilot.analytics-watchdog-queue" + static let eventQueue = "com.userpilot.event-queue" + static let experienceQueue = "com.userpilot.experience-queue" + static let delayQueue = "com.userpilot.delay-queue" + static let diContainerQueue = "com.userpilot.dicontainer-queue" + static let throttleQueue = "com.userpilot.throttle-queue" + static let debounceQueue = "com.userpilot.debounce-queue" + static let registryQueue = "com.userpilot.registry-queue" + } + + /// Network reachability configuration. + enum Network { + /// First-party host used for active reachability validation. + /// Never probe public hosts (Google/Apple/Cloudflare) from customer apps. + static let reachabilityHost = "find.userpilot.io" + } + + /// Remote endpoints and settings-cache tuning. + enum RemoteSource { + static let settingsBaseURL = "https://find.userpilot.io/v1/lookups/" + static let socketPath = "/mobile/v1/events/websocket" + static let experienceBaseURL = + "https://appex-dev-nxtapp-14664.userpilot.io/api/v1/public/content" + /// How long fetched SDK settings stay fresh before a refetch. + static let configurationDuration = TimeInterval(30 * 60) + } } - -internal struct GeneralConstants { - static let PATH_NAME = "/mobile/v1/events/websocket" - static let SESSION_DURATION = TimeInterval(30 * 60) - static let CONFIGURATION_DURATION = TimeInterval(30 * 60) -} - -internal struct RemoteSource { - static let settingsBaseURL = "https://find.userpilot.io/v1/lookups/" - static let socketPath = "/mobile/v1/events/websocket" - static let experienceBaseURL = - "https://appex-dev-nxtapp-14664.userpilot.io/api/v1/public/content" - static let configurationDuration = TimeInterval(30 * 60) // 30 minutes -} - -/// Dictionary / JSON keys for Auto Capture payloads (UIKit, SwiftUI helpers, and screen context). -internal struct AutoCaptureConstants { - - // MARK: - Event envelope - - static let internalProperties = "internal_properties" - static let nestedProperties = "properties" - static let rawInteractionType = "raw_interaction_type" - static let uiFramework = "ui_framework" - - // MARK: - Screen context (`buildScreenDictionary` / tracking payload) - - static let screen = "screen" - static let screenName = "screen_name" - static let screenClass = "screen_class" - static let screenTitle = "title" - static let screenType = "screen_type" - static let navigationTitle = "navigation_title" - static let currentScreen = "current_screen" - static let isUserpilotContainerClass = "is_userpilot_container_class" - static let vcAccessibilityIdentifier = "vc_accessibility_identifier" - static let vcAccessibilityLabel = "vc_accessibility_label" - - static let source = "screen_source" - static let autoCaptureSourceValue = "auto" - static let manualCaptureSourceValue = "manual" - - static let tabName = "tab_name" - static let tabIndex = "tab_index" - - /// `true` when a SwiftUI autocaptured screen resolved to the same name/title as the - /// previous screen context, usually because UIKit exposed a stale navigation title. - static let screenNameMatchesPreviousScreen = "screen_name_matches_previous_screen" - - // MARK: - Interaction (public properties) - - static let hierarchy = "hierarchy" - static let interactionType = "interaction_type" - static let targetClass = "target_class" - static let targetText = "target_text" - static let accessibilityLabel = "accessibility_label" - static let accessibilityIdentifier = "accessibility_identifier" - static let targetAction = "target_action" - static let ownerTargetClass = "owner_target_class" - static let targetViewName = "target_view_name" - static let placeholder = "placeholder" - static let dialogTitle = "dialog_title" - static let dialogMessage = "dialog_message" - static let section = "section" - - // MARK: - Interaction (source / internal_properties) - - static let isChecked = "is_checked" - static let selectedIndex = "selected_index" - static let selectedValue = "selected_value" - static let selectedDate = "selected_date" - static let hasText = "has_text" - static let textLength = "text_length" - - // MARK: - Element tracking payload - - /// Root segment when no owning `UIViewController` is found for a view (`UIView.userpilotResolvedScreenName()`). - static let unknownScreenHierarchyPlaceholder = "UnknownScreen" - - // MARK: - SwiftUI resolver - - static let swiftUIView = "swiftui_view" - - // MARK: - Common `element_type` values (UIKit) - - static let elementTypeUIAlertController = "UIAlertController" - - // MARK: - Tab bar fallback label - - /// Prefix when `tabBarItem.title` is nil, e.g. `"Tab 1"`. - static let defaultTabTitlePrefix = "Tab " - - // MARK: - High-frequency interaction debounce (text field, text view, slider) - - /// Quiet period after the last change before emitting a debounced `mobile_autocapture` event. - static let interactionDebounceInterval: TimeInterval = 1.0 - - // MARK: - Values - - static let reductText = "****" -} -// swiftlint:enable identifier_name diff --git a/Sources/Userpilot/Utilities/Helper/JsonCoder.swift b/Sources/Userpilot/Utilities/Helper/JsonCoder.swift new file mode 100644 index 00000000..e8621021 --- /dev/null +++ b/Sources/Userpilot/Utilities/Helper/JsonCoder.swift @@ -0,0 +1,32 @@ +// +// JsonCoder.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 05/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Shared JSON encoder/decoder utilities used by the SDK. +// + +import Foundation + +/// Shared encoder for event storage. +/// Uses standard encoding since EventStorage now stores milliseconds directly. +internal enum UserpilotEncoder { + static let shared: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + return encoder + }() +} + +// MARK: - Event Storage Decoder + +/// Shared decoder for event storage. +/// Uses standard decoding since EventStorage stores milliseconds directly. +internal enum UserpilotDecoder { + static let shared: JSONDecoder = { + return JSONDecoder() + }() +} diff --git a/Sources/Userpilot/Utilities/Threading/DelayUtils.swift b/Sources/Userpilot/Utilities/Threading/DelayUtils.swift index 7be11ed0..1e9fc83a 100644 --- a/Sources/Userpilot/Utilities/Threading/DelayUtils.swift +++ b/Sources/Userpilot/Utilities/Threading/DelayUtils.swift @@ -17,7 +17,7 @@ internal class DelayUtils { private var currentWorkItem: DispatchWorkItem? /// Queue for thread-safe operations - private let queue = DispatchQueue(label: DispatchQueueConstants.DELAY_QUEUE, qos: .userInteractive) + private let queue = DispatchQueue(label: Constants.DispatchQueues.delayQueue, qos: .userInteractive) /** Executes an action after a specified delay. diff --git a/Sources/Userpilot/Utilities/Threading/EventDebounce.swift b/Sources/Userpilot/Utilities/Threading/EventDebounce.swift index 240c39b3..42fc7612 100644 --- a/Sources/Userpilot/Utilities/Threading/EventDebounce.swift +++ b/Sources/Userpilot/Utilities/Threading/EventDebounce.swift @@ -27,7 +27,7 @@ internal final class EventDebounce { /// Serial queue that owns all mutable state. Every read/write of /// `workItems` and `latestValues` must happen on this queue. - private let queue = DispatchQueue(label: DispatchQueueConstants.DEBOUNCE_QUEUE) + private let queue = DispatchQueue(label: Constants.DispatchQueues.debounceQueue) /// Maps a debounce key to its pending work item. private var workItems: [String: DispatchWorkItem] = [:] diff --git a/Sources/Userpilot/Utilities/Threading/EventQueue.swift b/Sources/Userpilot/Utilities/Threading/EventQueue.swift new file mode 100644 index 00000000..38af61da --- /dev/null +++ b/Sources/Userpilot/Utilities/Threading/EventQueue.swift @@ -0,0 +1,105 @@ +// +// EventQueue.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 02/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Thread-safe event queue with optional prioritization for internal events. +// + +import Foundation + +internal class EventQueue { + private var queue: [Event] = [] + private let dispatchQueue = DispatchQueue(label: "events_queue", attributes: .concurrent) + + // MARK: - Write operations (exclusive) + + public func enqueue(_ event: Event, isInternalEvent: Bool = false) { + dispatchQueue.sync(flags: .barrier) { + if isInternalEvent { + self.queue.insert(event, at: 0) + } else { + self.queue.append(event) + } + } + } + + @discardableResult + public func dequeue() -> Event? { + dispatchQueue.sync(flags: .barrier) { + if !queue.isEmpty { + return queue.removeFirst() + } + return nil + } + } + + public func clear() { + dispatchQueue.sync(flags: .barrier) { + self.queue.removeAll() + } + } + + public func deleteFirst() { + dispatchQueue.sync(flags: .barrier) { + if !self.queue.isEmpty { + self.queue.removeFirst() + } + } + } + + // MARK: - Read operations (shared reads) + + public func peek() -> Event? { + dispatchQueue.sync { + queue.first + } + } + + public func size() -> Int { + dispatchQueue.sync { + queue.count + } + } + + public func isEmpty() -> Bool { + dispatchQueue.sync { + queue.isEmpty + } + } + + public func getAll() -> [Event] { + dispatchQueue.sync { + queue + } + } + + public func getAndClear() -> [Event] { + dispatchQueue.sync(flags: .barrier) { + let events = queue + queue.removeAll() + return events + } + } + + public func contains(_ event: Event, where compare: (Event, Event) -> Bool) -> Bool { + dispatchQueue.sync { + queue.contains(where: { compare($0, event) }) + } + } + + public func find(where predicate: (Event) -> Bool) -> Event? { + dispatchQueue.sync { + queue.first(where: predicate) + } + } + + public func getFirst() -> Event? { + dispatchQueue.sync { + queue.first + } + } +} diff --git a/Sources/Userpilot/Utilities/Threading/EventThrottle.swift b/Sources/Userpilot/Utilities/Threading/EventThrottle.swift index a0ab89e0..a6d4d282 100644 --- a/Sources/Userpilot/Utilities/Threading/EventThrottle.swift +++ b/Sources/Userpilot/Utilities/Threading/EventThrottle.swift @@ -27,7 +27,7 @@ internal class EventThrottle { private var activeScreenEvent: String? /// Serial dispatch queue for thread-safe operations. - private let queue = DispatchQueue(label: DispatchQueueConstants.THROTTLE_QUEUE) + private let queue = DispatchQueue(label: Constants.DispatchQueues.throttleQueue) // MARK: - Initialization diff --git a/Sources/Userpilot/Utilities/Threading/ReadWriteLock.swift b/Sources/Userpilot/Utilities/Threading/ReadWriteLock.swift deleted file mode 100644 index 9a4e4455..00000000 --- a/Sources/Userpilot/Utilities/Threading/ReadWriteLock.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// ReadWriteLock.swift -// Userpilot SDK -// -// Created by Motasem Hamed on 19/08/2024. -// Copyright © 2024 Userpilot. All rights reserved. -// -// [Brief Description] -// A thread-safe read-write lock implementation that allows concurrent reads and exclusive writes. -// Useful for scenarios where multiple threads need to read data simultaneously but only one thread -// can modify data at a time. -// - -import Foundation - -/// A thread-safe read-write lock for managing concurrent access to shared resources. -/// Supports multiple concurrent reads but ensures exclusive writes. -internal class ReadWriteLock { - - // MARK: - Properties - - /// A concurrent dispatch queue that manages read and write operations. - /// - Concurrent reads are allowed. - /// - Writes are executed exclusively using a barrier. - private let concurrentQueue = DispatchQueue( - label: DispatchQueueConstants.EVENT_QUEUE, - qos: .utility, - attributes: .concurrent, - autoreleaseFrequency: .workItem - ) - - // MARK: - Public Methods - - /** - Executes a closure in a read-only context. - - Multiple read operations can occur concurrently. This is useful for cases where - reading shared resources does not modify them. - - - Parameter closure: The closure that performs read operations. Executed synchronously on the queue. - */ - func read(closure: () -> Void) { - concurrentQueue.sync { - closure() - } - } - - /** - Executes a closure in a write context. - - Write operations are performed exclusively. While a write is in progress, all other - reads and writes are blocked. This ensures safe mutation of shared resources. - - - Parameter closure: The closure that performs write operations. Executed - asynchronously with a barrier on the queue. - */ - func write(closure: @escaping () -> Void) { - concurrentQueue.async(flags: .barrier) { - closure() - } - } -} diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 4d36451c..6eb5a829 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -50,7 +50,7 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(userpilot.socketManager.isShutdownState || !userpilot.socketManager.isSocketOpened) } - func testPublish_screenEvent_shouldSetupScreenEntity() { + func testPublish_screenEvent_shouldSetupScreenSessionStateMachine() { // Arrange let screenEvent = Event(type: .screen("Home Screen")) userpilot.socketManager.isSocketOpened = true @@ -60,8 +60,8 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.publish(screenEvent) // Assert - XCTAssertNotNil(analyticsPublisher.screenEntity) - XCTAssertEqual(analyticsPublisher.screenEntity?.event.screenTitle, "Home Screen") + XCTAssertNotNil(analyticsPublisher.screenSessionStateMachine) + XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "Home Screen") } func testPublish_customEvent_shouldAddToQueue() { @@ -70,7 +70,7 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.socketManager.isSocketOpened = true var didPublishEvent = false - userpilot.socketManager.onPublish = { _, _, _ in + userpilot.socketManager.onPublish = { _, _ in didPublishEvent = true } @@ -114,8 +114,8 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.publish(customEvent) // Assert - // Event should be cached, not sent immediately - XCTAssertNotNil(analyticsPublisher.mockGetCachedEvent()) + // Event should be cached in the live queue, not sent immediately. + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().count, 1) } func testPublish_whenSocketInShutdownState_shouldNotProcess() { @@ -140,14 +140,14 @@ class AnalyticsPublisherTests: XCTestCase { func testFlush_shouldUpdateSocketStateAndFlushQueue() { // Arrange userpilot.socketManager.isSocketOpened = true - var socketState: SocketManager.SocketState? - userpilot.socketManager.onUpdateSocketState = { state, _ in socketState = state } + var closeCalled = false + userpilot.socketManager.onClose = { closeCalled = true } // Act analyticsPublisher.flush() // Assert - XCTAssertEqual(socketState, .shuttingDown) + XCTAssertTrue(closeCalled) } // MARK: - Resume Tests @@ -168,7 +168,7 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(connectCalled) } - func testResume_shouldNotConnectWhenSocketAlreadyOpen() { + func testResume_shouldAskSocketManagerToConnectWhenUserIdExists() { // Arrange userpilot.storage.userId = "test-user" userpilot.socketManager.isSocketOpened = true @@ -180,7 +180,7 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.resume() // Assert - XCTAssertFalse(connectCalled) + XCTAssertTrue(connectCalled) } func testResume_shouldNotConnectWhenUserIdEmpty() { @@ -222,7 +222,7 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.socketManager.onClose = { closeCalled = true } // Act - analyticsPublisher.logout(socketState: .closed, shouldClearCachedIdentifyEvent: true) + analyticsPublisher.logout(clearCachedIdentifyEvent: true) // Assert XCTAssertTrue(closeCalled) @@ -236,10 +236,10 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.socketManager.isSocketOpened = true var publishLogoutEventCalled = false - userpilot.socketManager.onPublish = { _, _, _ in publishLogoutEventCalled = true } + userpilot.socketManager.onPublish = { _, _ in publishLogoutEventCalled = true } // Act - analyticsPublisher.logout(socketState: .closed, shouldClearCachedIdentifyEvent: true) + analyticsPublisher.logout(clearCachedIdentifyEvent: true) // Assert XCTAssertTrue(publishLogoutEventCalled) @@ -249,15 +249,18 @@ class AnalyticsPublisherTests: XCTestCase { func testOnSocketOpened_shouldFlushPriorityEvents() { // Arrange - userpilot.socketManager.isSocketOpened = true - userpilot.socketManager.isJoiningSocket = false + userpilot.storage.userId = "" + userpilot.socketManager.isSocketOpened = false + userpilot.socketManager.isJoiningSocket = true userpilot.experiencesPublisher.onCanRequestScreenEvent = { return true } let identifyEvent = Event(type: .identify("test-user")) analyticsPublisher.publish(identifyEvent) + userpilot.socketManager.isSocketOpened = true + userpilot.socketManager.isJoiningSocket = false var didPublishEvent = false - userpilot.socketManager.onPublish = { _, _, _ in + userpilot.socketManager.onPublish = { _, _ in didPublishEvent = true } @@ -273,7 +276,7 @@ class AnalyticsPublisherTests: XCTestCase { // Arrange let identifyEvent = Event(type: .identify("test-user")) analyticsPublisher.publish(identifyEvent) - userpilot.socketManager.didErrorOccurred = false + userpilot.socketManager.didCloseFromError = false var didSocketConnect = false userpilot.socketManager.onConnect = { @@ -288,17 +291,19 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(didSocketConnect) } - func testOnSocketClosed_shouldClearCachedPropertiesOnError() { + func testOnSocketClosed_shouldNotReconnectAfterSocketError() { // Arrange - userpilot.socketManager.didErrorOccurred = true + userpilot.socketManager.didCloseFromError = true + var didSocketConnect = false + userpilot.socketManager.onConnect = { + didSocketConnect = true + } // Act analyticsPublisher.onSocketClosed() // Assert - // Should clear all cached properties - XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) - XCTAssertNil(analyticsPublisher.mockGetCachedEvent()) + XCTAssertFalse(didSocketConnect) } func testOnSocketEventSent_shouldUpdateUserOnIdentifyEvent() { @@ -340,13 +345,13 @@ class AnalyticsPublisherTests: XCTestCase { // Arrange userpilot.socketManager.isSocketOpened = true var didPublishEvent = false - userpilot.socketManager.onPublish = { _, _, _ in + userpilot.socketManager.onPublish = { _, _ in didPublishEvent = true } let sdkEvent = MockSDKEvent() // Act - analyticsPublisher.publishInternalSDKEvent(sdkEvent, socketSubscription: nil) + analyticsPublisher.publishInternalSDKEvent(sdkEvent) // Assert // Would need to verify socket manager publish was called @@ -357,40 +362,41 @@ class AnalyticsPublisherTests: XCTestCase { // Arrange userpilot.socketManager.isSocketOpened = false var didPublishEvent = false - userpilot.socketManager.onPublish = { _, _, _ in + userpilot.socketManager.onPublish = { _, _ in didPublishEvent = true } let sdkEvent = MockSDKEvent() // Act - analyticsPublisher.publishInternalSDKEvent(sdkEvent, socketSubscription: nil) + analyticsPublisher.publishInternalSDKEvent(sdkEvent) // Assert // Should not publish experience event when socket is closed XCTAssertFalse(didPublishEvent) } - func testPublishFakeReloadScreenEvent_shouldPublishWhenScreenEntityExists() { + func testPublishFakeReloadScreenEvent_shouldPublishWhenScreenSessionStateMachineExists() { // Arrange userpilot.socketManager.isSocketOpened = true let screenEvent = Event(type: .screen("Test Screen")) analyticsPublisher.publish(screenEvent) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) let expectation = XCTestExpectation(description: "Wait for delayed fake reload publish") var publishScreenEventCalled = false - userpilot.socketManager.onPublish = { _, _, _ in + userpilot.socketManager.onPublish = { _, _ in publishScreenEventCalled = true expectation.fulfill() } - // Act (add delay before publishing, cause of throttling logic) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + // Act (add delay before publishing, because screen events are throttled) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { self.analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10) } // Assert (wait for the delayed call) - wait(for: [expectation], timeout: 1.0) + wait(for: [expectation], timeout: 2.0) XCTAssertTrue(publishScreenEventCalled) } @@ -401,7 +407,7 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.publish(screenEvent) var publishScreenEventCalled = false - userpilot.socketManager.onPublish = { _, _, _ in publishScreenEventCalled = true } + userpilot.socketManager.onPublish = { _, _ in publishScreenEventCalled = true } // Act analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10) @@ -413,6 +419,7 @@ class AnalyticsPublisherTests: XCTestCase { func testExperiencePublished_shouldUpdateSeenExperiences() { // Arrange + userpilot.socketManager.isSocketOpened = true let screenEvent = Event(type: .screen("Test Screen")) analyticsPublisher.publish(screenEvent) @@ -420,11 +427,12 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.experiencePublished(.flow, 123) // Assert - XCTAssertTrue(analyticsPublisher.screenEntity?.seenExperiences.contains(123) ?? false) + XCTAssertTrue(analyticsPublisher.screenSessionStateMachine?.seenExperiences.contains(123) ?? false) } func testExperiencePublished_shouldUpdateSeenSurveys() { // Arrange + userpilot.socketManager.isSocketOpened = true let screenEvent = Event(type: .screen("Test Screen")) analyticsPublisher.publish(screenEvent) @@ -432,39 +440,43 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.experiencePublished(.survey, 456) // Assert - XCTAssertTrue(analyticsPublisher.screenEntity?.seenSurveys.contains(456) ?? false) + XCTAssertTrue(analyticsPublisher.screenSessionStateMachine?.seenSurveys.contains(456) ?? false) } // MARK: - Screen Event Setup Tests - func testSetupScreenEvent_shouldCreateNewScreenEntityForDifferentScreen() { + func testSetupScreenEvent_shouldCreateNewScreenSessionStateMachineForDifferentScreen() { // Arrange + userpilot.socketManager.isSocketOpened = true let firstScreenEvent = Event(type: .screen("Screen 1")) let secondScreenEvent = Event(type: .screen("Screen 2")) // Act analyticsPublisher.publish(firstScreenEvent) - let firstScreenEntity = analyticsPublisher.screenEntity + let firstScreenSessionStateMachine = analyticsPublisher.screenSessionStateMachine + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) analyticsPublisher.publish(secondScreenEvent) - let secondScreenEntity = analyticsPublisher.screenEntity + let secondScreenSessionStateMachine = analyticsPublisher.screenSessionStateMachine // Assert - XCTAssertNotEqual(firstScreenEntity?.event.screenTitle, secondScreenEntity?.event.screenTitle) - XCTAssertEqual(secondScreenEntity?.event.screenTitle, "Screen 2") + XCTAssertNotEqual(firstScreenSessionStateMachine?.event.screenTitle, secondScreenSessionStateMachine?.event.screenTitle) + XCTAssertEqual(secondScreenSessionStateMachine?.event.screenTitle, "Screen 2") } func testSetupScreenEvent_shouldRetainSeenExperiencesForSameScreen() { // Arrange + userpilot.socketManager.isSocketOpened = true let screenEvent = Event(type: .screen("Same Screen")) analyticsPublisher.publish(screenEvent) analyticsPublisher.experiencePublished(.flow, 123) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) // Act analyticsPublisher.publish(screenEvent) // Same screen again // Assert - XCTAssertTrue(analyticsPublisher.screenEntity?.seenExperiences.contains(123) ?? false) + XCTAssertTrue(analyticsPublisher.screenSessionStateMachine?.seenExperiences.contains(123) ?? false) } // MARK: - Session State Tests @@ -524,7 +536,7 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.storage.user = user.toJson() ?? "" var publishIdentifyEventCalled = false - userpilot.socketManager.onPublish = { _, _, _ in publishIdentifyEventCalled = true } + userpilot.socketManager.onPublish = { _, _ in publishIdentifyEventCalled = true } // Act analyticsPublisher.publish(identifyEvent) diff --git a/Tests/UserpilotTests/Analytics/EventTests.swift b/Tests/UserpilotTests/Analytics/EventTests.swift index 5162eaea..9bb03388 100644 --- a/Tests/UserpilotTests/Analytics/EventTests.swift +++ b/Tests/UserpilotTests/Analytics/EventTests.swift @@ -14,9 +14,10 @@ final class EventTests: XCTestCase { func testEventTypeProperties_forEvent() { let eventType = EventType.event("Button Clicked") - XCTAssertEqual(eventType.caseName, "track") XCTAssertEqual(eventType.eventName, "track") XCTAssertTrue(eventType.isEvent) + XCTAssertFalse(eventType.isAutoCaptureEvent) + XCTAssertTrue(eventType.isTrackEvent) XCTAssertFalse(eventType.isScreenEvent) XCTAssertFalse(eventType.isIdentifyEvent) XCTAssertEqual(eventType.eventTitle, "Button Clicked") @@ -27,10 +28,11 @@ final class EventTests: XCTestCase { func testEventTypeProperties_forScreen() { let eventType = EventType.screen("Home Screen") - XCTAssertEqual(eventType.caseName, "screen") XCTAssertEqual(eventType.eventName, "screen") XCTAssertTrue(eventType.isScreenEvent) XCTAssertFalse(eventType.isEvent) + XCTAssertFalse(eventType.isAutoCaptureEvent) + XCTAssertFalse(eventType.isTrackEvent) XCTAssertFalse(eventType.isIdentifyEvent) XCTAssertEqual(eventType.screenTitle, "Home Screen") XCTAssertNil(eventType.eventTitle) @@ -40,10 +42,11 @@ final class EventTests: XCTestCase { func testEventTypeProperties_forIdentify() { let eventType = EventType.identify("user-00000") - XCTAssertEqual(eventType.caseName, "identify") XCTAssertEqual(eventType.eventName, "user_identify") XCTAssertTrue(eventType.isIdentifyEvent) XCTAssertFalse(eventType.isEvent) + XCTAssertFalse(eventType.isAutoCaptureEvent) + XCTAssertFalse(eventType.isTrackEvent) XCTAssertFalse(eventType.isScreenEvent) XCTAssertEqual(eventType.userId, "user-00000") XCTAssertNil(eventType.eventTitle) @@ -53,10 +56,9 @@ final class EventTests: XCTestCase { func testEventDerivedProperties() { let event = Event(type: .event("Clicked CTA")) - XCTAssertEqual(event.caseName, "track") XCTAssertEqual(event.eventName, "track") XCTAssertEqual(event.eventTitle, "Clicked CTA") - XCTAssertTrue(event.isEvent) + XCTAssertTrue(event.isTrackEvent) XCTAssertFalse(event.isIdentifyEvent) XCTAssertNil(event.userId) XCTAssertNil(event.screenTitle) @@ -71,16 +73,17 @@ final class EventTests: XCTestCase { interactionEventName: InteractionEventType.tap.rawValue ) - XCTAssertEqual(eventType.caseName, "mobile_autocapture") XCTAssertEqual(eventType.eventName, "mobile_autocapture") XCTAssertFalse(eventType.isEvent) + XCTAssertTrue(eventType.isAutoCaptureEvent) + XCTAssertTrue(eventType.isTrackEvent) XCTAssertFalse(eventType.isScreenEvent) XCTAssertFalse(eventType.isIdentifyEvent) XCTAssertNil(eventType.eventTitle) XCTAssertNil(eventType.userId) XCTAssertNil(eventType.screenTitle) - XCTAssertEqual(event.caseName, "mobile_autocapture") XCTAssertEqual(event.eventName, "mobile_autocapture") + XCTAssertTrue(event.isTrackEvent) XCTAssertEqual(event.userpilotAnalytic, .event) XCTAssertEqual(event.interactionEventName, "tap") XCTAssertEqual(event.properties?["target"] as? String, "button") diff --git a/Tests/UserpilotTests/Analytics/ScreenViewEntityTests.swift b/Tests/UserpilotTests/Analytics/ScreenSessionStateMachineTests.swift similarity index 76% rename from Tests/UserpilotTests/Analytics/ScreenViewEntityTests.swift rename to Tests/UserpilotTests/Analytics/ScreenSessionStateMachineTests.swift index d2af9aa0..95047db0 100644 --- a/Tests/UserpilotTests/Analytics/ScreenViewEntityTests.swift +++ b/Tests/UserpilotTests/Analytics/ScreenSessionStateMachineTests.swift @@ -1,16 +1,16 @@ // -// ScreenViewEntityTests.swift +// ScreenSessionStateMachineTests.swift // Userpilot SDK // import XCTest @testable import Userpilot -final class ScreenViewEntityTests: XCTestCase { +final class ScreenSessionStateMachineTests: XCTestCase { func testInitializationStoresEventAndInitialSeenSets() { let event = Event(type: .screen("Home")) - let entity = ScreenViewEntity(event: event, seenExperiences: [1, 2], seenSurveys: [3]) + let entity = ScreenSessionStateMachine(event: event, seenExperiences: [1, 2], seenSurveys: [3]) XCTAssertEqual(entity.event.screenTitle, "Home") XCTAssertEqual(entity.seenExperiences, [1, 2]) @@ -18,7 +18,7 @@ final class ScreenViewEntityTests: XCTestCase { } func testUpdateSeenExperiencesDeduplicatesIds() { - let entity = ScreenViewEntity(event: Event(type: .screen("Home"))) + let entity = ScreenSessionStateMachine(event: Event(type: .screen("Home"))) entity.updateSeenFlowExperiences(10) entity.updateSeenFlowExperiences(10) @@ -30,7 +30,7 @@ final class ScreenViewEntityTests: XCTestCase { } func testResetStateClearsSeenSetsWithoutReplacingEvent() { - let entity = ScreenViewEntity( + let entity = ScreenSessionStateMachine( event: Event(type: .screen("Home")), seenExperiences: [1], seenSurveys: [2] diff --git a/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift similarity index 98% rename from Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift rename to Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift index f82931bd..3d5bf13e 100644 --- a/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift @@ -1,5 +1,5 @@ // -// ExperienceStateManagerTests.swift +// ExperienceStateMachineTests.swift // Userpilot SDK // // Created by Motasem Hamed on 23/11/2025. @@ -10,7 +10,7 @@ import XCTest @testable import Userpilot // swiftlint:disable all -final class ExperienceStateManagerTests: XCTestCase { +final class ExperienceStateMachineTests: XCTestCase { private var userpilot: MockUserpilot! private var logger: MockLogger! @@ -22,7 +22,7 @@ final class ExperienceStateManagerTests: XCTestCase { logger = MockLogger() config.logger = logger userpilot = MockUserpilot(config: config) - stateManager = ExperienceStateManager(container: userpilot.container) + stateManager = ExperienceStateMachine(container: userpilot.container) } override func tearDown() { diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 50d2dacc..7144dd40 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -62,7 +62,7 @@ final class ExperiencesPublisherTests: XCTestCase { var publishedEvent: SDKEvent? let expectation = XCTestExpectation(description: "Event should be published") - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event expectation.fulfill() } @@ -84,7 +84,7 @@ final class ExperiencesPublisherTests: XCTestCase { let mockVC = MockUPExperience() experiencesPublisher.mockActiveExperience(experience: mockVC) var publishedEvent: SDKEvent? - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } @@ -95,8 +95,8 @@ final class ExperiencesPublisherTests: XCTestCase { let expectation = XCTestExpectation(description: "manual trigger cached") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { XCTAssertNil(publishedEvent) - XCTAssertTrue(self.userpilot.experienceStateManager.hasCachedExperience()) - XCTAssertEqual(self.userpilot.experienceStateManager.getCachedExperienceId(), "cached-experience-id") + XCTAssertTrue(self.userpilot.experienceStateMachine.hasCachedExperience()) + XCTAssertEqual(self.userpilot.experienceStateMachine.getCachedExperienceId(), "cached-experience-id") expectation.fulfill() } wait(for: [expectation], timeout: 1.0) @@ -110,7 +110,7 @@ final class ExperiencesPublisherTests: XCTestCase { experiencesPublisher.mockActiveExperience(experience: mockVC) var publishedExperienceIds: [String] = [] - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in if let event = event as? ExperienceContentEvent { publishedExperienceIds.append(event.experienceId) replayExpectation.fulfill() @@ -222,7 +222,7 @@ final class ExperiencesPublisherTests: XCTestCase { let screenTitle = "TestScreen" // Act - experiencesPublisher.updateSceen(screenTitle) + experiencesPublisher.updateScreen(screenTitle) // Assert XCTAssertEqual(experiencesPublisher.mockGetCurrentScreen(), "TestScreen") @@ -421,7 +421,7 @@ final class ExperiencesPublisherTests: XCTestCase { let mockEvent = MockSDKEvent(eventName: "test-event", eventPayload: ["key": "value"]) var publishedEvent: SDKEvent? - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } @@ -436,8 +436,8 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldSuppressAnalytics_WhenPreviewModeIsActive() { // Arrange var publishedEvent: SDKEvent? - userpilot.experienceStateManager.markPreviewMode() - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.experienceStateMachine.markPreviewMode() + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } @@ -450,7 +450,7 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldResetPreviewMode_WhenPreviewExperienceCloses() { // Arrange - userpilot.experienceStateManager.markPreviewMode() + userpilot.experienceStateMachine.markPreviewMode() let closeEvent = MockSDKEvent( eventName: SDKEventsName.flowExperienceDismissed.rawValue, eventPayload: ["mobile_content_id": 77] @@ -460,7 +460,7 @@ final class ExperiencesPublisherTests: XCTestCase { experiencesPublisher.publishInternalSDKEvent(closeEvent) // Assert - XCTAssertFalse(userpilot.experienceStateManager.isPreviewMode()) + XCTAssertFalse(userpilot.experienceStateMachine.isPreviewMode()) } func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForCloseNPSEvent() { @@ -493,7 +493,7 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldHandleCloseEvent_withoutDeepLink() { // Arrange experiencesPublisher.mockSetCurrentScreen(title: "main_screen") - userpilot.analyticsPublisher.screenEntity = ScreenViewEntity( + userpilot.analyticsPublisher.screenSessionStateMachine = ScreenSessionStateMachine( event: Event(type: .screen("main_screen")), seenExperiences: Set(), seenSurveys: Set() @@ -505,9 +505,10 @@ final class ExperiencesPublisherTests: XCTestCase { // so watch for that as proof the debounced block ran. let reloadExpectation = XCTestExpectation(description: "debounced fake‑reload published") var publishFakeReloadEventCalled = false - userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _ in + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in publishFakeReloadEventCalled = true reloadExpectation.fulfill() + return true } // Act @@ -565,14 +566,14 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertEqual(capturedParams?.appToken, userpilot.config.token) XCTAssertEqual(capturedParams?.contentType, "survey") XCTAssertEqual(capturedParams?.contentId, "preview-123") - XCTAssertEqual(capturedParams?.baseUrl, RemoteSource.experienceBaseURL) + XCTAssertEqual(capturedParams?.baseUrl, Constants.RemoteSource.experienceBaseURL) } func testTriggerPreviewExperience_shouldEnterPreviewModeBeforeFetching() { // Arrange let expectation = XCTestExpectation(description: "preview mode entered") userpilot.remoteSource.onFetchPreviewExperience = { _, completion in - XCTAssertTrue(self.userpilot.experienceStateManager.isPreviewMode()) + XCTAssertTrue(self.userpilot.experienceStateMachine.isPreviewMode()) completion(.failure(.emptyResponse)) expectation.fulfill() } diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 0fb3fd88..30698e55 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -34,17 +34,21 @@ class MockUserpilot: Userpilot { container.register(InstanceRegistering.self, value: Userpilot.Registry.shared) container.register(AnalyticsPublishing.self, value: analyticsPublisher) container.register(DataStoring.self, value: storage) + container.register(NetworkMonitoring.self, value: networkMonitor) + container.register(OfflineEventsHandling.self, value: offlineEventsHandler) + container.register(EventStoring.self, value: eventStorage) + container.registerEager(UserSessionStateManaging.self, initializer: UserSessionStateMachine.init) container.register(SessionMonitoring.self, value: sessionMonitor) container.register(PushNotificationMonitoring.self, value: pushNotificationMonitor) - container.register(SocketEvents.self, value: socketManager) + container.register(SocketManaging.self, value: socketManager) container.register(ExperiencesPublishing.self, value: experiencesPublisher) container.register(UserpilotRemoteSourcing.self, value: remoteSource) container.register(AutoPropertyDecoratoring.self, value: autoPropertyDecorator) container.register(ThemeHandling.self, value: themeHandler) container.register(ImageLoading.self, value: imageLoader) container.register(LinkOpening.self, value: linkOpener) - experienceStateManager = ExperienceStateManager(container: container) - container.register(ExperienceStateManaging.self, value: experienceStateManager) + experienceStateMachine = ExperienceStateMachine(container: container) + container.register(ExperienceStateManaging.self, value: experienceStateMachine) // Real screen tracker + autocapture coordinator (lazy: only built when a test // resolves `autoCaptureCoordinator`). `ScreenNameTracker.init` ignores its // container, and `AutoCaptureCoordinater.init` resolves the mocks registered @@ -61,6 +65,9 @@ class MockUserpilot: Userpilot { var analyticsPublisher = MockAnalyticsPublisher() var storage = MockStorage() + var networkMonitor = MockNetworkMonitor() + var offlineEventsHandler = MockOfflineEventsHandler() + var eventStorage = MockEventStorage() var sessionMonitor = MockSessionMonitor() var pushNotificationMonitor = MockPushNotificationMonitor() var socketManager = MockSocketManager() @@ -70,7 +77,7 @@ class MockUserpilot: Userpilot { var themeHandler = MockThemeHandler() var imageLoader = MockImageLoader() var linkOpener = MockLinkOpening() - var experienceStateManager: ExperienceStateManaging! + var experienceStateMachine: ExperienceStateManaging! var mockLogger = MockLogger() } @@ -183,6 +190,11 @@ class MockExperiencesPublisher: ExperiencesPublishing { return onCanRequestScreenEvent?() ?? false } + var currentScreen = "" + var getCurrentScreen: String { + return currentScreen + } + var onTriggerDeepLink: ((URL) -> Void)? func triggerDeepLink(url: URL) { onTriggerDeepLink?(url) @@ -198,9 +210,10 @@ class MockExperiencesPublisher: ExperiencesPublishing { onShowThankYouMessage?(surveyContent, surveyTheme, submissionId) } - var onUpdateSceen: ((String) -> Void)? - func updateSceen(_ screenName: String) { - onUpdateSceen?(screenName) + var onUpdateScreen: ((String) -> Void)? + func updateScreen(_ screenName: String) { + currentScreen = screenName + onUpdateScreen?(screenName) } var onLogout: (() -> Void)? @@ -239,13 +252,13 @@ class MockLinkOpening: LinkOpening { // MARK: - Mock Socket Manager -class MockSocketManager: SocketEvents { +class MockSocketManager: SocketManaging { var isSocketOpened: Bool = false var isJoiningSocket: Bool = false - var didErrorOccurred: Bool = false + var didCloseFromError: Bool = false var isShutdownState: Bool = false @@ -266,18 +279,12 @@ class MockSocketManager: SocketEvents { onRegisterCallback?(socketSubscription) } - var onUpdateSocketState: ((SocketManager.SocketState, Bool) -> Void)? - func updateSocketState(_ socketState: SocketManager.SocketState, forceUpdateState: Bool) { - onUpdateSocketState?(socketState, forceUpdateState) - } - - var onPublish: ((String, Payload, SocketSubscription?) -> Void)? + var onPublish: ((String, Payload) -> Void)? func publish( _ eventName: String, - payload: Payload, - socketSubscription: SocketSubscription? + payload: Payload ) { - onPublish?(eventName, payload, socketSubscription) + onPublish?(eventName, payload) } } @@ -305,24 +312,25 @@ class MockAnalyticsPublisher: AnalyticsPublishing { onReset?() } - var onLogout: ((SocketManager.SocketState, Bool) -> Void)? - func logout(socketState: SocketManager.SocketState, shouldClearCachedIdentifyEvent: Bool) { - onLogout?(socketState, shouldClearCachedIdentifyEvent) + var onLogout: ((Bool) -> Void)? + func logout(clearCachedIdentifyEvent: Bool) { + onLogout?(clearCachedIdentifyEvent) } var canRequestEvent: Bool = true - var onPublishInternalSDKEvent: ((SDKEvent, SocketSubscription?) -> Void)? - func publishInternalSDKEvent( - _ sdkEvent: SDKEvent, - socketSubscription: SocketSubscription? - ) { - onPublishInternalSDKEvent?(sdkEvent, socketSubscription) + var onPublishInternalSDKEvent: ((SDKEvent) -> Void)? + func publishInternalSDKEvent(_ sdkEvent: SDKEvent) { + onPublishInternalSDKEvent?(sdkEvent) } - var onPublishFakeReloadScreenEvent: ((ExperienceType, Int?) -> Void)? - func publishFakeReloadScreenEvent(_ experienceType: ExperienceType, _ experienceId: Int?) { - onPublishFakeReloadScreenEvent?(experienceType, experienceId) + var onPublishFakeReloadScreenEvent: ((ExperienceType?, Int?, Bool) -> Bool)? + func publishFakeReloadScreenEvent( + _ experienceType: ExperienceType?, + _ experienceId: Int?, + isFakeReload: Bool + ) -> Bool { + return onPublishFakeReloadScreenEvent?(experienceType, experienceId, isFakeReload) ?? false } var onExperiencePublished: ((ExperienceType, Int) -> Void)? @@ -332,7 +340,7 @@ class MockAnalyticsPublisher: AnalyticsPublishing { var isStartSession: Bool = false - var screenEntity: ScreenViewEntity? + var screenSessionStateMachine: ScreenSessionStateMachine? } // MARK: - Mock Storage @@ -401,6 +409,94 @@ class MockPushNotificationMonitor: PushNotificationMonitoring { } } +// MARK: - Mock Network Monitor + +class MockNetworkMonitor: NetworkMonitoring { + var isNetworkAvailable: Bool = true + var connectionType: ConnectionType = .wifi + var isConnectedViaWiFi: Bool { isNetworkAvailable && connectionType == .wifi } + var isConnectedViaCellular: Bool { isNetworkAvailable && connectionType == .cellular } + var isReady: Bool = true + weak var delegate: NetworkMonitoringDelegate? + + var onStartMonitoring: (() -> Void)? + func startMonitoring() { + onStartMonitoring?() + } + + var onStopMonitoring: (() -> Void)? + func stopMonitoring() { + onStopMonitoring?() + } +} + +// MARK: - Mock Offline Events Handler + +class MockOfflineEventsHandler: OfflineEventsHandling { + var shouldSaveOffline: Bool = false + var hasCachedEvents: Bool = false + var savedEvents: [(event: Event, clearStoredEventsFirst: Bool)] = [] + var didRestoreEvents = false + var didClearLocalEvents = false + + func saveEventToLocalStorage(event: Event, clearStoredEventsFirst: Bool) { + savedEvents.append((event, clearStoredEventsFirst)) + } + + func restoreEventsFromLocalStorage(completion: (() -> Void)?) { + didRestoreEvents = true + completion?() + } + + func clearLocalEvents() { + didClearLocalEvents = true + savedEvents.removeAll() + } +} + +// MARK: - Mock Event Storage + +class MockEventStorage: EventStoring { + var events: [EventStorage] = [] + var didDeleteAllEvents = false + + func saveEvent(_ activity: EventStorage, completion: @escaping (Bool) -> Void) { + events.append(activity) + completion(true) + } + + func getAllEventsAndDelete(completion: @escaping ([EventStorage]) -> Void) { + let storedEvents = events + events.removeAll() + completion(storedEvents) + } + + func deleteEvent(_ activity: EventStorage) { + events.removeAll { $0.requestId == activity.requestId } + } + + func deleteAllEvents() { + didDeleteAllEvents = true + events.removeAll() + } + + func getStorageStats() -> DatabaseStats { + let totalSize = events.reduce(Int64(0)) { $0 + Int64($1.sizeBytes) } + return DatabaseStats( + eventCount: events.count, + totalSizeBytes: totalSize, + maxEventCount: Constants.Database.maxEventCount, + maxSizeBytes: Constants.Database.maxSizeBytes, + isCountLimitReached: events.count >= Constants.Database.maxEventCount, + isSizeLimitReached: totalSize >= Constants.Database.maxSizeBytes + ) + } + + func hasEvents() -> Bool { + !events.isEmpty + } +} + // MARK: - Mock Logger class MockLogger: Logging { diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift index 776bcdab..a136f5a1 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift @@ -21,7 +21,7 @@ final class PushNotificationDeferredTests: PushNotificationMonitorTestCase { userpilot.storage.userId = "default-00000" userpilot.analyticsPublisher.canRequestEvent = false - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { sdkEvent, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { sdkEvent in guard let event = sdkEvent as? PushNotificationOpenedEvent else { XCTFail("sdkEvent is not PushNotificationOpenedEvent") return diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift index b4746063..deec49cd 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift @@ -100,8 +100,8 @@ final class PushNotificationHandlingTests: PushNotificationMonitorTestCase { var vendorCompletionCalled = false var navigatedURL: URL? - host.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in hostEvents.append(event) } - vendor.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in vendorEvents.append(event) } + host.analyticsPublisher.onPublishInternalSDKEvent = { event in hostEvents.append(event) } + vendor.analyticsPublisher.onPublishInternalSDKEvent = { event in vendorEvents.append(event) } vendor.linkOpener.onHandleURL = { url in navigatedURL = url } @@ -148,7 +148,7 @@ final class PushNotificationHandlingTests: PushNotificationMonitorTestCase { var didPublishEvent = false var didCallCompletion = false - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { _, _ in didPublishEvent = true } + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { _ in didPublishEvent = true } // Act let result = pushNotificationMonitor.processNotificationForTesting( @@ -172,7 +172,7 @@ final class PushNotificationHandlingTests: PushNotificationMonitorTestCase { userpilot.storage.userId = "default-00000" var publishedEvent: SDKEvent? - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTests.swift index c5051174..b3512042 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTests.swift @@ -67,22 +67,19 @@ final class PushNotificationMonitorTests: PushNotificationMonitorTestCase { func testSetPushToken_tracksEvent_whenNewValidTokenIsSet() throws { // Arrange var publishedEvent: SDKEvent? - var callbackSubscription: SocketSubscription? userpilot.storage.pushToken = "" userpilot.storage.userId = "user-00000" let token = Data("token-00000".utf8) let expectedToken = token.map({ String(format: "%02x", $0) }).joined() - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, subscription in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event - callbackSubscription = subscription } // Act pushNotificationMonitor.setPushToken(token) // Assert - XCTAssertTrue(callbackSubscription === pushNotificationMonitor) let tokenEvent = try XCTUnwrap(publishedEvent as? PushNotificationTokenEvent) XCTAssertEqual(tokenEvent.eventName, SDKEventsName.pushNotificationToken.rawValue) XCTAssertEqual(tokenEvent.eventPayload["app_token"] as? String, userpilot.config.token) @@ -116,7 +113,7 @@ final class PushNotificationMonitorTests: PushNotificationMonitorTestCase { var publishedEvent: SDKEvent? userpilot.storage.userId = "user-00000" - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } diff --git a/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift b/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift index 6f1a0990..f5183420 100644 --- a/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift +++ b/Tests/UserpilotTests/Remote/UserpilotRemoteSourceTests.swift @@ -331,7 +331,7 @@ final class UserpilotRemoteSourceTests: XCTestCase { """.utf8 ), HTTPURLResponse( - url: URL(string: RemoteSource.experienceBaseURL)!, + url: URL(string: Constants.RemoteSource.experienceBaseURL)!, statusCode: 200, httpVersion: nil, headerFields: nil @@ -340,7 +340,7 @@ final class UserpilotRemoteSourceTests: XCTestCase { ) let params = PreviewExperienceQueryParams( - baseUrl: RemoteSource.experienceBaseURL, + baseUrl: Constants.RemoteSource.experienceBaseURL, appToken: userpilot.config.token, contentType: "survey", contentId: "123" @@ -369,7 +369,7 @@ final class UserpilotRemoteSourceTests: XCTestCase { URLProtocolStub.response = ( Data("invalid json".utf8), HTTPURLResponse( - url: URL(string: RemoteSource.experienceBaseURL)!, + url: URL(string: Constants.RemoteSource.experienceBaseURL)!, statusCode: 200, httpVersion: nil, headerFields: nil @@ -378,7 +378,7 @@ final class UserpilotRemoteSourceTests: XCTestCase { ) let params = PreviewExperienceQueryParams( - baseUrl: RemoteSource.experienceBaseURL, + baseUrl: Constants.RemoteSource.experienceBaseURL, appToken: userpilot.config.token, contentType: "survey", contentId: "123" @@ -409,7 +409,7 @@ final class UserpilotRemoteSourceTests: XCTestCase { URLProtocolStub.response = ( nil, HTTPURLResponse( - url: URL(string: RemoteSource.experienceBaseURL)!, + url: URL(string: Constants.RemoteSource.experienceBaseURL)!, statusCode: 404, httpVersion: nil, headerFields: nil @@ -418,7 +418,7 @@ final class UserpilotRemoteSourceTests: XCTestCase { ) let params = PreviewExperienceQueryParams( - baseUrl: RemoteSource.experienceBaseURL, + baseUrl: Constants.RemoteSource.experienceBaseURL, appToken: userpilot.config.token, contentType: "survey", contentId: "999" diff --git a/Tests/UserpilotTests/Socket/SocketManagerTests.swift b/Tests/UserpilotTests/Socket/SocketManagerTests.swift index 94e2a6ac..ea1c3258 100644 --- a/Tests/UserpilotTests/Socket/SocketManagerTests.swift +++ b/Tests/UserpilotTests/Socket/SocketManagerTests.swift @@ -9,344 +9,412 @@ import XCTest @testable import Userpilot +// swiftlint:disable all final class SocketManagerTests: XCTestCase { - var socketManager: SocketManager! - var userpilot: MockUserpilot! - var mockSocketSubscription: MockSocketSubscription! + private var userpilot: MockUserpilot! + private var socketManager: SocketManager! + private var subscription: RecordingSocketSubscription! + private var transports: [FakePhoenixTransport]! + private var socketEndpoints: [String]! + private var socketParams: [SwiftPhoenixClientPayload?]! - private let socketUrl = "<#SOCKET_URL#>" - private let appToken = "<#APP_TOKEN#>" - private let userId = "<#USER_ID#>" - - override func setUpWithError() throws { + override func setUp() { super.setUp() - - if socketUrl == "<#SOCKET_URL#>" || appToken == "<#APP_TOKEN#>" || userId == "<#USER_ID#>" { - throw XCTSkip("configuration not configured - add your credentials and run test again") - } - - let config = Userpilot.Config(token: appToken) - userpilot = MockUserpilot(config: config) - userpilot.storage.socketURL = socketUrl - userpilot.storage.userId = userId + userpilot = MockUserpilot(config: Userpilot.Config(token: "NX-test-token")) + userpilot.storage.socketURL = "https://socket.example.com/mobile/v1/events/websocket" + userpilot.storage.userId = "user-1" + userpilot.sessionMonitor.isAppActive = true + subscription = RecordingSocketSubscription() + transports = [] + socketEndpoints = [] + socketParams = [] + socketManager = makeSocketManager() } override func tearDown() { - socketManager.close() + socketManager?.close() socketManager = nil + subscription = nil + transports = nil + socketEndpoints = nil + socketParams = nil userpilot = nil - mockSocketSubscription = nil super.tearDown() } - // MARK: - Socket State Tests - - func testInitialState_shouldBeClosed() { - // Assert + func testInitialStateIsClosed() { XCTAssertFalse(socketManager.isSocketOpened) XCTAssertFalse(socketManager.isJoiningSocket) - XCTAssertFalse(socketManager.didErrorOccurred) + XCTAssertFalse(socketManager.didCloseFromError) XCTAssertFalse(socketManager.isShutdownState) - XCTAssertFalse(socketManager.isSocketConnectedWithUnknownChannel) } - func testUpdateSocketState_shouldUpdateState() { - // Act - socketManager.updateSocketState(.connecting) + func testConnectWithEmptyTokenDoesNotFetchSettingsOrCreateSocket() { + userpilot = MockUserpilot(config: Userpilot.Config(token: "")) + userpilot.storage.socketURL = "https://socket.example.com/mobile/v1/events/websocket" + userpilot.storage.userId = "user-1" + userpilot.remoteSource.onFetchSettings = { _ in + XCTFail("SocketManager should not fetch settings when token is empty") + } + socketManager = makeSocketManager() + + socketManager.connect() - // Assert - XCTAssertTrue(socketManager.isJoiningSocket) + XCTAssertTrue(transports.isEmpty) + XCTAssertFalse(socketManager.isJoiningSocket) XCTAssertFalse(socketManager.isSocketOpened) } - func testUpdateSocketState_withError_shouldUpdateToError() { - // Act - socketManager.updateSocketState(.error) + func testConnectWithEmptyUserIdDoesNotFetchSettingsOrCreateSocket() { + userpilot.storage.userId = "" + userpilot.remoteSource.onFetchSettings = { _ in + XCTFail("SocketManager should not fetch settings when user id is empty") + } + socketManager = makeSocketManager() - // Assert - XCTAssertTrue(socketManager.didErrorOccurred) + socketManager.connect() + + XCTAssertTrue(transports.isEmpty) + XCTAssertFalse(socketManager.isJoiningSocket) XCTAssertFalse(socketManager.isSocketOpened) } - func testUpdateSocketState_withShuttingDown_shouldUpdateToShuttingDown() { - // Act - socketManager.updateSocketState(.shuttingDown) + func testConnectFetchesSettingsOnlyOnceWhileFetchIsInFlight() { + var fetchCount = 0 + var completions = [(Result) -> Void]() + userpilot.remoteSource.onFetchSettings = { completion in + fetchCount += 1 + completions.append(completion) + } + socketManager = makeSocketManager() - // Assert - XCTAssertTrue(socketManager.isShutdownState) - XCTAssertFalse(socketManager.isSocketOpened) - } + socketManager.connect() + socketManager.connect() - func testUpdateSocketState_withForceUpdate_shouldAlwaysUpdate() { - // Arrange - socketManager.updateSocketState(.error) + XCTAssertEqual(fetchCount, 1) + XCTAssertTrue(transports.isEmpty) - // Act - socketManager.updateSocketState(.opened, forceUpdateState: true) + completions.first?(.success(())) - // Assert - XCTAssertFalse(socketManager.didErrorOccurred) + XCTAssertEqual(transports.count, 1) } - // MARK: - Connection Tests - - func testConnect_withValidConfiguration_shouldInitiateConnection() { - // Arrange - let expectation = XCTestExpectation(description: "Socket connection initiated") + func testConnectAfterSettingsFailureCanRetry() { + var fetchCount = 0 + userpilot.remoteSource.onFetchSettings = { completion in + fetchCount += 1 + completion(.failure(.networkError("offline"))) + } + socketManager = makeSocketManager() - // Act + socketManager.connect() socketManager.connect() - // Assert - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertTrue(self.socketManager.isJoiningSocket) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(fetchCount, 2) + XCTAssertTrue(transports.isEmpty) } - func testConnect_withEmptyToken_shouldNotConnect() { - // Arrange - let config = Userpilot.Config(token: "") - let userpilot = MockUserpilot(config: config) - socketManager = SocketManager(container: userpilot.container) + func testSuccessfulJoinOpensSocketAndNotifiesSubscribers() throws { + socketManager.registerCallback(subscription) - // Act - socketManager.connect() + let transport = try openAndJoinSocket() - // Assert + XCTAssertTrue(socketManager.isSocketOpened) XCTAssertFalse(socketManager.isJoiningSocket) - XCTAssertFalse(socketManager.isSocketOpened) + XCTAssertEqual(subscription.openCount, 1) + XCTAssertEqual(transport.connectCallCount, 1) + XCTAssertEqual(socketEndpoints.last, userpilot.storage.socketURL) + XCTAssertEqual(socketParams.last??[Constants.Socket.tokenKey] as? String, "NX-test-token") + XCTAssertEqual(socketParams.last??[Constants.Socket.userIdKey] as? String, "user-1") + XCTAssertNotNil(socketParams.last??[Constants.Socket.autoPropertiesKey] as? String) + XCTAssertNotNil(socketParams.last??[Constants.Socket.appPropertiesKey] as? String) } - func testConnect_withEmptyUserId_shouldNotConnect() { - // Arrange - userpilot.storage.userId = "" + func testIncomingMessagesIgnoresInvalidAndPublishesValidMessages() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() - // Act - socketManager.connect() + transport.receive( + topic: "phoenix", + event: "server_event", + payload: ["request_type": "ignored"] + ) + XCTAssertTrue(subscription.messages.isEmpty) - // Assert - XCTAssertFalse(socketManager.isJoiningSocket) - XCTAssertFalse(socketManager.isSocketOpened) - } + transport.receive( + topic: Constants.Socket.channelTopic, + event: "server_event", + payload: ["request_type": "valid_message"] + ) - func testClose_shouldCloseSocket() { - // Arrange - socketManager.connect() + XCTAssertEqual(subscription.messages.count, 1) + XCTAssertEqual(subscription.messages.last?.event, "server_event") + XCTAssertEqual(subscription.messages.last?.resolvedEvent, "valid_message") + } - // Act - socketManager.close() + func testPublishSuccessNotifiesSubscribersWithResolvedEvent() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() - // Assert with delay - let expectation = self.expectation(description: "Wait for socket to close") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } + socketManager.publish("track", payload: ["metadata": ["name": "Button clicked"]]) + let push = try XCTUnwrap(transport.lastSentPush(event: "track")) - waitForExpectations(timeout: 2.0) + transport.reply( + to: push, + status: Constants.Socket.successKey, + response: ["request_type": "resolved_track"] + ) - XCTAssertFalse(socketManager.isSocketOpened) - XCTAssertFalse(socketManager.isJoiningSocket) + let sentEvent = try XCTUnwrap(subscription.sentEvents.last) + XCTAssertEqual(sentEvent.event, "resolved_track") + XCTAssertTrue(sentEvent.status) + XCTAssertEqual(sentEvent.payload?["metadata"] as? [String: String], ["name": "Button clicked"]) + XCTAssertEqual(sentEvent.message.resolvedEvent, "resolved_track") } - // MARK: - Socket Subscription Tests + func testPublishErrorNotifiesSubscribersWithOriginalEventName() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() - func testRegisterCallback_shouldRegisterSocketSubscription() { - // Act - socketManager.registerCallback(mockSocketSubscription) + socketManager.publish("track", payload: ["metadata": ["name": "Button clicked"]]) + let push = try XCTUnwrap(transport.lastSentPush(event: "track")) - // Assert - This is implicit since we can't directly test the multicast registration - // but we can verify it works through other tests - XCTAssertTrue(true) // Placeholder assertion - } + transport.reply(to: push, status: Constants.Socket.errorKey) - func testSocketSubscription_onSocketOpened_shouldNotifySubscribers() { - // Arrange - let expectation = XCTestExpectation(description: "Socket opened notification") - mockSocketSubscription.onSocketOpenedCalled = { - expectation.fulfill() - } + let sentEvent = try XCTUnwrap(subscription.sentEvents.last) + XCTAssertEqual(sentEvent.event, "track") + XCTAssertFalse(sentEvent.status) + } - socketManager.registerCallback(mockSocketSubscription) + func testPublishTimeoutNotifiesSubscribersWithFailure() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() - // Act - socketManager.connect() + socketManager.publish("track", payload: ["metadata": ["name": "Button clicked"]]) + let push = try XCTUnwrap(transport.lastSentPush(event: "track")) - // Simulate socket opened (this would normally be handled by Phoenix Socket) - // Note: In a real test, you might need to mock the Phoenix Socket behavior + transport.reply(to: push, status: Constants.Socket.timeoutKey) - wait(for: [expectation], timeout: 2.0) + let sentEvent = try XCTUnwrap(subscription.sentEvents.last) + XCTAssertEqual(sentEvent.event, "track") + XCTAssertFalse(sentEvent.status) } - func testSocketSubscription_onSocketClosed_shouldNotifySubscribers() { - // Arrange - let expectation = XCTestExpectation(description: "Socket closed notification") - mockSocketSubscription.onSocketClosedCalled = { - expectation.fulfill() - } + func testPublishResolutionIsSuppressedWhenAppIsInactive() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() + userpilot.sessionMonitor.isAppActive = false - socketManager.registerCallback(mockSocketSubscription) - socketManager.connect() + socketManager.publish("track", payload: ["metadata": ["name": "Button clicked"]]) + let push = try XCTUnwrap(transport.lastSentPush(event: "track")) - // Act - socketManager.close() + transport.reply( + to: push, + status: Constants.Socket.successKey, + response: ["request_type": "resolved_track"] + ) - wait(for: [expectation], timeout: 2.0) + XCTAssertTrue(subscription.sentEvents.isEmpty) } - func testPublish_withValidEvent_shouldSendEvent() { - // Arrange - let expectation = XCTestExpectation(description: "Event published") - let eventName = "user_identify" - let payload: [String: Any] = ["metadata": [:], "company": [:]] - - mockSocketSubscription.onSocketEventSentCalled = { event, _, _, status in - XCTAssertEqual(event, eventName) - XCTAssertTrue(status) - expectation.fulfill() - } + func testPublishResolutionIsSuppressedDuringShutdown() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() - socketManager.registerCallback(mockSocketSubscription) - socketManager.connect() + socketManager.publish("track", payload: ["metadata": ["name": "Button clicked"]]) + let push = try XCTUnwrap(transport.lastSentPush(event: "track")) - // Delay to allow connect setup before publishing - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - // Act - self.socketManager.publish(eventName, payload: payload, socketSubscription: nil) - } + socketManager.close() + transport.reply( + to: push, + status: Constants.Socket.successKey, + response: ["request_type": "resolved_track"] + ) - // Assert - wait(for: [expectation], timeout: 4.0) + XCTAssertTrue(subscription.sentEvents.isEmpty) } - func testPublish_withShouldCloseSocket_shouldCloseAfterSending() { - // Arrange - let expectation = XCTestExpectation(description: "Socket closed after event") - let eventName = "user_identify" - let payload: [String: Any] = ["metadata": [:], "company": [:]] - - mockSocketSubscription.onSocketEventSentCalled = { _, _, _, _ in - // Wait a bit for the socket to close - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - XCTAssertFalse(self.socketManager.isSocketOpened) - expectation.fulfill() - } - } + func testCloseDisconnectsTransportAndNotifiesSubscribers() throws { + socketManager.registerCallback(subscription) + let transport = try openAndJoinSocket() + let expectation = expectation(description: "Socket closed") + subscription.onClose = { expectation.fulfill() } - socketManager.registerCallback(mockSocketSubscription) - socketManager.connect() - - // Act - // Delay to allow connect setup before publishing - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - // Act - self.socketManager.publish(eventName, payload: payload, socketSubscription: nil) - } + socketManager.close() - // Assert - wait(for: [expectation], timeout: 5.0) + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(subscription.closeCount, 1) + XCTAssertEqual(transport.disconnectCallCount, 1) + XCTAssertFalse(socketManager.isSocketOpened) } - func testPublish_withSpecificSubscription_shouldNotifySpecificSubscription() { - // Arrange - let expectation = XCTestExpectation(description: "Specific subscription notified") - let eventName = "user_identify" - let payload: [String: Any] = ["metadata": [:], "company": [:]] - - let specificSubscription = MockSocketSubscription() - specificSubscription.onSocketEventSentCalled = { event, _, _, _ in - XCTAssertEqual(event, eventName) - expectation.fulfill() + private func makeSocketManager() -> SocketManager { + return SocketManager(container: userpilot.container) { [weak self] endpoint, params in + let transport = FakePhoenixTransport() + self?.transports.append(transport) + self?.socketEndpoints.append(endpoint) + self?.socketParams.append(params) + let socket = Socket( + endPoint: endpoint, + transport: { _ in transport }, + paramsClosure: { params } + ) + socket.timeout = 60.0 + socket.heartbeatInterval = 60.0 + return socket } + } + private func openAndJoinSocket( + file: StaticString = #filePath, + line: UInt = #line + ) throws -> FakePhoenixTransport { socketManager.connect() - - // Act - socketManager.publish( - eventName, - payload: payload, - socketSubscription: specificSubscription + let transport = try XCTUnwrap(transports.last, file: file, line: line) + transport.open() + let joinPush = try XCTUnwrap( + transport.lastSentPush(event: ChannelEvent.join), + "Expected channel join push", + file: file, + line: line ) - - wait(for: [expectation], timeout: 2.0) + transport.reply(to: joinPush, status: Constants.Socket.successKey) + return transport } +} - // MARK: - Edge Cases +private final class RecordingSocketSubscription: SocketSubscription { + struct SentEvent { + let event: String + let payload: Payload + let message: Message + let status: Bool + } - func testConnect_whenAlreadyConnecting_shouldNotCreateDuplicateConnection() { - // Arrange - socketManager.connect() - XCTAssertTrue(socketManager.isJoiningSocket) + var openCount = 0 + var closeCount = 0 + var sentEvents = [SentEvent]() + var messages = [Message]() + var onClose: (() -> Void)? - // Act - socketManager.connect() + func onSocketOpened() { + openCount += 1 + } - // Assert - // This test ensures we don't create multiple connections - XCTAssertTrue(socketManager.isJoiningSocket) + func onSocketClosed() { + closeCount += 1 + onClose?() } - func testPublish_whenSocketNotConnected_shouldNotCrash() { - // Arrange - let eventName = "test_event" - let payload: [String: Any] = ["key": "value"] + func onSocketEventSent( + _ event: String, + _ payload: Payload, + _ message: Message, + _ status: Bool + ) { + sentEvents.append(SentEvent( + event: event, + payload: payload, + message: message, + status: status + )) + } - // Act & Assert (should not crash) - XCTAssertNoThrow( - socketManager.publish(eventName, payload: payload, socketSubscription: nil) - ) + func onNewMessage(_ message: Message) { + messages.append(message) } +} - func testUpdateSocketState_withSameState_shouldNotUpdateState() { - // Arrange - socketManager.updateSocketState(.connecting) +private final class FakePhoenixTransport: PhoenixTransport { + private(set) var readyState: PhoenixTransportReadyState = .closed + var delegate: PhoenixTransportDelegate? - // Act - socketManager.updateSocketState(.connecting) + private(set) var connectCallCount = 0 + private(set) var disconnectCallCount = 0 + private(set) var sentPushes = [SentPush]() - // Assert - XCTAssertTrue(socketManager.isJoiningSocket) + func connect(with headers: [String: Any]) { + connectCallCount += 1 + readyState = .connecting } - func testUpdateSocketState_withErrorState_shouldNotAllowOtherUpdates() { - // Arrange - socketManager.updateSocketState(.error) - - // Act - socketManager.updateSocketState(.opened) + func disconnect(code: Int, reason: String?) { + disconnectCallCount += 1 + readyState = .closed + } - // Assert - XCTAssertTrue(socketManager.didErrorOccurred) - XCTAssertFalse(socketManager.isSocketOpened) + func send(data: Data) { + guard + let rawPush = try? JSONSerialization.jsonObject(with: data) as? [Any?], + let push = SentPush(rawPush: rawPush) + else { return } + sentPushes.append(push) } -} -// MARK: - Additional Helper Extensions for Testing + func open() { + readyState = .open + delegate?.onOpen(response: nil) + } -extension SocketManagerTests { + func receive( + joinRef: String? = nil, + ref: String = "", + topic: String, + event: String, + payload: SwiftPhoenixClientPayload + ) { + let message: [Any?] = [joinRef, ref, topic, event, payload] + guard + let data = try? JSONSerialization.data(withJSONObject: message), + let rawMessage = String(data: data, encoding: .utf8) + else { return } + delegate?.onMessage(message: rawMessage) + } - func createMockMessage(event: String = "test", payload: [String: Any] = [:]) -> Message { - // Create a mock message for testing - // Note: You may need to adjust this based on your Message structure - return Message( - ref: "", - topic: "events:*", - event: event, - payload: payload, - joinRef: nil + func reply( + to push: SentPush, + status: String, + response: SwiftPhoenixClientPayload = [:] + ) { + receive( + joinRef: push.joinRef, + ref: push.ref, + topic: push.topic, + event: ChannelEvent.reply, + payload: [ + "status": status, + "response": response + ] ) } - func waitForSocketConnection(timeout: TimeInterval = 2.0) { - let expectation = XCTestExpectation(description: "Wait for socket connection") - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } + func lastSentPush(event: String) -> SentPush? { + return sentPushes.last { $0.event == event } + } +} - wait(for: [expectation], timeout: timeout) +private struct SentPush { + let joinRef: String? + let ref: String + let topic: String + let event: String + let payload: SwiftPhoenixClientPayload + + init?(rawPush: [Any?]) { + guard + rawPush.count == 5, + let ref = rawPush[1] as? String, + let topic = rawPush[2] as? String, + let event = rawPush[3] as? String, + let payload = rawPush[4] as? SwiftPhoenixClientPayload + else { return nil } + + self.joinRef = rawPush[0] as? String + self.ref = ref + self.topic = topic + self.event = event + self.payload = payload } } +// swiftlint:enable all diff --git a/Tests/UserpilotTests/UserpilotTests.swift b/Tests/UserpilotTests/UserpilotTests.swift index f5b52990..da2a53ca 100644 --- a/Tests/UserpilotTests/UserpilotTests.swift +++ b/Tests/UserpilotTests/UserpilotTests.swift @@ -226,7 +226,7 @@ class UserpilotTests: XCTestCase { func testLogout_resetsUserAndEmitsLogoutEvent() throws { // Arrange var logoutCalled = false - userpilot.analyticsPublisher.onLogout = { _, _ in logoutCalled = true } + userpilot.analyticsPublisher.onLogout = { _ in logoutCalled = true } // Act userpilot.logout() @@ -354,9 +354,9 @@ class UserpilotTests: XCTestCase { weak var weakDataStoring: DataStoring? weak var weakSessionMonitoring: SessionMonitoring? weak var weakAutoPropertyDecoration: AutoPropertyDecoratoring? - weak var weakSocketManager: SocketEvents? + weak var weakSocketManager: SocketManaging? weak var weakRemoteSource: UserpilotRemoteSourcing? - weak var weakExperienceStateManager: ExperienceStateManaging? + weak var weakExperienceStateMachine: ExperienceStateManaging? weak var weakLinkOpener: LinkOpening? weak var weakThemeHandler: ThemeHandling? weak var weakImageLoader: ImageLoading? @@ -374,9 +374,9 @@ class UserpilotTests: XCTestCase { weakDataStoring = userpilot.container.resolve(DataStoring.self) weakSessionMonitoring = userpilot.container.resolve(SessionMonitoring.self) weakAutoPropertyDecoration = userpilot.container.resolve(AutoPropertyDecoratoring.self) - weakSocketManager = userpilot.container.resolve(SocketEvents.self) + weakSocketManager = userpilot.container.resolve(SocketManaging.self) weakRemoteSource = userpilot.container.resolve(UserpilotRemoteSourcing.self) - weakExperienceStateManager = userpilot.container.resolve(ExperienceStateManaging.self) + weakExperienceStateMachine = userpilot.container.resolve(ExperienceStateManaging.self) weakLinkOpener = userpilot.container.resolve(LinkOpening.self) weakThemeHandler = userpilot.container.resolve(ThemeHandling.self) weakImageLoader = userpilot.container.resolve(ImageLoading.self) @@ -399,7 +399,7 @@ class UserpilotTests: XCTestCase { XCTAssertNil(weakAutoPropertyDecoration) XCTAssertNil(weakSocketManager) XCTAssertNil(weakRemoteSource) - XCTAssertNil(weakExperienceStateManager) + XCTAssertNil(weakExperienceStateMachine) XCTAssertNil(weakLinkOpener) XCTAssertNil(weakThemeHandler) XCTAssertNil(weakImageLoader) diff --git a/Tests/UserpilotTests/Utilities/Helper/ConstantsTests.swift b/Tests/UserpilotTests/Utilities/Helper/ConstantsTests.swift index 47f28068..67fc92d2 100644 --- a/Tests/UserpilotTests/Utilities/Helper/ConstantsTests.swift +++ b/Tests/UserpilotTests/Utilities/Helper/ConstantsTests.swift @@ -12,16 +12,16 @@ import XCTest final class ConstantsTests: XCTestCase { func testDispatchQueueConstants() { - XCTAssertEqual(DispatchQueueConstants.EVENT_QUEUE, "com.userpilot.event-queue") - XCTAssertEqual(DispatchQueueConstants.EXPERIENCE_QUEUE, "com.userpilot.experience-queue") - XCTAssertEqual(DispatchQueueConstants.DI_CONTAINER_QUEUE, "com.userpilot.dicontainer-queue") - XCTAssertEqual(DispatchQueueConstants.THROTTLE_QUEUE, "com.userpilot.throttle-queue") + XCTAssertEqual(Constants.DispatchQueues.eventQueue, "com.userpilot.event-queue") + XCTAssertEqual(Constants.DispatchQueues.experienceQueue, "com.userpilot.experience-queue") + XCTAssertEqual(Constants.DispatchQueues.diContainerQueue, "com.userpilot.dicontainer-queue") + XCTAssertEqual(Constants.DispatchQueues.throttleQueue, "com.userpilot.throttle-queue") } func testGeneralConstants() { - XCTAssertEqual(GeneralConstants.PATH_NAME, "/mobile/v1/events/websocket") - XCTAssertEqual(GeneralConstants.SESSION_DURATION, 1800, accuracy: 0.1) - XCTAssertEqual(GeneralConstants.CONFIGURATION_DURATION, 1800, accuracy: 0.1) + XCTAssertEqual(Constants.RemoteSource.socketPath, "/mobile/v1/events/websocket") + XCTAssertEqual(Constants.Analytics.sessionDuration, 1800, accuracy: 0.1) + XCTAssertEqual(Constants.RemoteSource.configurationDuration, 1800, accuracy: 0.1) } func testUserpilotLoggingConstants() { diff --git a/Tests/UserpilotTests/Utilities/Threading/AtomicReferenceTests.swift b/Tests/UserpilotTests/Utilities/Threading/AtomicReferenceTests.swift new file mode 100644 index 00000000..2f3e2586 --- /dev/null +++ b/Tests/UserpilotTests/Utilities/Threading/AtomicReferenceTests.swift @@ -0,0 +1,209 @@ +// +// AtomicReferenceTests.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 23/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// + +import XCTest + +@testable import Userpilot + +class AtomicReferenceTests: XCTestCase { + + // MARK: - Basic Operations Tests + + func testInitialValue() { + let atomicInt = AtomicReference(42) + XCTAssertEqual(atomicInt.value, 42) + + let atomicString = AtomicReference("hello") + XCTAssertEqual(atomicString.value, "hello") + } + + func testGetAndSet() { + let atomic = AtomicReference("initial") + XCTAssertEqual(atomic.value, "initial") + + atomic.value = "updated" + XCTAssertEqual(atomic.value, "updated") + } + + func testGetAndSetWithReturnValue() { + let atomic = AtomicReference(10) + + let oldValue = atomic.getAndSet(20) + XCTAssertEqual(oldValue, 10) + XCTAssertEqual(atomic.value, 20) + } + + func testCompareAndSet_Success() { + let atomic = AtomicReference("expected") + + let success = atomic.compareAndSet(expected: "expected", new: "new") + XCTAssertTrue(success) + XCTAssertEqual(atomic.value, "new") + } + + func testCompareAndSet_Failure() { + let atomic = AtomicReference("actual") + + let success = atomic.compareAndSet(expected: "different", new: "new") + XCTAssertFalse(success) + XCTAssertEqual(atomic.value, "actual") + } + + func testUpdate() { + let atomic = AtomicReference(5) + + let newValue = atomic.update { $0 * 2 } + XCTAssertEqual(newValue, 10) + XCTAssertEqual(atomic.value, 10) + } + + func testRead() { + let atomic = AtomicReference("test") + + let uppercased = atomic.read { $0.uppercased() } + XCTAssertEqual(uppercased, "TEST") + XCTAssertEqual(atomic.value, "test") + } + + // MARK: - Thread Safety Tests + + func testThreadSafety_ConcurrentWrites() { + let atomic = AtomicReference(0) + let iterations = 1000 + let expectation = self.expectation(description: "All threads complete") + let group = DispatchGroup() + + for _ in 0..(0) + let writeIterations = 500 + let readIterations = 500 + let expectation = self.expectation(description: "All operations complete") + let group = DispatchGroup() + + for _ in 0..(0) + let iterations = 100 + let expectation = self.expectation(description: "All CAS operations complete") + let group = DispatchGroup() + var successCount = 0 + let lock = NSLock() + + for _ in 0..(Counter(count: 0, name: "test")) + + atomic.update { counter in + var updated = counter + updated.count += 1 + return updated + } + + let result = atomic.read { $0 } + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.name, "test") + } + + func testWithOptionalValue() { + let atomic = AtomicReference(nil) + XCTAssertNil(atomic.value) + + atomic.value = "not nil" + XCTAssertEqual(atomic.value, "not nil") + + atomic.value = nil + XCTAssertNil(atomic.value) + } + + func testWithArrayValue() { + let atomic = AtomicReference<[Int]>([1, 2, 3]) + + atomic.update { array in + var newArray = array + newArray.append(4) + return newArray + } + + XCTAssertEqual(atomic.value, [1, 2, 3, 4]) + } +} diff --git a/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift b/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift index b91506cc..abc17b2b 100644 --- a/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift +++ b/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift @@ -58,7 +58,7 @@ final class DelayUtilsTests: XCTestCase { self?.delayUtils.cancelDelay() // Wait for cancel to complete (since it's async) - DispatchQueue(label: DispatchQueueConstants.DELAY_QUEUE).asyncAfter(deadline: .now() + 0.01) { + DispatchQueue(label: Constants.DispatchQueues.delayQueue).asyncAfter(deadline: .now() + 0.01) { DispatchQueue.main.async { cancelExpectation.fulfill() } diff --git a/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift b/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift new file mode 100644 index 00000000..7a2f17e1 --- /dev/null +++ b/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift @@ -0,0 +1,374 @@ +// +// EventQueueTests.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 13/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// + +import XCTest + +@testable import Userpilot + +// swiftlint:disable all +class EventQueueTests: XCTestCase { + + var eventQueue: EventQueue! + + override func setUp() { + super.setUp() + eventQueue = EventQueue() + } + + override func tearDown() { + eventQueue = nil + super.tearDown() + } + + // MARK: - Helper Methods + + private func createEvent(name: String) -> Event { + return Event(type: .event(name)) + } + + private func createScreenEvent(name: String) -> Event { + return Event(type: .screen(name)) + } + + private func createIdentifyEvent(userId: String) -> Event { + return Event(type: .identify(userId)) + } + + // MARK: - Enqueue Tests + + func testEnqueue_addsEventToQueue() { + let event = createEvent(name: "test_event") + + eventQueue.enqueue(event) + + XCTAssertEqual(eventQueue.size(), 1) + } + + func testEnqueue_multipleEvents_addsAllToQueue() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + eventQueue.enqueue(createEvent(name: "event_3")) + + XCTAssertEqual(eventQueue.size(), 3) + } + + // MARK: - Dequeue Tests + + func testDequeue_whenQueueIsEmpty_returnsNil() { + XCTAssertNil(eventQueue.dequeue()) + } + + func testDequeue_whenQueueHasOneEvent_returnsEventAndRemovesIt() { + eventQueue.enqueue(createEvent(name: "test_event")) + + let dequeuedEvent = eventQueue.dequeue() + + XCTAssertNotNil(dequeuedEvent) + XCTAssertEqual(dequeuedEvent?.eventTitle, "test_event") + XCTAssertEqual(eventQueue.size(), 0) + } + + func testDequeue_maintainsFIFOOrder() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + eventQueue.enqueue(createEvent(name: "event_3")) + + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "event_1") + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "event_2") + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "event_3") + XCTAssertNil(eventQueue.dequeue()) + } + + // MARK: - Peek Tests + + func testPeek_whenQueueIsEmpty_returnsNil() { + XCTAssertNil(eventQueue.peek()) + } + + func testPeek_whenQueueHasEvents_returnsFirstEventWithoutRemoving() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + + let peekedEvent = eventQueue.peek() + + XCTAssertNotNil(peekedEvent) + XCTAssertEqual(peekedEvent?.eventTitle, "event_1") + XCTAssertEqual(eventQueue.size(), 2) + } + + func testPeek_multipleCalls_returnsSameEvent() { + eventQueue.enqueue(createEvent(name: "test_event")) + + XCTAssertEqual(eventQueue.peek()?.eventTitle, "test_event") + XCTAssertEqual(eventQueue.peek()?.eventTitle, "test_event") + XCTAssertEqual(eventQueue.peek()?.eventTitle, "test_event") + XCTAssertEqual(eventQueue.size(), 1) + } + + // MARK: - Size Tests + + func testSize_whenQueueIsEmpty_returnsZero() { + XCTAssertEqual(eventQueue.size(), 0) + } + + func testSize_returnsCorrectCount() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + eventQueue.enqueue(createEvent(name: "event_3")) + + XCTAssertEqual(eventQueue.size(), 3) + } + + // MARK: - IsEmpty Tests + + func testIsEmpty_whenQueueIsEmpty_returnsTrue() { + XCTAssertTrue(eventQueue.isEmpty()) + } + + func testIsEmpty_whenQueueHasEvents_returnsFalse() { + eventQueue.enqueue(createEvent(name: "test_event")) + + XCTAssertFalse(eventQueue.isEmpty()) + } + + func testIsEmpty_afterClear_returnsTrue() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + + eventQueue.clear() + + XCTAssertTrue(eventQueue.isEmpty()) + } + + // MARK: - Clear Tests + + func testClear_removesAllEvents() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + eventQueue.enqueue(createEvent(name: "event_3")) + + eventQueue.clear() + + XCTAssertEqual(eventQueue.size(), 0) + XCTAssertTrue(eventQueue.isEmpty()) + } + + func testClear_onEmptyQueue_doesNothing() { + eventQueue.clear() + + XCTAssertEqual(eventQueue.size(), 0) + XCTAssertTrue(eventQueue.isEmpty()) + } + + // MARK: - DeleteFirst Tests + + func testDeleteFirst_removesFirstEvent() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + + eventQueue.deleteFirst() + + XCTAssertEqual(eventQueue.size(), 1) + XCTAssertEqual(eventQueue.peek()?.eventTitle, "event_2") + } + + func testDeleteFirst_onEmptyQueue_doesNothing() { + eventQueue.deleteFirst() + + XCTAssertEqual(eventQueue.size(), 0) + XCTAssertTrue(eventQueue.isEmpty()) + } + + // MARK: - GetAll Tests + + func testGetAll_whenQueueIsEmpty_returnsEmptyArray() { + XCTAssertTrue(eventQueue.getAll().isEmpty) + } + + func testGetAll_returnsAllEventsInOrder() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + eventQueue.enqueue(createEvent(name: "event_3")) + + let events = eventQueue.getAll() + + XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0].eventTitle, "event_1") + XCTAssertEqual(events[1].eventTitle, "event_2") + XCTAssertEqual(events[2].eventTitle, "event_3") + } + + func testGetAll_doesNotModifyQueue() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + + let events = eventQueue.getAll() + + XCTAssertEqual(events.count, 2) + XCTAssertEqual(eventQueue.size(), 2) + } + + // MARK: - GetAndClear Tests + + func testGetAndClear_returnsAllEventsAndClearsQueue() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + + let events = eventQueue.getAndClear() + + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].eventTitle, "event_1") + XCTAssertEqual(events[1].eventTitle, "event_2") + XCTAssertTrue(eventQueue.isEmpty()) + } + + func testGetAndClear_whenQueueIsEmpty_returnsEmptyArray() { + let events = eventQueue.getAndClear() + + XCTAssertTrue(events.isEmpty) + XCTAssertTrue(eventQueue.isEmpty()) + } + + // MARK: - Contains Tests + + func testContains_whenEventExists_returnsTrue() { + let event1 = createEvent(name: "event_1") + let event2 = createEvent(name: "event_2") + eventQueue.enqueue(event1) + eventQueue.enqueue(event2) + + let contains = eventQueue.contains(event1) { eventOne, eventTwo in + eventOne.eventTitle == eventTwo.eventTitle + } + + XCTAssertTrue(contains) + } + + func testContains_whenEventDoesNotExist_returnsFalse() { + let event1 = createEvent(name: "event_1") + let event2 = createEvent(name: "event_2") + eventQueue.enqueue(event1) + + let contains = eventQueue.contains(event2) { eventOne, eventTwo in + eventOne.eventTitle == eventTwo.eventTitle + } + + XCTAssertFalse(contains) + } + + // MARK: - Find Tests + + func testFind_whenEventExists_returnsEvent() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + eventQueue.enqueue(createEvent(name: "event_3")) + + let foundEvent = eventQueue.find { event in + event.eventTitle == "event_2" + } + + XCTAssertNotNil(foundEvent) + XCTAssertEqual(foundEvent?.eventTitle, "event_2") + } + + func testFind_whenEventDoesNotExist_returnsNil() { + eventQueue.enqueue(createEvent(name: "event_1")) + + let foundEvent = eventQueue.find { event in + event.eventTitle == "nonexistent" + } + + XCTAssertNil(foundEvent) + } + + // MARK: - GetFirst Tests + + func testGetFirst_whenQueueIsEmpty_returnsNil() { + XCTAssertNil(eventQueue.getFirst()) + } + + func testGetFirst_returnsFirstEventWithoutRemoving() { + eventQueue.enqueue(createEvent(name: "event_1")) + eventQueue.enqueue(createEvent(name: "event_2")) + + let first = eventQueue.getFirst() + + XCTAssertEqual(first?.eventTitle, "event_1") + XCTAssertEqual(eventQueue.size(), 2) + } + + // MARK: - Prioritization Tests + + func testEnqueue_internalEvent_addsEventToFront() { + eventQueue.enqueue(createEvent(name: "regular_event")) + eventQueue.enqueue(createEvent(name: "internal_event"), isInternalEvent: true) + + XCTAssertEqual(eventQueue.getFirst()?.eventTitle, "internal_event") + } + + func testEnqueue_multipleInternalEvents_preservesInternalLifoOrder() { + eventQueue.enqueue(createEvent(name: "regular_event")) + eventQueue.enqueue(createEvent(name: "internal_1"), isInternalEvent: true) + eventQueue.enqueue(createEvent(name: "internal_2"), isInternalEvent: true) + + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "internal_2") + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "internal_1") + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "regular_event") + } + + func testQueueSupportsDifferentEventTypes() { + eventQueue.enqueue(createEvent(name: "track_event")) + eventQueue.enqueue(createScreenEvent(name: "Home")) + eventQueue.enqueue(createIdentifyEvent(userId: "user-1")) + + XCTAssertEqual(eventQueue.dequeue()?.eventTitle, "track_event") + XCTAssertEqual(eventQueue.dequeue()?.screenTitle, "Home") + XCTAssertEqual(eventQueue.dequeue()?.userId, "user-1") + } + + // MARK: - Thread Safety Tests + + func testThreadSafety_concurrentEnqueues() { + let iterations = 100 + let expectation = XCTestExpectation(description: "Concurrent enqueues") + expectation.expectedFulfillmentCount = iterations + + for index in 0.. Date: Wed, 8 Jul 2026 12:25:44 +0300 Subject: [PATCH 04/95] =?UTF-8?q?=F0=9F=94=80=20merge=20BRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/AnalyticsPublisher.swift | 50 +++-- .../Utilities/Helper/Constants.swift | 10 + .../Analytics/AnalyticsPublisherTests.swift | 184 ++++++++++++++++++ .../ExperiencesPublisherTests.swift | 7 + 4 files changed, 234 insertions(+), 17 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index d5dd7b56..5e1a6816 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -680,23 +680,29 @@ extension AnalyticsPublisher: AnalyticsPublishing { return eventTitle.isEmpty ? event.eventName : eventTitle } - let trackTitle = event.eventName - - guard let properties = event.properties else { return trackTitle } - - let screenName = trackEventThrottleScreenName(from: event.screen) - if screenName.isEmpty { return trackTitle } - - let tabName = trackEventThrottleString(from: properties[Constants.AutoCapture.tabName]) - if tabName.isEmpty { - let hierarchy = trackEventThrottleString(from: properties[Constants.AutoCapture.hierarchy]) - let row = trackEventThrottleString(from: properties[Constants.AutoCapture.selectedIndex]) - let interaction = trackEventThrottleString(from: event.interactionEventName) - return "\(screenName)|\(trackTitle)|\(interaction)|\(hierarchy)|\(row)" - } else { - let tabIndex = properties[Constants.AutoCapture.tabIndex] ?? 0 - return "\(screenName)|\(trackTitle)|\(tabName)|\(tabIndex)" + let properties = event.properties ?? [:] + func prop(_ key: String) -> String { + trackEventThrottleString(from: properties[key]) } + + let rawInteraction = prop(Constants.AutoCapture.rawInteractionType) + + return [ + trackEventThrottleScreenName(from: event.screen), + event.eventName, + rawInteraction.isEmpty ? (event.interactionEventName ?? "") : rawInteraction, + prop(Constants.AutoCapture.tabName), + prop(Constants.AutoCapture.tabIndex), + prop(Constants.AutoCapture.hierarchy), + prop(Constants.AutoCapture.accessibilityIdentifier), + prop(Constants.AutoCapture.dialogTitle), + prop(Constants.AutoCapture.targetText), + prop(Constants.AutoCapture.section), + prop(Constants.AutoCapture.selectedIndex), + prop(Constants.AutoCapture.selectedValue), + prop(Constants.AutoCapture.placeholder), + prop(Constants.AutoCapture.accessibilityLabel) + ].joined(separator: "|") } /// Resolves a display class for throttling from `Event.screen` (set on autocapture events via `makeEvent`). @@ -1094,7 +1100,7 @@ extension AnalyticsPublisher { let screenEvent = screenSessionStateMachine.event if let screenTitle = screenEvent.screenTitle { - if !config.enableScreenAutoCapture && config.enableInteractionAutoCapture { + if shouldSyncManualScreenForInteractionPayload() { screenNameTracker.updateScreen( with: ScreenTrackingPayload( screenTitle: screenTitle, @@ -1132,6 +1138,16 @@ extension AnalyticsPublisher { return true } + /// Whether a manually published screen should update `ScreenNameTracker` so + /// subsequent interaction autocapture events carry the correct screen context. + private func shouldSyncManualScreenForInteractionPayload() -> Bool { + if config.isWrapperSDK { + return !config.isWrapperScreenAutoCaptureEnabled && + config.isWrapperInteractionAutoCaptureEnabled + } + return !config.enableScreenAutoCapture && config.enableInteractionAutoCapture + } + /** A special case needed when coming from a logout state. In logout the app didn't execute setupScreenEvent, so after identify diff --git a/Sources/Userpilot/Utilities/Helper/Constants.swift b/Sources/Userpilot/Utilities/Helper/Constants.swift index 37048191..b4a57b8a 100644 --- a/Sources/Userpilot/Utilities/Helper/Constants.swift +++ b/Sources/Userpilot/Utilities/Helper/Constants.swift @@ -123,3 +123,13 @@ internal enum Constants { static let configurationDuration = TimeInterval(30 * 60) } } + +/// Keys used by wrapper SDKs (React Native, Flutter, Ionic) in `additionalProperties`. +internal struct WrapperSDKConstants { + static let pluginType = "PluginType" + static let pluginTypeReactNative = "ReactNative" + static let pluginTypeFlutter = "Flutter" + static let pluginTypeIonic = "Ionic" + static let enableScreenAutoCapture = "WrapperEnableScreenAutoCapture" + static let enableInteractionAutoCapture = "WrapperEnableInteractionAutoCapture" +} diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 6eb5a829..b064db3d 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -64,6 +64,52 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "Home Screen") } + func testPublish_screenEvent_shouldSyncScreenTrackerForWrapperInteractionOnlyConfig() { + // Arrange + let config = Userpilot.Config(token: "NX-WRAPPER-\(UUID().uuidString)") + .additionalProperties([ + WrapperSDKConstants.pluginType: WrapperSDKConstants.pluginTypeFlutter, + WrapperSDKConstants.enableScreenAutoCapture: false, + WrapperSDKConstants.enableInteractionAutoCapture: true + ]) + .defaultInstance(false) + userpilot = MockUserpilot(config: config) + analyticsPublisher = AnalyticsPublisher(container: userpilot.container) + userpilot.socketManager.isSocketOpened = true + userpilot.experiencesPublisher.onCanRequestScreenEvent = { return true } + let screenEvent = Event(type: .screen("Wrapper Manual Screen")) + + // Act + analyticsPublisher.publish(screenEvent) + + // Assert + let tracker = userpilot.container.resolve(ScreenNameTracking.self) + XCTAssertEqual(tracker.getCurrentPayload()?.currentScreen, "Wrapper Manual Screen") + } + + func testPublish_screenEvent_shouldNotSyncScreenTrackerForWrapperScreenAutocaptureConfig() { + // Arrange + let config = Userpilot.Config(token: "NX-WRAPPER-\(UUID().uuidString)") + .additionalProperties([ + WrapperSDKConstants.pluginType: WrapperSDKConstants.pluginTypeFlutter, + WrapperSDKConstants.enableScreenAutoCapture: true, + WrapperSDKConstants.enableInteractionAutoCapture: true + ]) + .defaultInstance(false) + userpilot = MockUserpilot(config: config) + analyticsPublisher = AnalyticsPublisher(container: userpilot.container) + userpilot.socketManager.isSocketOpened = true + userpilot.experiencesPublisher.onCanRequestScreenEvent = { return true } + let screenEvent = Event(type: .screen("Wrapper Screen Autocapture")) + + // Act + analyticsPublisher.publish(screenEvent) + + // Assert + let tracker = userpilot.container.resolve(ScreenNameTracking.self) + XCTAssertNil(tracker.getCurrentPayload()) + } + func testPublish_customEvent_shouldAddToQueue() { // Arrange let customEvent = Event(type: .event("button_clicked")) @@ -525,6 +571,144 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertFalse(connectCalled) } + // MARK: - Track Event Throttle Key Tests + + private func makeAutoCaptureEvent( + properties: Payload = nil, + screen: Payload = [AutoCaptureConstants.screenClass: "HomeViewController"], + interactionEventName: String = "tap" + ) -> Event { + return Event( + type: .autoCaptureEvent, + properties: properties, + screen: screen, + interactionEventName: interactionEventName + ) + } + + /// Counts socket publishes triggered by publishing the given events back-to-back. + private func publishAndCountSocketPublishes( + _ events: [Event], + expectedCount: Int, + timeout: TimeInterval = 2.0 + ) -> Int { + userpilot.socketManager.isSocketOpened = true + + var publishCount = 0 + let expectation = XCTestExpectation(description: "events flushed to socket") + expectation.expectedFulfillmentCount = expectedCount + expectation.assertForOverFulfill = false + userpilot.socketManager.onPublish = { _, _ in + publishCount += 1 + expectation.fulfill() + } + + events.forEach { analyticsPublisher.publish($0) } + + wait(for: [expectation], timeout: timeout) + // Grace period so an unexpected extra publish would also be counted + let grace = XCTestExpectation(description: "grace period") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { grace.fulfill() } + wait(for: [grace], timeout: 1.0) + return publishCount + } + + func testTrackEvent_autoCaptureNilProperties_distinctInteractionsShouldBothPublish() { + let tap = makeAutoCaptureEvent(interactionEventName: "tap") + let swipe = makeAutoCaptureEvent(interactionEventName: "swipe") + + let publishCount = publishAndCountSocketPublishes([tap, swipe], expectedCount: 2) + + XCTAssertEqual(publishCount, 2) + } + + func testTrackEvent_autoCaptureDialogEvents_differentTitlesShouldBothPublish() { + let hierarchy = "UIAlertController:attr__index=\"0\";HomeViewController" + let deleteDialog = makeAutoCaptureEvent( + properties: [ + AutoCaptureConstants.hierarchy: hierarchy, + AutoCaptureConstants.rawInteractionType: "view_presented", + AutoCaptureConstants.dialogTitle: "Delete item?" + ], + interactionEventName: "view_presented" + ) + let logoutDialog = makeAutoCaptureEvent( + properties: [ + AutoCaptureConstants.hierarchy: hierarchy, + AutoCaptureConstants.rawInteractionType: "view_presented", + AutoCaptureConstants.dialogTitle: "Log out?" + ], + interactionEventName: "view_presented" + ) + + let publishCount = publishAndCountSocketPublishes([deleteDialog, logoutDialog], expectedCount: 2) + + XCTAssertEqual(publishCount, 2) + } + + func testTrackEvent_autoCaptureUnresolvableScreenName_shouldStillUseOtherDiscriminators() { + // Screen dict is non-empty (passes the flush guard) but has no + // class/title/name, so the screen segment cannot be resolved + let screen: Payload = [AutoCaptureConstants.screenType: "modal"] + let saveTap = makeAutoCaptureEvent( + properties: [AutoCaptureConstants.targetText: "Save"], + screen: screen + ) + let cancelTap = makeAutoCaptureEvent( + properties: [AutoCaptureConstants.targetText: "Cancel"], + screen: screen + ) + + let publishCount = publishAndCountSocketPublishes([saveTap, cancelTap], expectedCount: 2) + + XCTAssertEqual(publishCount, 2) + } + + func testTrackEvent_autoCaptureIdenticalEvents_secondShouldThrottle() { + let tap = makeAutoCaptureEvent( + properties: [ + AutoCaptureConstants.hierarchy: "UIButton:attr__index=\"0\";HomeViewController", + AutoCaptureConstants.rawInteractionType: "tap", + AutoCaptureConstants.targetText: "Save" + ] + ) + + let publishCount = publishAndCountSocketPublishes([tap, tap], expectedCount: 1) + + XCTAssertEqual(publishCount, 1) + } + + func testTrackEvent_autoCaptureTabEvents_differentTabsShouldBothPublish() { + let homeTab = makeAutoCaptureEvent( + properties: [ + AutoCaptureConstants.tabName: "Home", + AutoCaptureConstants.tabIndex: 0, + AutoCaptureConstants.rawInteractionType: "tab_selected" + ], + interactionEventName: "tab_selected" + ) + let profileTab = makeAutoCaptureEvent( + properties: [ + AutoCaptureConstants.tabName: "Profile", + AutoCaptureConstants.tabIndex: 1, + AutoCaptureConstants.rawInteractionType: "tab_selected" + ], + interactionEventName: "tab_selected" + ) + + let publishCount = publishAndCountSocketPublishes([homeTab, profileTab], expectedCount: 2) + + XCTAssertEqual(publishCount, 2) + } + + func testTrackEvent_customEvents_sameTitleShouldThrottleSecond() { + let event = Event(type: .event("button_clicked")) + + let publishCount = publishAndCountSocketPublishes([event, event], expectedCount: 1) + + XCTAssertEqual(publishCount, 1) + } + func testPublish_withSameIdentifyEvent_shouldNotReprocess() { // Arrange let userId = "test-user" diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 7144dd40..e2db9c28 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -451,16 +451,23 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldResetPreviewMode_WhenPreviewExperienceCloses() { // Arrange userpilot.experienceStateMachine.markPreviewMode() + var publishedFakeReload = false + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in + publishedFakeReload = true + return true + } let closeEvent = MockSDKEvent( eventName: SDKEventsName.flowExperienceDismissed.rawValue, eventPayload: ["mobile_content_id": 77] ) + closeEvent.isCloseEvent = true // Act experiencesPublisher.publishInternalSDKEvent(closeEvent) // Assert XCTAssertFalse(userpilot.experienceStateMachine.isPreviewMode()) + XCTAssertTrue(publishedFakeReload) } func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForCloseNPSEvent() { From 31de0b2878907a5a68d93646a75d65792d096fc1 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 13 Jul 2026 14:56:09 +0300 Subject: [PATCH 05/95] =?UTF-8?q?=F0=9F=90=9B=20Fix=20sending=20events=20i?= =?UTF-8?q?n=20preview=20mode=20for=20thankyou=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Experiences/ExperienceStateManager.swift | 11 +++-- .../ExperienceStateManagerTests.swift | 13 ++++++ .../ExperiencesPublisherTests.swift | 46 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperienceStateManager.swift b/Sources/Userpilot/Experiences/ExperienceStateManager.swift index b380358c..dc779cf9 100644 --- a/Sources/Userpilot/Experiences/ExperienceStateManager.swift +++ b/Sources/Userpilot/Experiences/ExperienceStateManager.swift @@ -46,7 +46,7 @@ internal enum ExperienceFlowState { case active(triggerType: TriggerType, content: ExperienceContent) /// Thank you message is displayed after survey completion. - case showingThankYou + case showingThankYou(isPreview: Bool) /// Manual experience cached because another experience is in progress. case cachedPendingManual(experienceId: String) @@ -78,6 +78,8 @@ extension ExperienceFlowState { return true case .waitingDelay(let triggerType), .active(let triggerType, _): return triggerType == .preview + case .showingThankYou(let isPreview): + return isPreview default: return false } @@ -136,8 +138,8 @@ extension ExperienceFlowState: CustomStringConvertible { return "WaitingDelay(\(triggerType))" case .active(let triggerType, let content): return "Active(\(triggerType), content=\(type(of: content)))" - case .showingThankYou: - return "ShowingThankYou" + case .showingThankYou(let isPreview): + return "ShowingThankYou(preview=\(isPreview))" case .cachedPendingManual(let experienceId): return "CachedPendingManual(id=\(experienceId))" case .cachedPendingAutomatic: @@ -297,7 +299,8 @@ extension ExperienceStateManager: ExperienceStateManaging { } func markShowingThankYou() { - state.value = .showingThankYou + let isPreview = state.value.isPreviewMode() + state.value = .showingThankYou(isPreview: isPreview) logger.info("Experience state: ShowingThankYou") } diff --git a/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift b/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift index f82931bd..8e595490 100644 --- a/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift @@ -145,6 +145,19 @@ final class ExperienceStateManagerTests: XCTestCase { XCTAssertTrue(stateManager.isActivelyRendered()) } + func testMarkShowingThankYou_preservesPreviewMode() { + // Arrange + stateManager.markPreviewMode() + stateManager.markActiveFromCurrentState(content: makeExperienceContent()) + + // Act + stateManager.markShowingThankYou() + + // Assert + XCTAssertTrue(stateManager.isPreviewMode()) + XCTAssertTrue(stateManager.shouldBypassScreenValidation()) + } + func testMarkCachedManual_setsCachedExperienceId() { // Act stateManager.markCachedManual("cached-exp") diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 50fdd3ab..835bee5b 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -449,6 +449,52 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertNil(publishedEvent) } + func testPublishInternalSDKEvent_shouldSuppressCompletedSurvey_WhenPreviewThankYouIsShowing() { + // Arrange + var publishedEvent: SDKEvent? + userpilot.experienceStateManager.markPreviewMode() + userpilot.experienceStateManager.markActiveFromCurrentState( + content: .survey(content: MockContentFactory.makeSurveyContent()) + ) + userpilot.experienceStateManager.markShowingThankYou() + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + publishedEvent = event + } + let completedSurveyEvent = ExperienceSurveyCompletedEvent( + surveyId: 10, + submissionId: 20 + ) + + // Act + experiencesPublisher.publishInternalSDKEvent(completedSurveyEvent) + + // Assert + XCTAssertNil(publishedEvent) + XCTAssertFalse(userpilot.experienceStateManager.isPreviewMode()) + } + + func testPublishInternalSDKEvent_shouldPublishCompletedSurvey_WhenThankYouIsNotPreview() { + // Arrange + var publishedEvent: SDKEvent? + userpilot.experienceStateManager.markShowingThankYou() + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + publishedEvent = event + } + let completedSurveyEvent = ExperienceSurveyCompletedEvent( + surveyId: 10, + submissionId: 20 + ) + + // Act + experiencesPublisher.publishInternalSDKEvent(completedSurveyEvent) + + // Assert + XCTAssertEqual( + publishedEvent?.eventName, + SDKEventsName.surveyExperienceCompleted.rawValue + ) + } + func testPublishInternalSDKEvent_shouldResetPreviewMode_WhenPreviewExperienceCloses() { // Arrange userpilot.experienceStateManager.markPreviewMode() From f2dc033730895e99df503bf6d56be976e63b01e0 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 14 Jul 2026 12:39:25 +0300 Subject: [PATCH 06/95] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20preview=20sessi?= =?UTF-8?q?ons=20across=20lifecycle=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Experiences/ExperiencesPublisher.swift | 172 ++++++++++++------ .../Experiences/PreviewSessionTracker.swift | 51 ++++++ .../ExperiencesPublisherTests.swift | 75 ++++++++ 3 files changed, 240 insertions(+), 58 deletions(-) create mode 100644 Sources/Userpilot/Experiences/PreviewSessionTracker.swift diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index de90b73b..8221c101 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -117,8 +117,8 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// The last screen on which NPS was shown, preventing repeated NPS on same screen. private lazy var npsTrackedScreen: String = "" - /// Queue to track pending experience content waiting to be displayed - private var pendingExperiences: [ExperienceContent] = [] + /// Queue to track pending experience content waiting to be displayed. + private var pendingExperiences: [PendingExperience] = [] /// Utility for managing display delays for surveys and NPS experiences private lazy var delayUtils = DelayUtils() @@ -135,6 +135,15 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Track last active experience private weak var activeExperience: UIViewController? + /// Guards preview request identity across lifecycle and network callbacks. + private let previewSessionTracker = PreviewSessionTracker() + + /// Content waiting to be shown together with immutable trigger provenance. + private struct PendingExperience { + let content: ExperienceContent + let triggerType: TriggerType + } + /// Determines if there are currently active experiences being displayed private var hasActiveExperience: Bool { return activeExperience != nil || experienceStateManager.isActivelyRendered() @@ -196,6 +205,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { return requestFakeScreenReloadEventDate?.isMoreThanOneSecond(from: Date()) ?? true && !hasActiveExperience && !experienceStateManager.isActive() + && !isPreviewExperienceMode() && !experienceStateManager.hasCachedExperience() } @@ -337,14 +347,14 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func getActiveMobileContent() -> ExperienceContent? { guard !pendingExperiences.isEmpty else { return nil } - let content = pendingExperiences.first + let content = pendingExperiences.first?.content clearPendingExperiences() return content } /// Preview mode suppresses normal analytics while draft content is being rendered. func isPreviewExperienceMode() -> Bool { - experienceStateManager.isPreviewMode() + experienceStateManager.isPreviewMode() || previewSessionTracker.isActive() } // MARK: - Deep Link Handling @@ -446,6 +456,10 @@ extension ExperiencesPublisher: SocketSubscription { tryCatch { if currentScreen == screenName { return } currentScreen = screenName + if isPreviewExperienceMode() { + resumePendingPreviewExperience() + return + } resetState() } } @@ -502,23 +516,28 @@ extension ExperiencesPublisher: SocketSubscription { }() if let experience { + let triggerType: TriggerType if eventName == SDKEventsName.fetchExperienceContent.rawValue { + triggerType = .manual self.experienceStateManager.markManualTrigger( experience.experienceId().toString() ) } else { + triggerType = .automatic self.experienceStateManager.markAutomaticTrigger(experience) } - self.pendingExperiences.append(experience) + self.pendingExperiences.append( + PendingExperience(content: experience, triggerType: triggerType) + ) } } // Process the first pending experience - if let experience = self.pendingExperiences.first { - if experience.asNPSContent() != nil { - self.openNPSBottomSheetExperience(experience) + if let pendingExperience = self.pendingExperiences.first { + if pendingExperience.content.asNPSContent() != nil { + self.openNPSBottomSheetExperience(pendingExperience) } else { - self.checkCachedThemes(experience.experienceThemeId()) + self.checkCachedThemes(pendingExperience.content.experienceThemeId()) } } } @@ -565,7 +584,9 @@ extension ExperiencesPublisher: SocketSubscription { self.experienceStateManager.markManualTrigger( experience.experienceId().toString() ) - self.pendingExperiences.append(experience) + self.pendingExperiences.append( + PendingExperience(content: experience, triggerType: .manual) + ) self.checkCachedThemes(experience.experienceThemeId()) } } @@ -621,34 +642,35 @@ extension ExperiencesPublisher { * Handles flows (carousel, slide-out), surveys (list, step), and NPS experiences. */ private func openExperienceFlow() { - if let experienceContent = pendingExperiences.first { + if let pendingExperience = pendingExperiences.first { + let experienceContent = pendingExperience.content switch experienceContent { case .flow(let content): switch content.type { case .carousel: - self.openCarouselExperience(experienceContent) + self.openCarouselExperience(pendingExperience) case .slideout: if self.isBottomSheetContent(content) { - self.openSlideOutBottomSheetExperience(experienceContent) + self.openSlideOutBottomSheetExperience(pendingExperience) } else { - self.openSlideOutDialogExperience(experienceContent) + self.openSlideOutDialogExperience(pendingExperience) } } case .survey(let content): switch content.type { case .list: - self.openSurveyListExperience(experienceContent) + self.openSurveyListExperience(pendingExperience) case .step: if self.isBottomSheetSurveyContent(content) { - self.openSurveyBottomSheetExperience(experienceContent) + self.openSurveyBottomSheetExperience(pendingExperience) } else { - self.openSurveyDialogExperience(experienceContent) + self.openSurveyDialogExperience(pendingExperience) } } case .nps: - openNPSBottomSheetExperience(experienceContent) + openNPSBottomSheetExperience(pendingExperience) } } } @@ -693,9 +715,9 @@ extension ExperiencesPublisher { * Opens a carousel experience in a full-screen activity. * Content is passed to prevent issues if pending experiences are cleared during screen transitions. */ - private func openCarouselExperience(_ experienceContent: ExperienceContent) { + private func openCarouselExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: ExperienceViewModel.init, makeViewController: CarouselExperienceViewController.init, presentation: .fullScreen @@ -703,9 +725,9 @@ extension ExperiencesPublisher { } /** Opens a slide-out experience as a dialog fragment */ - private func openSlideOutDialogExperience(_ experienceContent: ExperienceContent) { + private func openSlideOutDialogExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: ExperienceViewModel.init, makeViewController: SlideOutDialogViewController.init, presentation: .dialog @@ -713,9 +735,9 @@ extension ExperiencesPublisher { } /** Opens a slide-out experience as a bottom sheet fragment */ - private func openSlideOutBottomSheetExperience(_ experienceContent: ExperienceContent) { + private func openSlideOutBottomSheetExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: ExperienceViewModel.init, makeViewController: SlideOutBottomSheetViewController.init, presentation: .bottomSheet @@ -723,9 +745,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience in a full-screen activity with list view */ - private func openSurveyListExperience(_ experienceContent: ExperienceContent) { + private func openSurveyListExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: SurveyViewModel.init, makeViewController: SurveyListViewController.init, presentation: .fullScreen @@ -733,9 +755,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience as a dialog fragment */ - private func openSurveyDialogExperience(_ experienceContent: ExperienceContent) { + private func openSurveyDialogExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: SurveyViewModel.init, makeViewController: SurveyDialogViewController.init, presentation: .dialog @@ -743,9 +765,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience as a bottom sheet fragment */ - private func openSurveyBottomSheetExperience(_ experienceContent: ExperienceContent) { + private func openSurveyBottomSheetExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: SurveyViewModel.init, makeViewController: SurveyBottomSheetViewController.init, presentation: .bottomSheet @@ -753,13 +775,13 @@ extension ExperiencesPublisher { } /** Opens an NPS experience as a bottom sheet fragment */ - private func openNPSBottomSheetExperience(_ experienceContent: ExperienceContent) { + private func openNPSBottomSheetExperience(_ pendingExperience: PendingExperience) { if currentScreen == npsTrackedScreen { resetProcessingExperienceStatus() return } showExperience( - experienceContent, + pendingExperience, makeViewModel: NPSViewModel.init, makeViewController: NPSBottomSheetViewController.init, presentation: .bottomSheet @@ -836,21 +858,14 @@ extension ExperiencesPublisher { * dropped frames and interrupts opening content animations. */ private func showExperience( - _ experienceContent: ExperienceContent, + _ pendingExperience: PendingExperience, makeViewModel: @escaping (DIContainer) -> VM, makeViewController: @escaping (VM) -> VC, presentation: PresentationStyle ) { tryCatch { - let triggerType: TriggerType - if experienceStateManager.isManualTrigger() { - triggerType = .manual - } else if experienceStateManager.isPreviewMode() { - triggerType = .preview - } else { - triggerType = .automatic - } - experienceStateManager.markWaitingDelay(triggerType) + let experienceContent = pendingExperience.content + experienceStateManager.markWaitingDelay(pendingExperience.triggerType) delayUtils.delayAction(delayTime: resolvedDelay(experienceContent)) { [weak self] in guard @@ -980,13 +995,18 @@ extension ExperiencesPublisher { if pendingExperiences.isEmpty { return } experienceQueue.async { [weak self] in guard let self else { return } + if self.pendingExperiences.first?.triggerType == .preview, + self.previewSessionTracker.isActive() { + self.experienceStateManager.markPreviewMode() + return + } self.resetProcessingExperienceStatus() if self.pendingExperiences.count == 1 { self.pendingExperiences.removeAll() } else { - if let lastContent = self.pendingExperiences.last, self.pendingExperiences.count > 1 { - self.pendingExperiences = [lastContent] - self.experienceStateManager.markAutomaticTrigger(lastContent) + if let lastPending = self.pendingExperiences.last, self.pendingExperiences.count > 1 { + self.pendingExperiences = [lastPending] + self.experienceStateManager.markAutomaticTrigger(lastPending.content) self.openExperienceFlow() } } @@ -1000,6 +1020,7 @@ extension ExperiencesPublisher { * becomes invalid from view models. */ private func resetState() { + previewSessionTracker.cancel() resetState(completion: nil) } @@ -1024,7 +1045,12 @@ extension ExperiencesPublisher { experienceStateManager.markIdle() } - private func resetProcessingPreviewExperienceStatus() { + private func resetProcessingPreviewExperienceStatus(previewSessionId: UInt64? = nil) { + if let previewSessionId { + guard previewSessionTracker.finish(previewSessionId) else { return } + } else { + previewSessionTracker.cancel() + } resetProcessingExperienceStatus() } @@ -1033,7 +1059,9 @@ extension ExperiencesPublisher { switch state { case .cachedPendingAutomatic(let experience): activeExperience = nil - pendingExperiences.append(experience) + pendingExperiences.append( + PendingExperience(content: experience, triggerType: .automatic) + ) experienceStateManager.markAutomaticTrigger(experience) checkCachedThemes(experience.experienceThemeId()) @@ -1053,8 +1081,9 @@ extension ExperiencesPublisher { extension ExperiencesPublisher { func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) { + let previewSessionId = previewSessionTracker.begin() resetState { [weak self] in - guard let self else { return } + guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } self.experienceStateManager.markPreviewMode() self.userpilotRemoteSource.fetchPreviewExperience( params: PreviewExperienceQueryParams( @@ -1064,24 +1093,34 @@ extension ExperiencesPublisher { contentId: experienceId ), completion: { [weak self] result in - guard let self else { return } + guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } switch result { case .success(let previewExperience): - self.processPreviewExperience(previewExperience) + self.processPreviewExperience( + previewExperience, + previewSessionId: previewSessionId + ) case .failure(let error): - self.showExperienceTriggeringDebugMessage(error.localizedDescription) + self.showExperienceTriggeringDebugMessage( + error.localizedDescription, + previewSessionId: previewSessionId + ) } } ) } } - private func processPreviewExperience(_ previewExperience: PreviewExperience) { + private func processPreviewExperience( + _ previewExperience: PreviewExperience, + previewSessionId: UInt64 + ) { tryCatch { + guard previewSessionTracker.isCurrent(previewSessionId) else { return } guard let theme = previewExperience.theme, previewExperience.flow != nil || previewExperience.survey != nil else { - resetProcessingPreviewExperienceStatus() + resetProcessingPreviewExperienceStatus(previewSessionId: previewSessionId) return } @@ -1095,21 +1134,27 @@ extension ExperiencesPublisher { } guard let experience else { - resetProcessingPreviewExperienceStatus() + resetProcessingPreviewExperienceStatus(previewSessionId: previewSessionId) return } experienceQueue.async { [weak self] in - guard let self else { return } - self.pendingExperiences.append(experience) + guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } + self.pendingExperiences.append( + PendingExperience(content: experience, triggerType: .preview) + ) self.themeHandler.saveTheme(theme) self.openExperienceFlow() } } } - private func showExperienceTriggeringDebugMessage(_ message: String) { - resetProcessingPreviewExperienceStatus() + private func showExperienceTriggeringDebugMessage( + _ message: String, + previewSessionId: UInt64 + ) { + guard previewSessionTracker.isCurrent(previewSessionId) else { return } + resetProcessingPreviewExperienceStatus(previewSessionId: previewSessionId) performOn(.main) { [weak self] in guard let self, @@ -1137,6 +1182,17 @@ extension ExperiencesPublisher { host.present(alert, animated: true, completion: nil) } } + + private func resumePendingPreviewExperience() { + experienceQueue.async { [weak self] in + guard + let self, + case .pendingPreview = self.experienceStateManager.getCurrentState(), + self.pendingExperiences.first?.triggerType == .preview + else { return } + self.openExperienceFlow() + } + } } #if DEBUG diff --git a/Sources/Userpilot/Experiences/PreviewSessionTracker.swift b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift new file mode 100644 index 00000000..a24fe0f7 --- /dev/null +++ b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift @@ -0,0 +1,51 @@ +// +// PreviewSessionTracker.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// + +import Foundation + +/// Tracks the active preview request and rejects stale asynchronous responses. +internal final class PreviewSessionTracker { + + private let lock = NSLock() + private var nextId: UInt64 = 0 + private var activeId: UInt64? + + func begin() -> UInt64 { + lock.lock() + defer { lock.unlock() } + nextId += 1 + activeId = nextId + return nextId + } + + func isCurrent(_ id: UInt64) -> Bool { + lock.lock() + defer { lock.unlock() } + return activeId == id + } + + func isActive() -> Bool { + lock.lock() + defer { lock.unlock() } + return activeId != nil + } + + @discardableResult + func finish(_ id: UInt64) -> Bool { + lock.lock() + defer { lock.unlock() } + guard activeId == id else { return false } + activeId = nil + return true + } + + func cancel() { + lock.lock() + activeId = nil + lock.unlock() + } +} diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 835bee5b..5c1a4415 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -54,6 +54,17 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertTrue(result) } + func testCanRequestScreenEvent_shouldReturnFalse_WhenPreviewIsPending() { + // Arrange + userpilot.experienceStateManager.markPreviewMode() + + // Act + let result = experiencesPublisher.canRequestScreenEvent() + + // Assert + XCTAssertFalse(result) + } + // MARK: - triggerExperience Tests func testTriggerExperience_shouldPublishEvent_WhenNoActiveExperience() { @@ -229,6 +240,17 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertEqual(experiencesPublisher.mockGetCurrentScreen(), "TestScreen") } + func testUpdateScreen_shouldPreservePendingPreview() { + // Arrange + userpilot.experienceStateManager.markPreviewMode() + + // Act + experiencesPublisher.updateSceen("PreviewScreen") + + // Assert + XCTAssertTrue(userpilot.experienceStateManager.isPreviewMode()) + } + func testOnSocketEventSent_shouldSaveTheme_ForThemeEvent() { // Arrange let expectation = XCTestExpectation(description: "Theme should be saved") @@ -659,6 +681,41 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [closeExpectation, fetchExpectation], timeout: 1.0) } + func testTriggerPreviewExperience_shouldIgnoreStaleResponse_WhenNewerPreviewStarts() throws { + // Arrange + let fetchExpectation = XCTestExpectation(description: "both preview fetches requested") + fetchExpectation.expectedFulfillmentCount = 2 + var completions: [ + String: (Result) -> Void + ] = [:] + userpilot.remoteSource.onFetchPreviewExperience = { params, completion in + completions[params.contentId] = completion + fetchExpectation.fulfill() + } + + let latestThemeSaved = XCTestExpectation(description: "latest preview theme saved") + var savedThemeIds: [Int] = [] + userpilot.themeHandler.onSaveTheme = { theme in + if let id = theme.id { + savedThemeIds.append(id) + if id == 22 { + latestThemeSaved.fulfill() + } + } + } + + // Act + experiencesPublisher.triggerPreviewExperience("first", []) + experiencesPublisher.triggerPreviewExperience("second", []) + wait(for: [fetchExpectation], timeout: 1.0) + completions["first"]?(.success(try makePreviewExperience(themeId: 11))) + completions["second"]?(.success(try makePreviewExperience(themeId: 22))) + + // Assert + wait(for: [latestThemeSaved], timeout: 1.0) + XCTAssertFalse(savedThemeIds.contains(11)) + } + // MARK: - Thread Safety Tests func testThreadSafety_multipleSimultaneousAccess() { @@ -689,6 +746,24 @@ final class ExperiencesPublisherTests: XCTestCase { } wait(for: [resultExpectation], timeout: 1.0) } + + private func makePreviewExperience(themeId: Int) throws -> PreviewExperience { + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + return PreviewExperience( + flow: flow, + survey: nil, + contentType: "flow", + theme: ThemeContent( + id: themeId, + themeData: ThemeData(carousel: nil, slideOut: nil, survey: nil) + ) + ) + } } // swiftlint:disable all From 092066f5d9a27517f98e20c47e59b18ea755f123 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 20 Jul 2026 15:30:16 +0300 Subject: [PATCH 07/95] =?UTF-8?q?=F0=9F=90=9B=20Improve=20experience=20ded?= =?UTF-8?q?uplication=20with=20seen-status=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/AnalyticsPublisher.swift | 30 ++++ .../Experiences/ExperiencesPublisher.swift | 169 ++++++++++++++---- .../Analytics/AnalyticsPublisherTests.swift | 25 +++ .../ExperiencesPublisherTests.swift | 99 ++++++++++ Tests/UserpilotTests/MockUserpilot.swift | 5 + 5 files changed, 293 insertions(+), 35 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index baaf741c..8e255f41 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -60,6 +60,16 @@ internal protocol AnalyticsPublishing: AnyObject { _ experienceId: Int ) + /** + * Returns whether the experience was already displayed on the current screen entity. + * + * Flows use `seenExperiences`, Surveys use `seenSurveys`, and NPS returns `false` because NPS + * is deduplicated by `ExperiencesPublisher` using its last tracked screen. The screen entity + * retains seen ids when the same screen is reported again and starts with empty sets when the + * screen title changes. + */ + func isExperienceSeen(_ experienceContent: ExperienceContent) -> Bool + /// For experience which are come from start session var isStartSession: Bool { get } @@ -983,6 +993,26 @@ extension AnalyticsPublisher { } } + /** + * Checks the current screen's type-specific seen set for the supplied experience. + * + * A Flow id is checked only in `seenExperiences`, and a Survey id only in `seenSurveys`, so a + * Flow and Survey with the same numeric id remain distinct. NPS always returns `false` here and + * continues through the existing per-screen NPS deduplication path in `ExperiencesPublisher`. + * If no screen entity exists, Flow and Survey are treated as unseen. + */ + func isExperienceSeen(_ experienceContent: ExperienceContent) -> Bool { + switch experienceContent { + case .flow(let content): + return screenViewEntity?.seenExperiences.contains(content.id) == true + case .survey(let content): + return screenViewEntity?.seenSurveys.contains(content.id) == true + case .nps: + // NPS deduplication is managed by ExperiencesPublisher per screen. + return false + } + } + } // MARK: - Event Broadcasting diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 8221c101..87a8a094 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -483,9 +483,6 @@ extension ExperiencesPublisher: SocketSubscription { ) { experienceQueue.async { [weak self] in guard let self, - // If there's an active experience, ignore new content - !hasActiveExperience, - !experienceStateManager.isActive(), !experienceStateManager.isPreviewMode(), !message.payload.isEmpty, let response = message.payload.toJSONString() @@ -499,38 +496,7 @@ extension ExperiencesPublisher: SocketSubscription { self.themeHandler.saveTheme(themeData) } - // Process content from screen events and fetch experience content events - if eventName == EventType.screenEvent || - eventName == SDKEventsName.fetchExperienceContent.rawValue { - - // Determine the new experience based on response - let experience: ExperienceContent? = { - if let flowContent = response.toFlowContent()?.flowContent { - return .flow(content: flowContent) - } else if let surveyContent = response.toSurveyContent()?.surveyContent { - return .survey(content: surveyContent) - } else if let npsContent = response.toNPSContent()?.npsContent { - return .nps(content: npsContent) - } - return nil - }() - - if let experience { - let triggerType: TriggerType - if eventName == SDKEventsName.fetchExperienceContent.rawValue { - triggerType = .manual - self.experienceStateManager.markManualTrigger( - experience.experienceId().toString() - ) - } else { - triggerType = .automatic - self.experienceStateManager.markAutomaticTrigger(experience) - } - self.pendingExperiences.append( - PendingExperience(content: experience, triggerType: triggerType) - ) - } - } + self.processExperienceContentResponse(eventName, response) // Process the first pending experience if let pendingExperience = self.pendingExperiences.first { @@ -543,6 +509,54 @@ extension ExperiencesPublisher: SocketSubscription { } } + /** + * Selects and queues one eligible experience from a screen or manual-content response. + * + * Responses are parsed in priority order: Flow, Survey, then NPS. Automatic screen responses + * select the first candidate that is not in the current screen entity's seen sets, allowing an + * unseen Survey or NPS to fall back when a Flow was already shown. Manual fetch responses select + * the first candidate without filtering by seen state. + * + * Preview responses and active/pending/cached duplicates are ignored. A distinct candidate is + * cached when another experience is active; otherwise the appropriate trigger state is set and + * the candidate is appended to `pendingExperiences`. Other socket event types and responses + * without valid candidates are ignored. + */ + private func processExperienceContentResponse(_ eventName: String, _ response: String) { + guard eventName == EventType.screenEvent || + eventName == SDKEventsName.fetchExperienceContent.rawValue + else { return } + + let triggerType = triggerTypeForEvent(eventName) + let candidates = experienceCandidates(response) + let experience: ExperienceContent? + if triggerType == .automatic { + experience = candidates.first { !analyticsPublisher.isExperienceSeen($0) } + } else { + experience = candidates.first + } + + guard let experience, !experienceStateManager.isPreviewMode() else { return } + if isDuplicateExperience(experience) { + logger.info("Ignoring duplicate experience: %@", experience.experienceId().toString()) + return + } + if hasActiveExperience || experienceStateManager.isActive() { + experienceStateManager.markCachedAutomatic(experience) + logger.info("Experience cached - active experience in progress") + return + } + + if triggerType == .manual { + experienceStateManager.markManualTrigger(experience.experienceId().toString()) + } else { + experienceStateManager.markAutomaticTrigger(experience) + } + pendingExperiences.append( + PendingExperience(content: experience, triggerType: triggerType) + ) + } + /** * Handles new messages received from the socket. * This is triggered from manual experience events. @@ -1054,11 +1068,26 @@ extension ExperiencesPublisher { resetProcessingExperienceStatus() } + /** + * Processes the experience, if any, that was cached while another experience was active. + * + * An automatic cached experience is discarded if it became seen before replay; otherwise it is + * queued and resumes automatic processing. A cached manual id is retriggered through the public + * manual path. If the state contains neither cached case, processing returns to idle. + */ private func processCachedExperienceAfterClose() { let state = experienceStateManager.getCurrentState() switch state { case .cachedPendingAutomatic(let experience): activeExperience = nil + if analyticsPublisher.isExperienceSeen(experience) { + logger.info( + "Ignoring cached experience already seen: %@", + experience.experienceId().toString() + ) + resetProcessingExperienceStatus() + return + } pendingExperiences.append( PendingExperience(content: experience, triggerType: .automatic) ) @@ -1074,6 +1103,76 @@ extension ExperiencesPublisher { resetProcessingExperienceStatus() } } + + /** + * Parses every available response type in backend priority order: Flow, Survey, then NPS. + * + * Each decoder contributes only when its content exists and is valid. Returning all candidates, + * instead of stopping at the first decoded type, lets automatic selection continue past a seen + * higher-priority candidate. + */ + private func experienceCandidates(_ response: String) -> [ExperienceContent] { + var candidates: [ExperienceContent] = [] + if let flowContent = response.toFlowContent()?.flowContent { + candidates.append(.flow(content: flowContent)) + } + if let surveyContent = response.toSurveyContent()?.surveyContent { + candidates.append(.survey(content: surveyContent)) + } + if let npsContent = response.toNPSContent()?.npsContent { + candidates.append(.nps(content: npsContent)) + } + return candidates + } + + /** + * Maps the response event to its trigger provenance. + * + * `fetchExperienceContent` is a manual API response and bypasses seen-screen filtering. Screen + * and any other accepted response events are automatic and must select an unseen candidate. + */ + private func triggerTypeForEvent(_ eventName: String) -> TriggerType { + eventName == SDKEventsName.fetchExperienceContent.rawValue ? .manual : .automatic + } + + /** + * Returns whether the same content is already active, pending, or cached. + * + * This differs from a seen check: seen content was already displayed on the current screen, + * while duplicate content is another copy currently moving through the rendering lifecycle. + * Checking all three locations prevents repeated responses from replacing or requeuing work. + */ + private func isDuplicateExperience(_ experience: ExperienceContent) -> Bool { + if sameExperience(experienceStateManager.getActiveContent(), experience) { + return true + } + if pendingExperiences.contains(where: { sameExperience($0.content, experience) }) { + return true + } + return sameExperience(experienceStateManager.getCachedExperienceContent(), experience) + } + + /** + * Compares two experiences by type and that type's stable identifier. + * + * Flow and Survey compare numeric ids. NPS compares the survey key because its `experienceId()` + * is currently zero for every NPS. Different content types and a nil first value never match. + */ + private func sameExperience( + _ first: ExperienceContent?, + _ second: ExperienceContent + ) -> Bool { + switch (first, second) { + case (.flow(let firstContent), .flow(let secondContent)): + return firstContent.id == secondContent.id + case (.survey(let firstContent), .survey(let secondContent)): + return firstContent.id == secondContent.id + case (.nps(let firstContent), .nps(let secondContent)): + return firstContent.content.survey.key == secondContent.content.survey.key + default: + return false + } + } } // MARK: - Preview Experience diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 8281846a..cb463265 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -541,6 +541,31 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(analyticsPublisher.screenEntity?.seenSurveys.contains(456) ?? false) } + func testIsExperienceSeen_shouldUseSeenSetForContentType() throws { + // Arrange + analyticsPublisher.publish(Event(type: .screen("Test Screen"))) + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + let survey = MockContentFactory.makeSurveyContent(id: 456) + + // Act + analyticsPublisher.experiencePublished(.flow, flow.id) + analyticsPublisher.experiencePublished(.survey, survey.id) + + // Assert + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.flow(content: flow))) + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.survey(content: survey))) + XCTAssertFalse( + analyticsPublisher.isExperienceSeen( + .survey(content: MockContentFactory.makeSurveyContent(id: 999)) + ) + ) + } + // MARK: - Screen Event Setup Tests func testSetupScreenEvent_shouldCreateNewScreenEntityForDifferentScreen() { diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 5c1a4415..d9f092b4 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -368,6 +368,89 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + func testOnSocketEventSent_shouldSelectSurvey_WhenHigherPriorityFlowWasSeen() { + // Arrange + let expectation = XCTestExpectation(description: "Unseen survey should be selected") + let message = Message(payload: makeFlowAndSurveyPayload()) + userpilot.analyticsPublisher.onIsExperienceSeen = { experience in + if case .flow = experience { return true } + return false + } + + // Act + experiencesPublisher.onSocketEventSent(EventType.screenEvent, nil, message, true) + + // Assert + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard case .survey(let content) = self.experiencesPublisher.getActiveMobileContent() else { + XCTFail("Expected unseen survey content") + expectation.fulfill() + return + } + XCTAssertEqual(content.id, 20) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testOnSocketEventSent_shouldNotCacheDuplicateActiveExperience() throws { + // Arrange + let expectation = XCTestExpectation(description: "Duplicate response should be ignored") + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + userpilot.experienceStateManager.markAutomaticTrigger(.flow(content: flow)) + userpilot.experienceStateManager.markActiveFromCurrentState(content: .flow(content: flow)) + + // Act + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, + nil, + Message(payload: MockContentFactory.makeFlowContentPayload()), + true + ) + + // Assert + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertNil(self.userpilot.experienceStateManager.getCachedExperienceContent()) + XCTAssertNil(self.experiencesPublisher.getActiveMobileContent()) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testPublishInternalSDKEvent_shouldIgnoreCachedAutomaticExperience_WhenSeenBeforeReplay() throws { + // Arrange + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + userpilot.analyticsPublisher.onIsExperienceSeen = { _ in true } + userpilot.experienceStateManager.markCachedAutomatic(.flow(content: flow)) + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": flow.id] + ) + closeEvent.isCloseEvent = true + + // Act + experiencesPublisher.publishInternalSDKEvent(closeEvent) + + // Assert + XCTAssertNil(userpilot.experienceStateManager.getCachedExperienceContent()) + XCTAssertNil(experiencesPublisher.getActiveMobileContent()) + if case .idle = userpilot.experienceStateManager.getCurrentState() { + // Expected state. + } else { + XCTFail("Expected idle state after ignoring cached seen content") + } + } + // MARK: - onNewMessage Tests func testOnNewMessage_shouldProcessFlowContent_WhenValidPayload() { @@ -593,6 +676,22 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertTrue(publishFakeReloadEventCalled) } + private func makeFlowAndSurveyPayload() -> [String: Any] { + var payload = MockContentFactory.makeFlowContentPayload() + payload["surveys"] = [ + "id": 20, + "type": "list", + "modules": [], + "metadata": NSNull(), + "theme_data": ["id": 22, "theme_data": NSNull()], + "screens": ["Home"], + "screen_type": "selected", + "locale_code": "en", + "time_delay": 0 + ] + return payload + } + // MARK: - showThankYouMessage Tests func testShowThankYouMessage_shouldTriggerThankYouView() { diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index e838b9ff..817a49ad 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -334,6 +334,11 @@ class MockAnalyticsPublisher: AnalyticsPublishing { onExperiencePublished?(experienceType, experienceId) } + var onIsExperienceSeen: ((ExperienceContent) -> Bool)? + func isExperienceSeen(_ experienceContent: ExperienceContent) -> Bool { + onIsExperienceSeen?(experienceContent) ?? false + } + var isStartSession: Bool = false var screenEntity: ScreenViewEntity? From 8526bb85e777a1c079264aa46803040128002330 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 21 Jul 2026 12:57:38 +0300 Subject: [PATCH 08/95] =?UTF-8?q?=F0=9F=9A=B8=20Add=20full=20featured=20Co?= =?UTF-8?q?nfigActivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserpilotSample.xcodeproj/project.pbxproj | 56 ++-- .../ConfigurationsDialogViewController.swift | 67 ---- .../ConfigurationsDialogViewController.xib | 135 --------- .../Cells/ConfigFlagTableViewCell.swift | 111 +++++++ .../Featues/Config/ConfigViewController.swift | 285 ++++++++++++++++++ .../Featues/Config/Models/ConfigFlag.swift | 134 ++++++++ .../MainViewController+TableView.swift | 2 +- .../Featues/Main/MainViewController.swift | 16 +- .../Managers/Storage/StorageManager.swift | 6 + .../Managers/UI/DialogManager.swift | 15 - .../Managers/Userpilot/UserpilotManager.swift | 18 +- 11 files changed, 594 insertions(+), 251 deletions(-) delete mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift delete mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.xib create mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Cells/ConfigFlagTableViewCell.swift create mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/ConfigViewController.swift create mode 100644 Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Models/ConfigFlag.swift diff --git a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj index 4dc996e5..74e1e434 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj +++ b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj @@ -21,9 +21,10 @@ AB3D7E202D1413EA00FC4ECF /* Userpilot in Frameworks */ = {isa = PBXBuildFile; productRef = AB3D7E1F2D1413EA00FC4ECF /* Userpilot */; }; AB46B0A42D07134B0054D249 /* Userpilot in Frameworks */ = {isa = PBXBuildFile; productRef = AB46B0A32D07134B0054D249 /* Userpilot */; }; AB4721352DBA1E8800B92EE2 /* AppDelegate+PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4721342DBA1E7900B92EE2 /* AppDelegate+PushNotification.swift */; }; - AB585F622CD79C2800E2A66D /* ConfigurationsDialogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB585F612CD79C2800E2A66D /* ConfigurationsDialogViewController.swift */; }; - AB585F642CD79C3000E2A66D /* ConfigurationsDialogViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = AB585F632CD79C3000E2A66D /* ConfigurationsDialogViewController.xib */; }; AB585F662CD79D8C00E2A66D /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB585F652CD79D8C00E2A66D /* StorageManager.swift */; }; + FACF000000000000000000B1 /* ConfigViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A1 /* ConfigViewController.swift */; }; + FACF000000000000000000B2 /* ConfigFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A2 /* ConfigFlag.swift */; }; + FACF000000000000000000B3 /* ConfigFlagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */; }; AB5ED5932D0E07D5002C815A /* PaddingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5ED5922D0E07CD002C815A /* PaddingLabel.swift */; }; AB6ABAA62CE88B9300FB72A8 /* SDKEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6ABAA52CE88B9300FB72A8 /* SDKEventsViewController.swift */; }; AB6ABAAC2CE88FFD00FB72A8 /* SDKEventTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6ABAAA2CE88FFD00FB72A8 /* SDKEventTableViewCell.swift */; }; @@ -102,9 +103,10 @@ AB1942CC2CD2626C00924844 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/EventTableViewCell.xcstrings; sourceTree = ""; }; AB2564922DB4F55000393126 /* UserpilotSample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = UserpilotSample.entitlements; sourceTree = ""; }; AB4721342DBA1E7900B92EE2 /* AppDelegate+PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+PushNotification.swift"; sourceTree = ""; }; - AB585F612CD79C2800E2A66D /* ConfigurationsDialogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationsDialogViewController.swift; sourceTree = ""; }; - AB585F632CD79C3000E2A66D /* ConfigurationsDialogViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationsDialogViewController.xib; sourceTree = ""; }; AB585F652CD79D8C00E2A66D /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; + FACF000000000000000000A1 /* ConfigViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigViewController.swift; sourceTree = ""; }; + FACF000000000000000000A2 /* ConfigFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFlag.swift; sourceTree = ""; }; + FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFlagTableViewCell.swift; sourceTree = ""; }; AB585F672CD79EF400E2A66D /* Dev.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Dev.xcconfig; sourceTree = ""; }; AB5ED5922D0E07CD002C815A /* PaddingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingLabel.swift; sourceTree = ""; }; AB6ABAA52CE88B9300FB72A8 /* SDKEventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKEventsViewController.swift; sourceTree = ""; }; @@ -206,15 +208,6 @@ path = Properties; sourceTree = ""; }; - AB585F602CD79C1100E2A66D /* Configurations */ = { - isa = PBXGroup; - children = ( - AB585F612CD79C2800E2A66D /* ConfigurationsDialogViewController.swift */, - AB585F632CD79C3000E2A66D /* ConfigurationsDialogViewController.xib */, - ); - path = Configurations; - sourceTree = ""; - }; AB6ABAA42CE8890700FB72A8 /* SDKEvents */ = { isa = PBXGroup; children = ( @@ -258,6 +251,7 @@ AB6DBD352C68CD3B0009BB11 /* Featues */ = { isa = PBXGroup; children = ( + FACF000000000000000000C1 /* Config */, ABEB31742F73F58400E1789D /* AutoCapture */, AB6ABAA42CE8890700FB72A8 /* SDKEvents */, ABCDBF3E2CAEAB250083DF1D /* DeepLink */, @@ -356,12 +350,37 @@ ABACA85E2C96E0C2006C9B77 /* Dialogs */ = { isa = PBXGroup; children = ( - AB585F602CD79C1100E2A66D /* Configurations */, AB585F5F2CD79C0400E2A66D /* Properties */, ); path = Dialogs; sourceTree = ""; }; + FACF000000000000000000C1 /* Config */ = { + isa = PBXGroup; + children = ( + FACF000000000000000000C2 /* Models */, + FACF000000000000000000C3 /* Cells */, + FACF000000000000000000A1 /* ConfigViewController.swift */, + ); + path = Config; + sourceTree = ""; + }; + FACF000000000000000000C2 /* Models */ = { + isa = PBXGroup; + children = ( + FACF000000000000000000A2 /* ConfigFlag.swift */, + ); + path = Models; + sourceTree = ""; + }; + FACF000000000000000000C3 /* Cells */ = { + isa = PBXGroup; + children = ( + FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; ABACA8632C96E3E9006C9B77 /* UI */ = { isa = PBXGroup; children = ( @@ -643,9 +662,8 @@ ABAC8E6E2CE9E9D0005EEC9D /* SDKEventTableViewCell.xib in Resources */, AB1942CB2CD2626C00924844 /* EventTableViewCell.xib in Resources */, AB1942AC2CD25DFE00924844 /* Localizable.xcstrings in Resources */, - AB1942BB2CD260F000924844 /* IdentifyViewController.xib in Resources */, - AB585F642CD79C3000E2A66D /* ConfigurationsDialogViewController.xib in Resources */, - AB1942B72CD260CA00924844 /* DeepLinkViewController.xib in Resources */, + AB1942BB2CD260F000924844 /* IdentifyViewController.xib in Resources */, + AB1942B72CD260CA00924844 /* DeepLinkViewController.xib in Resources */, AB1942AF2CD25FD200924844 /* ScreenTwoViewController.xib in Resources */, AB1942C32CD261F100924844 /* CustomEventViewController.xib in Resources */, C5C897C52C5CFFFB0083928E /* LaunchScreen.storyboard in Resources */, @@ -703,7 +721,9 @@ ABF38C7C2C73688000013F18 /* MainViewController.swift in Sources */, ABF38C812C73719300013F18 /* Content.swift in Sources */, ABED3F692C985E2000004D1C /* CustomEventViewController.swift in Sources */, - AB585F622CD79C2800E2A66D /* ConfigurationsDialogViewController.swift in Sources */, + FACF000000000000000000B1 /* ConfigViewController.swift in Sources */, + FACF000000000000000000B2 /* ConfigFlag.swift in Sources */, + FACF000000000000000000B3 /* ConfigFlagTableViewCell.swift in Sources */, ABCD472E2F793C7600D71EA8 /* IgnoredScreenDemoViewController.swift in Sources */, ABAD7AC02F7BE8C6003BC33A /* AutoCaptureTabsTestViewController.swift in Sources */, ABF38C872C73889100013F18 /* ScreenOneViewController.swift in Sources */, diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift deleted file mode 100644 index 282d179a..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// ConfigurationsDialogViewController.swift -// UserpilotSample -// -// Created by Motasem Hamed on 03/11/2024. -// - -import Foundation -import UIKit - -class ConfigurationsDialogViewController: UIViewController { - - // MARK: - IBOutlets - - @IBOutlet weak var textFieldAppToken: UITextField! - - // MARK: - Properties - private var doneButtonHandler: (() -> Void)? - - // MARK: - Initialization - - public init(doneButtonHandler: (() -> Void)?) { - super.init(nibName: "ConfigurationsDialogViewController", bundle: nil) - self.doneButtonHandler = doneButtonHandler - } - - required public init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Actions - - @IBAction func cancelButtonHandler(_ sender: UIButton) { - self.excuteCancelButton() - } - - @IBAction func doneButtonHandler(_ sender: UIButton) { - self.excuteDoneButton() - } - - // MARK: - Helper methods - - func excuteCancelButton() { - self.dismiss(animated: true) - } - - func excuteDoneButton() { - if !self.isViewLoaded { - self.executeDoneButtonHandler() - return - } - - self.dismiss(animated: true, completion: { - self.executeDoneButtonHandler() - }) - } - - func executeDoneButtonHandler() { - if self.doneButtonHandler != nil, - let appToken = textFieldAppToken.text, !appToken.isEmpty { - StorageManager.shared.set(value: appToken, forKey: StorageManager.Keys.appToken) - self.doneButtonHandler?() - } - dismiss(animated: true) - } - -} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.xib deleted file mode 100644 index c6017c23..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.xib +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Cells/ConfigFlagTableViewCell.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Cells/ConfigFlagTableViewCell.swift new file mode 100644 index 00000000..17ee7622 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Cells/ConfigFlagTableViewCell.swift @@ -0,0 +1,111 @@ +// +// ConfigFlagTableViewCell.swift +// UserpilotSample +// +// Created by Userpilot on 21/07/2026. +// + +import UIKit + +/// Renders a single `ConfigFlag`: title, description and an on/off switch. +final class ConfigFlagTableViewCell: UITableViewCell { + + static let reuseIdentifier = "ConfigFlagTableViewCell" + + // MARK: - UI Components + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .boldSystemFont(ofSize: 14) + label.textColor = .label + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = .secondaryLabel + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let flagSwitch: UISwitch = { + let toggle = UISwitch() + toggle.translatesAutoresizingMaskIntoConstraints = false + return toggle + }() + + private let dividerView: UIView = { + let view = UIView() + view.backgroundColor = .separator + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + // MARK: - Callback + + /// Called whenever the user toggles the switch. + private var onToggle: ((Bool) -> Void)? + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupViews() { + selectionStyle = .none + contentView.addSubview(titleLabel) + contentView.addSubview(descriptionLabel) + contentView.addSubview(flagSwitch) + contentView.addSubview(dividerView) + + flagSwitch.setContentHuggingPriority(.required, for: .horizontal) + flagSwitch.setContentCompressionResistancePriority(.required, for: .horizontal) + flagSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: flagSwitch.leadingAnchor, constant: -12), + + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + descriptionLabel.trailingAnchor.constraint(equalTo: flagSwitch.leadingAnchor, constant: -12), + + flagSwitch.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + flagSwitch.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + dividerView.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 12), + dividerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + dividerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + dividerView.heightAnchor.constraint(equalToConstant: 1), + dividerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + // MARK: - Configuration + + func configure(with flag: ConfigFlag, isOn: Bool, onToggle: @escaping (Bool) -> Void) { + titleLabel.text = flag.title + descriptionLabel.text = flag.description + flagSwitch.isOn = isOn + self.onToggle = onToggle + } + + // MARK: - Actions + + @objc private func switchChanged() { + onToggle?(flagSwitch.isOn) + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/ConfigViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/ConfigViewController.swift new file mode 100644 index 00000000..9b9516b7 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/ConfigViewController.swift @@ -0,0 +1,285 @@ +// +// ConfigViewController.swift +// UserpilotSample +// +// Created by Userpilot on 21/07/2026. +// +// Screen that lets the developer configure the Userpilot SDK at runtime: +// the app token plus every boolean flag of `Userpilot.Config`. +// +// Values are persisted to `StorageManager` and read back by +// `UserpilotManager` the next time the SDK is initialised, so the app must be +// restarted for changes to take effect. +// + +import UIKit + +final class ConfigViewController: BaseViewController { + + // MARK: - Sections + + private enum Section: Int, CaseIterable { + case token + case flags + } + + // MARK: - Properties + + private let flags = ConfigFlag.allCases + + /// In-memory copy of the current toggle state, seeded from persisted values. + private lazy var values: [ConfigFlag: Bool] = { + var map = [ConfigFlag: Bool]() + flags.forEach { map[$0] = $0.value } + return map + }() + + // MARK: - UI Components + + private let backButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage(systemName: "chevron.left"), for: .normal) + button.tintColor = .label + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = "Configuration" + label.font = .boldSystemFont(ofSize: 16) + label.textColor = .label + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let tokenTextField: UITextField = { + let textField = UITextField() + textField.placeholder = "Enter your app token" + textField.borderStyle = .roundedRect + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.clearButtonMode = .whileEditing + textField.returnKeyType = .done + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private let tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .onDrag + tableView.translatesAutoresizingMaskIntoConstraints = false + return tableView + }() + + private let restartNoteLabel: UILabel = { + let label = UILabel() + label.text = "Changes are applied on the next launch. The app will restart after saving." + label.font = .systemFont(ofSize: 12) + label.textColor = .secondaryLabel + label.textAlignment = .center + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let saveButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Save & Restart", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .boldSystemFont(ofSize: 14) + button.backgroundColor = .systemBlue + button.layer.cornerRadius = 8 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + setupViews() + setupTableView() + setupActions() + tokenTextField.text = StorageManager.shared.get(forKey: StorageManager.Keys.appToken) ?? "" + } + + // MARK: - Setup + + private func setupViews() { + view.addSubview(backButton) + view.addSubview(titleLabel) + view.addSubview(tableView) + view.addSubview(restartNoteLabel) + view.addSubview(saveButton) + + let safeArea = view.safeAreaLayoutGuide + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 12), + titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + backButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + backButton.widthAnchor.constraint(equalToConstant: 30), + backButton.heightAnchor.constraint(equalToConstant: 30), + + tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: restartNoteLabel.topAnchor, constant: -8), + + restartNoteLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + restartNoteLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + restartNoteLabel.bottomAnchor.constraint(equalTo: saveButton.topAnchor, constant: -8), + + saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + saveButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -16), + saveButton.heightAnchor.constraint(equalToConstant: 45) + ]) + } + + private func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register( + ConfigFlagTableViewCell.self, + forCellReuseIdentifier: ConfigFlagTableViewCell.reuseIdentifier + ) + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 80 + } + + private func setupActions() { + backButton.addTarget(self, action: #selector(onBackTapped), for: .touchUpInside) + saveButton.addTarget(self, action: #selector(onSaveAndRestartTapped), for: .touchUpInside) + tokenTextField.delegate = self + } + + // MARK: - Actions + + @objc private func onBackTapped() { + close() + } + + @objc private func onSaveAndRestartTapped() { + let token = tokenTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !token.isEmpty else { + presentMessage(title: "Missing app token", message: "Please enter your app token.") + return + } + + StorageManager.shared.set(value: token, forKey: StorageManager.Keys.appToken) + flags.forEach { flag in + StorageManager.shared.set(value: values[flag] ?? flag.defaultValue, forKey: flag.key) + } + + let alert = UIAlertController( + title: nil, + message: "Configuration saved. Restarting…", + preferredStyle: .alert + ) + present(alert, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + exit(0) + } + } + + private func presentMessage(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + +// MARK: - UITableViewDataSource + +extension ConfigViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + Section.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section(rawValue: section) { + case .token: + return 1 + case .flags: + return flags.count + case .none: + return 0 + } + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch Section(rawValue: section) { + case .token: + return "App Token" + case .flags: + return "SDK Config Flags" + case .none: + return nil + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Section(rawValue: indexPath.section) { + case .token: + return tokenCell(for: tableView) + case .flags: + let flag = flags[indexPath.row] + let cell = tableView.dequeueReusableCell( + withIdentifier: ConfigFlagTableViewCell.reuseIdentifier, + for: indexPath + ) + (cell as? ConfigFlagTableViewCell)?.configure( + with: flag, + isOn: values[flag] ?? flag.defaultValue + ) { [weak self] isOn in + self?.values[flag] = isOn + } + return cell + case .none: + return UITableViewCell() + } + } + + private func tokenCell(for tableView: UITableView) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.backgroundColor = .clear + if tokenTextField.superview !== cell.contentView { + tokenTextField.removeFromSuperview() + cell.contentView.addSubview(tokenTextField) + NSLayoutConstraint.activate([ + tokenTextField.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 6), + tokenTextField.leadingAnchor.constraint( + equalTo: cell.contentView.leadingAnchor, constant: 16), + tokenTextField.trailingAnchor.constraint( + equalTo: cell.contentView.trailingAnchor, constant: -16), + tokenTextField.bottomAnchor.constraint( + equalTo: cell.contentView.bottomAnchor, constant: -6), + tokenTextField.heightAnchor.constraint(equalToConstant: 44) + ]) + } + return cell + } +} + +// MARK: - UITableViewDelegate + +extension ConfigViewController: UITableViewDelegate {} + +// MARK: - UITextFieldDelegate + +extension ConfigViewController: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Models/ConfigFlag.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Models/ConfigFlag.swift new file mode 100644 index 00000000..05303e79 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Models/ConfigFlag.swift @@ -0,0 +1,134 @@ +// +// ConfigFlag.swift +// UserpilotSample +// +// Created by Userpilot on 21/07/2026. +// + +import Foundation + +/// Represents a single boolean flag of the Userpilot SDK `Userpilot.Config`. +/// +/// Each case maps an SDK config option to a `UserDefaults` `key` and its +/// `defaultValue`, together with a human readable `title` and a `description` +/// explaining what the flag means for the Userpilot SDK. The Config screen +/// renders one toggle per case and `UserpilotManager` reads the persisted +/// `value` when building the SDK configuration, so the app must be restarted +/// for changes to take effect. +enum ConfigFlag: CaseIterable { + + case loggingEnabled + case useInAppBrowser + case allowReceiveEventsFromExternalSource + // swiftlint:disable:next identifier_name + case disableRequestPushNotificationsPermission + case enableScreenAutoCapture + case enableInteractionAutoCapture + case enableInteractionTextCapture + // swiftlint:disable:next identifier_name + case enableInteractionAccessibilityLabelCapture + case enableInteractionValueCapture + + /// The `UserDefaults` key used to persist this flag. + var key: String { + switch self { + case .loggingEnabled: + return "CONFIG_LOGGING_ENABLED" + case .useInAppBrowser: + return "CONFIG_USE_IN_APP_BROWSER" + case .allowReceiveEventsFromExternalSource: + return "CONFIG_ALLOW_RECEIVE_EVENTS_FROM_EXTERNAL_SOURCE" + case .disableRequestPushNotificationsPermission: + return "CONFIG_DISABLE_REQUEST_PUSH_NOTIFICATIONS_PERMISSION" + case .enableScreenAutoCapture: + return "CONFIG_ENABLE_SCREEN_AUTO_CAPTURE" + case .enableInteractionAutoCapture: + return "CONFIG_ENABLE_INTERACTION_AUTO_CAPTURE" + case .enableInteractionTextCapture: + return "CONFIG_ENABLE_INTERACTION_TEXT_CAPTURE" + case .enableInteractionAccessibilityLabelCapture: + return "CONFIG_ENABLE_INTERACTION_ACCESSIBILITY_LABEL_CAPTURE" + case .enableInteractionValueCapture: + return "CONFIG_ENABLE_INTERACTION_VALUE_CAPTURE" + } + } + + /// Human readable title matching the SDK config option name. + var title: String { + switch self { + case .loggingEnabled: + return "loggingEnabled" + case .useInAppBrowser: + return "useInAppBrowser" + case .allowReceiveEventsFromExternalSource: + return "allowReceiveEventsFromExternalSource" + case .disableRequestPushNotificationsPermission: + return "disableRequestPushNotificationsPermission" + case .enableScreenAutoCapture: + return "enableScreenAutoCapture" + case .enableInteractionAutoCapture: + return "enableInteractionAutoCapture" + case .enableInteractionTextCapture: + return "enableInteractionTextCapture" + case .enableInteractionAccessibilityLabelCapture: + return "enableInteractionAccessibilityLabelCapture" + case .enableInteractionValueCapture: + return "enableInteractionValueCapture" + } + } + + /// Explanation of what the flag does for the Userpilot SDK. + var description: String { + switch self { + case .loggingEnabled: + return "Enables verbose SDK logs in the Xcode console. Turn on while integrating " + + "to debug Userpilot behaviour; keep it off in production." + case .useInAppBrowser: + return "Opens experience/URL links inside an in-app SFSafariViewController instead " + + "of an external browser." + case .allowReceiveEventsFromExternalSource: + return "When set on the default (host) instance, autocapture events coming from " + + "embedded/vendor instances are also forwarded to this instance's analytics." + case .disableRequestPushNotificationsPermission: + return "Prevents the SDK from requesting the push notifications permission. Enable " + + "it when your app manages the permission itself." + case .enableScreenAutoCapture: + return "Automatically tracks UIViewController screen views (UIKit apps) without " + + "manual screen() calls." + case .enableInteractionAutoCapture: + return "Automatically captures user interactions (taps and value changes) as " + + "events (UIKit apps)." + case .enableInteractionTextCapture: + return "Includes the visible text/labels of tapped views in autocapture events. " + + "Disable it to avoid capturing PII or sensitive data." + case .enableInteractionAccessibilityLabelCapture: + return "Includes accessibility labels of views in autocapture events. Use it " + + "together with text capture for richer element targeting." + case .enableInteractionValueCapture: + return "Captures value payloads for change events (switch state, slider/segment " + + "values, selected date/time and picker selections)." + } + } + + /// The default value used when the flag has never been persisted. + var defaultValue: Bool { + switch self { + case .loggingEnabled, + .useInAppBrowser, + .enableScreenAutoCapture, + .enableInteractionAutoCapture, + .enableInteractionTextCapture, + .enableInteractionAccessibilityLabelCapture, + .enableInteractionValueCapture: + return true + case .allowReceiveEventsFromExternalSource, + .disableRequestPushNotificationsPermission: + return false + } + } + + /// The persisted value for this flag, falling back to `defaultValue` when never saved. + var value: Bool { + StorageManager.shared.bool(forKey: key, default: defaultValue) + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift index 49705a5b..4f791765 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift @@ -33,7 +33,7 @@ extension MainViewController: UITableViewDelegate, UITableViewDataSource { case .events: FlowRoutingManager.shared.openViewController(CustomEventViewController.newInstance()) case .configurations: - showConfigurationDialog() + openConfigScreen() case .eventsLog: FlowRoutingManager.shared.openViewController(SDKEventsViewController.newInstance()) case .autoCapture: diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift index 85aeed7c..a7f7f537 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift @@ -22,6 +22,8 @@ class MainViewController: BaseViewController { internal lazy var content: [Content] = [.identify, .screens, .events, .eventsLog, .autoCapture, .configurations] + private var didPresentInitialConfig = false + // MARK: - Override override func viewDidLoad() { super.viewDidLoad() @@ -35,17 +37,15 @@ class MainViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if let appToken: String? = StorageManager.shared.get(forKey: StorageManager.Keys.appToken), - appToken == nil { - showConfigurationDialog() + let appToken: String? = StorageManager.shared.get(forKey: StorageManager.Keys.appToken) + if !didPresentInitialConfig, appToken?.isEmpty ?? true { + didPresentInitialConfig = true + openConfigScreen() } } - internal func showConfigurationDialog() { - DialogManager.shared().showConfigurationDialog { [weak self] in - guard self != nil else { return } - exit(0) - } + internal func openConfigScreen() { + FlowRoutingManager.shared.openViewController(ConfigViewController()) } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Storage/StorageManager.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Storage/StorageManager.swift index b8f70442..e4a16292 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Storage/StorageManager.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Storage/StorageManager.swift @@ -29,6 +29,12 @@ class StorageManager { return UserDefaults.standard.value(forKey: key) as? T } + // Retrieve a boolean from UserDefaults, falling back to `defaultValue` when never saved + func bool(forKey key: String, default defaultValue: Bool) -> Bool { + guard hasValue(forKey: key) else { return defaultValue } + return UserDefaults.standard.bool(forKey: key) + } + // Remove a value from UserDefaults func removeValue(forKey key: String) { UserDefaults.standard.removeObject(forKey: key) diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/UI/DialogManager.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/UI/DialogManager.swift index fbed3311..d9ee5793 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/UI/DialogManager.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/UI/DialogManager.swift @@ -46,19 +46,4 @@ class DialogManager { } } - // MARK: - Configuration Dialog - func showConfigurationDialog(doneButtonHandler: (() -> Void)?) { - - let dialog = ConfigurationsDialogViewController( - doneButtonHandler: doneButtonHandler) - - dialog.modalPresentationStyle = .overCurrentContext - dialog.modalTransitionStyle = .crossDissolve - - if let topMostController = FlowRoutingManager.topMostController() { - if !topMostController.isKind(of: ConfigurationsDialogViewController.self) { - topMostController.present(dialog, animated: false, completion: nil) - } - } - } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift index f18d499b..ce265499 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift @@ -37,13 +37,17 @@ class UserpilotManager { func initialize() { guard let appToken: String = StorageManager.shared.get(forKey: StorageManager.Keys.appToken) else { return } userpilot = Userpilot(config: Userpilot.Config(token: appToken) - .logging(enabled: true) - .enableUseInAppBrowser() - .enableScreenAutoCapture() - .enableInteractionAutoCapture() - .enableInteractionTextCapture() - .enableInteractionAccessibilityLabelCapture() - .enableInteractionValueCapture() + .logging(enabled: ConfigFlag.loggingEnabled.value) + .enableUseInAppBrowser(ConfigFlag.useInAppBrowser.value) + .allowReceiveEventsFromExternalSource(ConfigFlag.allowReceiveEventsFromExternalSource.value) + .disableRequestPushNotificationsPermission( + ConfigFlag.disableRequestPushNotificationsPermission.value) + .enableScreenAutoCapture(ConfigFlag.enableScreenAutoCapture.value) + .enableInteractionAutoCapture(ConfigFlag.enableInteractionAutoCapture.value) + .enableInteractionTextCapture(ConfigFlag.enableInteractionTextCapture.value) + .enableInteractionAccessibilityLabelCapture( + ConfigFlag.enableInteractionAccessibilityLabelCapture.value) + .enableInteractionValueCapture(ConfigFlag.enableInteractionValueCapture.value) ) userpilot?.navigationDelegate = self userpilot?.analyticsDelegate = self From dd32929053c8537996d9f0df528d131ec0a0815f Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 12 Aug 2026 13:47:09 +0300 Subject: [PATCH 09/95] =?UTF-8?q?=E2=9C=A8=20Send=20identify=20once=20per?= =?UTF-8?q?=20screen=20when=20user=20data=20is=20unchanged=20and=20re-sync?= =?UTF-8?q?=20push=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/AnalyticsPublisher.swift | 110 +- .../IdentifyRefreshStateMachine.swift | 159 +++ .../PushNotificationMonitor.swift | 49 +- Tests/UserpilotTests/MockUserpilot.swift | 5 + ...12-unchanged-identify-per-screen-design.md | 944 ++++++++++++++++++ 5 files changed, 1243 insertions(+), 24 deletions(-) create mode 100644 Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift create mode 100644 docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index eabc760e..5aa2bbdf 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -134,6 +134,15 @@ internal class AnalyticsPublisher { return container?.resolve(SessionMonitoring.self) } + /// Push notification monitoring, used to re-assert the device token for a returning user. + /// + /// `AnalyticsPublishing` is registered *before* `PushNotificationMonitoring` in + /// `initializeContainer()`, and `PushNotificationMonitor.init` resolves this publisher, so this + /// must never be resolved from `init` — only on demand. + private weak var pushNotificationMonitor: PushNotificationMonitoring? { + return container?.resolve(PushNotificationMonitoring.self) + } + /// Decorator used to modify event properties before sending. private let autoPropertyDecorator: AutoPropertyDecoratoring @@ -154,6 +163,10 @@ internal class AnalyticsPublisher { /// Event throttling mechanism to prevent spam private lazy var eventThrottle = EventThrottle(throttleDuration: 1.0) + /// Policy for identify events that carry no new user data: whether to forward one to the + /// backend for the current screen, and whether a forwarded one still owes a push-token sync. + private let identifyRefreshStateMachine = IdentifyRefreshStateMachine() + /** * Tracks the last screen viewed and its state including seen experiences. * Contains boolean flag for fake reload state. @@ -243,6 +256,8 @@ extension AnalyticsPublisher: AnalyticsPublishing { } // Reset start session for next login user startSession = true + // A new user starts with a fresh refresh allowance and owes no push-token sync + identifyRefreshStateMachine.onUserChanged() // Reset content states experiencesPublisher?.logout() // Clear seen contents from screenViewEntity @@ -276,6 +291,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { func reset() { startSession = true eventThrottle.clear() + identifyRefreshStateMachine.onUserChanged() } /** @@ -306,7 +322,11 @@ extension AnalyticsPublisher: AnalyticsPublishing { return } - guard !didHandleIdentifyEvent(event) else { return } + // Run identify events through the refresh policy. `nil` for every other event type. + let refreshDecision = event.isIdentifyEvent ? handleIdentifyEvent(event) : nil + + // Nothing new for the backend, and this screen already refreshed the user + guard refreshDecision != .suppress else { return } // Check if socket is in shutdown state // For example: getting event while logging out, ignore the event @@ -328,29 +348,45 @@ extension AnalyticsPublisher: AnalyticsPublishing { } // Socket is opened, process the event immediately - processEvent(event) + processEvent(event, isIdentifyRefresh: refreshDecision == .refresh) } } /** - * Handles identify events by checking for duplicate users and caching the event. + * Builds the refresh request from the collaborators `IdentifyRefreshStateMachine` deliberately + * does not own. * - * - Parameter event: The identify event to handle - * - Returns: true if the event should be ignored (same user), false if processing should continue + * - Parameter event: The identify event to classify + * - Returns: The facts the refresh policy needs to decide */ - private func didHandleIdentifyEvent(_ event: Event) -> Bool { - guard event.isIdentifyEvent else { return false } + private func identifyRefreshRequest(for event: Event) -> IdentifyRefreshRequest { + IdentifyRefreshRequest( + carriesNoNewData: storage.user.isNotEmpty + && User.fromJson(storage.user).isSameIdentifyEvent(event: event), + isAnonymousUser: storage.anonymousUserId.isNotEmpty + && event.userId == storage.anonymousUserId, + // A pending identify has not reached the backend yet, so this is `onSocketClosed` + // replaying it, not a fresh host-app call. + isPendingReplay: cachedIdentifyEvent != nil && !socketManager.isSocketOpened + ) + } - // When same user, return true to stop and ignore event - guard !(storage.user.isNotEmpty && User.fromJson(storage.user).isSameIdentifyEvent(event: event)) else { - return true - } + /** + * Runs an identify event through the refresh policy and, unless it is suppressed, caches it as + * the pending identify event. + * + * - Parameter event: The identify event to handle + * - Returns: The policy decision for this event + */ + private func handleIdentifyEvent(_ event: Event) -> IdentifyRefreshDecision { + let decision = identifyRefreshStateMachine.transition(identifyRefreshRequest(for: event)) + guard decision != .suppress else { return decision } // Update cached user storage.temporaryUser = event.toUser().toJson() cachedIdentifyEvent = event - return false + return decision } /** @@ -389,11 +425,13 @@ extension AnalyticsPublisher: AnalyticsPublishing { * Processes an event based on its type. * * - Parameter event: The event to process + * - Parameter isIdentifyRefresh: True when this identify carries no new user data and is being + * forwarded only so the backend re-affirms the user. */ - private func processEvent(_ event: Event) { + private func processEvent(_ event: Event, isIdentifyRefresh: Bool = false) { switch event.type { case .identify: - identify(event) + identify(event, isIdentifyRefresh: isIdentifyRefresh) case .screen: screen(event) case .event, .autoCaptureEvent: @@ -425,8 +463,10 @@ extension AnalyticsPublisher: AnalyticsPublishing { * If a new user ID is detected, it closes the socket and cleans up for user switching. * * - Parameter event: The identify event containing user information + * - Parameter isIdentifyRefresh: True when this identify carries no new user data and is being + * forwarded only so the backend re-affirms the user. */ - private func identify(_ event: Event) { + private func identify(_ event: Event, isIdentifyRefresh: Bool = false) { tryCatch { guard let userId = event.userId else { return } @@ -434,12 +474,34 @@ extension AnalyticsPublisher: AnalyticsPublishing { if storage.userId.isNotEmpty && userId != storage.userId { userpilot?.clean() logout(socketState: .switchingUser) - } else { - flushPriorityEvents(fakeReloadScreenEvent: true) + return } + + if isIdentifyRefresh { + // Nothing about the user changed, so the backend has nothing to re-evaluate: + // send the identify alone and skip the fake reload screen event. + flushPriorityEvents(canRequestScreenEvent: false) + syncPushTokenIfNeeded() + return + } + + flushPriorityEvents(fakeReloadScreenEvent: true) } } + /** + * Re-publishes the device push token owed by a forwarded identify refresh. + * + * `PushNotificationMonitor.setPushToken` only publishes when the token value changes, so a + * returning user whose token is unchanged would otherwise never re-pair token ↔ user on the + * backend. Called after the identify is on the wire so the backend sees the user first. + */ + private func syncPushTokenIfNeeded() { + // Check the socket first: the obligation must not be consumed while it cannot be fulfilled. + guard canRequestEvent, identifyRefreshStateMachine.consumePushTokenSync() else { return } + pushNotificationMonitor?.resyncPushToken() + } + /** * Processes and sends screen view events. * Handles screen throttling and determines when to trigger screen events. @@ -569,6 +631,9 @@ extension AnalyticsPublisher: AnalyticsPublishing { if isScreenTitleChanged { isNewScreen = true + // A genuinely new screen re-opens the identify refresh allowance + identifyRefreshStateMachine.onScreenChanged() + // New screen: start with an empty set of seen experiences screenViewEntity = ScreenViewEntity( event: event, @@ -760,6 +825,9 @@ extension AnalyticsPublisher: SocketSubscription { func onSocketOpened() { clearCachedEvents() flushPriorityEvents(canRequestScreenEvent: experiencesPublisher?.canRequestScreenEvent() == true) + // Cold start path: an identify refresh cached while the socket was down has just been + // flushed above, so the push token it owes can be re-asserted now. + syncPushTokenIfNeeded() } /** @@ -858,6 +926,14 @@ internal extension AnalyticsPublisher { func mockGetEventsToFlush() -> [Event] { return eventsToFlush } + + func mockGetCachedIdentifyEvent() -> Event? { + return cachedIdentifyEvent + } + + func mockIdentifyRefreshState() -> IdentifyRefreshStateMachine.State { + return identifyRefreshStateMachine.state + } } #endif diff --git a/Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift b/Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift new file mode 100644 index 00000000..08b3041b --- /dev/null +++ b/Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift @@ -0,0 +1,159 @@ +// +// IdentifyRefreshStateMachine.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// +// [Brief Description] +// Owns the policy for `identify` events that carry no new user data ("identify refreshes"): +// whether to forward one to the backend, and whether a forwarded one still owes a push-token +// re-assert. +// + +import Foundation + +/// The facts about an incoming identify event that the refresh policy needs. +/// +/// These are computed by `AnalyticsPublisher` from collaborators the state machine deliberately +/// does not own (`DataStoring`, `SocketEvents`), and passed in as one documented value rather than +/// as positional booleans. +internal struct IdentifyRefreshRequest { + + /// The event adds nothing to the cached user (see `User.isSameIdentifyEvent(event:)`). + let carriesNoNewData: Bool + + /// The event identifies the generated anonymous user, which is excluded from refreshes. + let isAnonymousUser: Bool + + /// The SDK is replaying a still-pending identify after a socket close, not the host app + /// calling `identify` again. Replays bypass the per-screen allowance. + let isPendingReplay: Bool +} + +/// What `AnalyticsPublisher` should do with an identify event. +internal enum IdentifyRefreshDecision { + + /// Carries new user data — take the normal path (identify + fake-reload screen event). + case carriesNewData + + /// Carries nothing new, but forward it so the backend re-affirms the user. No screen event. + case refresh + + /// Carries nothing new and must be dropped. + case suppress +} + +/** + * Owns the policy for `identify` events that carry no new user data. + * + * Such an event is still forwarded to the backend **once per screen** so the backend can re-affirm + * the user, and each forwarded refresh owes exactly one push-token re-assert (the token senders are + * value-guarded, so a returning user whose token is unchanged would otherwise never re-pair + * token ↔ user). + * + * All state lives in a single `State` value. Two independent booleans would allow combinations that + * cannot occur, and would hide the rule that a screen change must not discard an unsettled token + * obligation. + * + * Thread-safe: identify, screen and socket callbacks arrive from different queues. + * + * Mirrored by Android's `IdentifyRefreshStateMachine` — keep both in sync. + */ +internal final class IdentifyRefreshStateMachine { + + /// Lifecycle of the current screen's refresh allowance. + internal enum State: Equatable { + + /// The current screen has not refreshed the user yet. + case refreshAllowed + + /// A refresh was forwarded and still owes a push-token re-assert. + case awaitingPushTokenSync + + /// A refresh was forwarded on this screen and its token sync is done. + case refreshSettled + } + + // MARK: - Properties + + private let lock = NSLock() + + private var _state: State = .refreshAllowed + + /// The current state. Exposed for assertions; callers must not derive control flow from it. + internal var state: State { + lock.lock() + defer { lock.unlock() } + return _state + } + + // MARK: - Transitions + + /** + * Classifies an incoming identify event and advances the allowance when it is forwarded. + * + * - Parameter request: The facts about the incoming identify event. + * - Returns: The decision `AnalyticsPublisher` should act on. + */ + internal func transition(_ request: IdentifyRefreshRequest) -> IdentifyRefreshDecision { + lock.lock() + defer { lock.unlock() } + + guard request.carriesNoNewData else { return .carriesNewData } + + // `anonymous()` re-sends the same generated id with no properties, so there is nothing to + // refresh. Suppress without touching the allowance: an anonymous call must not spend the + // current screen's refresh on behalf of a real user. + guard !request.isAnonymousUser else { return .suppress } + + // A replay of a still-pending identify is the SDK reconnecting, not the host app calling + // again. It must go through, and it re-arms the token obligation because the original + // forward never reached the backend. + if request.isPendingReplay { + _state = .awaitingPushTokenSync + return .refresh + } + + switch _state { + case .refreshAllowed: + _state = .awaitingPushTokenSync + return .refresh + case .awaitingPushTokenSync, .refreshSettled: + return .suppress + } + } + + /** + * Claims the pending push-token re-assert, if one is owed. + * + * - Returns: `true` exactly once per forwarded refresh. + */ + internal func consumePushTokenSync() -> Bool { + lock.lock() + defer { lock.unlock() } + + guard _state == .awaitingPushTokenSync else { return false } + _state = .refreshSettled + return true + } + + /// A genuinely new screen re-opens the refresh allowance. + internal func onScreenChanged() { + lock.lock() + defer { lock.unlock() } + + // A pending token sync belongs to an identify that has not reached the backend yet, not to + // the screen it was requested on. Keep suppressing until it settles — re-sending an + // identical identify before the first one lands would only add noise. + guard _state != .awaitingPushTokenSync else { return } + _state = .refreshAllowed + } + + /// Logout or user switch: the next user starts with a fresh allowance and owes nothing. + internal func onUserChanged() { + lock.lock() + defer { lock.unlock() } + + _state = .refreshAllowed + } +} diff --git a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift index cbe99883..e1f68f5f 100644 --- a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift +++ b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift @@ -27,6 +27,12 @@ internal protocol PushNotificationMonitoring: AnyObject { /// - Parameter deviceToken: The device token received from APNs. func setPushToken(_ deviceToken: Data?) + /// Re-publishes the current device token even when its value has not changed. + /// + /// `setPushToken(_:)` is value-guarded and is therefore a no-op for a returning user whose + /// token is unchanged. This re-asserts the token ↔ user pairing on the backend. + func resyncPushToken() + /// Refreshes the push authorization status and calls the completion handler with the updated status. /// /// - Parameter completion: A closure that is called with the updated authorization status. @@ -100,22 +106,51 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr // Cache the token in all cases so in next identify in same session, we will sync it cachedToken = deviceToken guard - let newToken = deviceToken?.map({ String(format: "%02x", $0) }).joined(), + let newToken = deviceToken.map(hexString(from:)), storage.pushToken != newToken else { return } if analyticsPublisher.canRequestEvent { - analyticsPublisher.publishInternalSDKEvent( - PushNotificationTokenEvent( - appToken: config.token, - userId: storage.userId, - token: newToken), - socketSubscription: self) + publishTokenEvent(newToken) } } + /// Re-publishes the current device token even when its value has not changed. + /// + /// `setPushToken(_:)` is value-guarded, so a returning user whose APNs token did not change + /// would otherwise never re-pair token ↔ user on the backend. Driven by `AnalyticsPublisher` + /// after it forwards an identify that carries no new user data. + func resyncPushToken() { + // Prefer the token the OS handed us this launch; fall back to the persisted one for a warm + // start where `didRegisterForRemoteNotificationsWithDeviceToken` has not fired yet. + guard + let token = cachedToken.map(hexString(from:)) ?? storage.pushToken, + token.isNotEmpty, + analyticsPublisher.canRequestEvent + else { + return + } + + publishTokenEvent(token) + } + + /// Hex representation of a raw APNs device token. + private func hexString(from deviceToken: Data) -> String { + deviceToken.map { String(format: "%02x", $0) }.joined() + } + + /// Publishes the `user_token` event for the given hex token. + private func publishTokenEvent(_ token: String) { + analyticsPublisher.publishInternalSDKEvent( + PushNotificationTokenEvent( + appToken: config.token, + userId: storage.userId, + token: token), + socketSubscription: self) + } + /// Handles the socket event for sending the push token. /// /// - Parameters: diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 17928ed7..fa1301bb 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -346,6 +346,11 @@ class MockPushNotificationMonitor: PushNotificationMonitoring { onSetPushToken?(deviceToken) } + var onResyncPushToken: (() -> Void)? + func resyncPushToken() { + onResyncPushToken?() + } + var onRefreshPushStatus: (() -> Void)? func refreshPushStatus(completion: ((UNAuthorizationStatus) -> Void)?) { onRefreshPushStatus?() diff --git a/docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md b/docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md new file mode 100644 index 00000000..7e03acc8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md @@ -0,0 +1,944 @@ +# Identify refresh — forward an unchanged `identify` once per screen, re-assert the push token + +**Date:** 2026-08-12 +**Platforms:** iOS (`ios-sdk`) **and** Android (`userpilot-android-sdk`) — one design, mirrored +**Public API impact:** none on either platform (no new/changed `@objc` or public Kotlin surface, no config option) + +Vocabulary used throughout: an **identify refresh** is an `identify` event that carries no new user +data but is forwarded anyway so the backend re-affirms the user. It is not a "duplicate" — the SDK +sends it on purpose. + +--- + +## 1. Current behaviour (identical on both platforms) + +**iOS** — [`AnalyticsPublisher.didHandleIdentifyEvent`](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L341-L354): + +```swift +guard !(storage.user.isNotEmpty && User.fromJson(storage.user).isSameIdentifyEvent(event: event)) else { + return true // ignore +} +``` + +**Android** — [`AnalyticsPublisher.handleIdentifyEvent`](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L258-L269): + +```kotlin +if (storage.user.isNotEmpty() && storage.user.toUser().isSameIdentifyEvent(event)) { + return true // ignore +} +``` + +`User.isSameIdentifyEvent` uses **partial-update** semantics on both platforms — the event's +properties/company must be a *subset* of the cached user with equal values. So "identical" really +means *"adds nothing to the cached user"*. + +Three consequences of the unconditional drop: + +1. **The backend never sees the identify.** `storage.user` survives process death (UserDefaults / + SharedPreferences), so on a warm launch an app that identifies the same user produces no + `identify` frame for the entire session. +2. **The drop happens before the socket-state guards**, so `handleClosedSocket` → connect never + runs for it either. +3. **The device push token is never re-asserted.** Both platforms value-guard the token send — + iOS [`setPushToken`](../../../Sources/Userpilot/PushNotification/PushNotificationMonitor.swift#L99-L117) + (`storage.pushToken != newToken`), Android + [`sendFCMToken` / `checkFCMToken`](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/pushNotifications/PushNotificationHandler.kt) + (`it != storage.pushNotificationToken`, checked twice). A returning user whose token has not + changed can never re-pair token ↔ user on the backend. + +For an identify that **does** carry new data with the same `userId`, both publishers call +`flushPriorityEvents(fakeReloadScreenEvent: true)`, which publishes the `identify` frame **and** a +screen frame with `fake_reload: true` + `is_session_start` + `seen_contents` + `seen_surveys`. That +screen frame is what drives backend experience re-evaluation. + +--- + +## 2. Goal + +An `identify` from the host app that carries no new user data must: + +- still reach the backend, **at most once per screen**; +- **also re-publish the push token event** (`user_token`); +- **not** emit the fake-reload screen frame (nothing changed ⇒ nothing to re-evaluate); +- **not apply to anonymous users** — `anonymous()` keeps today's drop-it behaviour. + +## 3. Decisions + +| # | Decision | Chosen | +|---|---|---| +| 1 | When is `user_token` re-published? | Only when the refresh is actually forwarded. Property-change identifies keep today's value-guarded token behaviour. | +| 2 | What re-opens the once-per-screen allowance? | A genuine **screen change**, and **logout / user switch**. *Not* app foreground. | +| 3 | Refresh while the socket is closed (cold start)? | Cache it, open the socket, let `onSocketOpened` flush it; the token sync fires after that flush. | +| 4 | Anonymous users | **Excluded.** A refresh request for the anonymous id is suppressed and does **not** consume the screen allowance. | +| 5 | Where does the state live? | A dedicated `IdentifyRefreshStateMachine` per SDK instance — one enum value, no boolean flags. | + +--- + +## 4. `IdentifyRefreshStateMachine` — the shared design + +### 4.1 Why a state machine and not two booleans + +The naive implementation needs two independent facts: *"has this screen already refreshed?"* and +*"does a forwarded refresh still owe a token sync?"*. Two booleans make four combinations, one of +which is nonsense (`settled && !forwarded`), and they hide an ordering rule that is easy to get +wrong: **a screen change must not silently discard an unsettled token obligation.** Folding both +into a single three-state value makes the illegal combination unrepresentable and turns that +ordering rule into an explicit transition. + +Android already has the precedent — [`ScreenSessionStateMachine`](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/ScreenSessionStateMachine.kt) +is a `@Synchronized internal class`, Koin-`scoped`, constructor-injected into `AnalyticsPublisher`, +exposing `transition(...)` plus intent-named mutators and a sealed decision type. The new machine +follows that shape exactly, and iOS gets the same class so the two SDKs read the same. + +### 4.2 States + +| State | Meaning | +|---|---| +| `refreshAllowed` / `REFRESH_ALLOWED` | The current screen has not refreshed the user yet. Initial state. | +| `awaitingPushTokenSync` / `AWAITING_PUSH_TOKEN_SYNC` | A refresh was forwarded and still owes a push-token re-assert. | +| `refreshSettled` / `REFRESH_SETTLED` | A refresh was forwarded on this screen and its token sync is done. | + +### 4.3 Input and output value types + +The machine owns the **policy**; it does not own `Storage` or `SocketManager`. The three facts it +cannot compute itself arrive in one documented value type rather than as positional booleans: + +```swift +/// The facts about an incoming identify event that the refresh policy needs. +internal struct IdentifyRefreshRequest { + /// The event adds nothing to the cached user (`User.isSameIdentifyEvent`). + let carriesNoNewData: Bool + /// The event identifies the generated anonymous user, which is excluded from refreshes. + let isAnonymousUser: Bool + /// The SDK is replaying a still-pending identify after a socket close, not the host app + /// calling `identify` again. Replays bypass the per-screen allowance. + let isPendingReplay: Bool +} + +/// What the publisher should do with an identify event. +internal enum IdentifyRefreshDecision { + /// Carries new user data — take the normal path (identify + fake-reload screen event). + case carriesNewData + /// Carries nothing new, but forward it so the backend re-affirms the user. No screen event. + case refresh + /// Carries nothing new and must be dropped. + case suppress +} +``` + +Kotlin is the same shape (`internal data class IdentifyRefreshRequest`, +`internal enum class IdentifyRefreshDecision { CARRIES_NEW_DATA, REFRESH, SUPPRESS }`). + +### 4.4 Transition table + +| From | Call | Guard | To | Result | +|---|---|---|---|---| +| any | `transition` | `!carriesNoNewData` | *unchanged* | `carriesNewData` | +| any | `transition` | `isAnonymousUser` | *unchanged* | `suppress` | +| any | `transition` | `isPendingReplay` | `awaitingPushTokenSync` | `refresh` | +| `refreshAllowed` | `transition` | — | `awaitingPushTokenSync` | `refresh` | +| `awaitingPushTokenSync` | `transition` | — | *unchanged* | `suppress` | +| `refreshSettled` | `transition` | — | *unchanged* | `suppress` | +| `awaitingPushTokenSync` | `consumePushTokenSync` | — | `refreshSettled` | `true` | +| others | `consumePushTokenSync` | — | *unchanged* | `false` | +| `awaitingPushTokenSync` | `onScreenChanged` | — | *unchanged* | — | +| others | `onScreenChanged` | — | `refreshAllowed` | — | +| any | `onUserChanged` | — | `refreshAllowed` | — | + +Two transitions deserve their reasoning spelled out in code comments: + +- **`onScreenChanged` is a no-op while awaiting a token sync.** The obligation belongs to an + identify that has not reached the backend yet, not to the screen it was requested on. And + re-sending an identical identify before the first one lands would be pure noise — so continuing + to suppress until it settles is the correct behaviour, not a compromise. +- **`isPendingReplay` bypasses the allowance.** `onSocketClosed` re-enters the *public* `publish` + with the cached identify (iOS [:775](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L775), + Android `onSocketClosed` → `cachedIdentifyEvent?.let { publish(it) }`). Without the bypass that + replay would hit an already-consumed allowance, get suppressed, and the reconnect would silently + lose the user. A **pending** identify (`cachedIdentifyEvent != nil`) while the socket is **not + open** can only be an SDK replay, because a successful send clears `cachedIdentifyEvent` in + `onSocketEventSent`. + +### 4.5 Swift implementation — `Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift` (new) + +```swift +/// Owns the policy for `identify` events that carry no new user data. +/// +/// Such an event is still forwarded to the backend **once per screen** so the backend can +/// re-affirm the user, and each forwarded refresh owes one push-token re-assert (the token +/// senders are value-guarded and would otherwise never re-pair token ↔ user). +/// +/// All state lives in a single `State` value: two independent booleans would allow +/// combinations that cannot occur and would hide the rule that a screen change must not +/// discard an unsettled token obligation. +/// +/// Thread-safe: identify, screen and socket callbacks arrive from different queues. +/// (Mirrors Android's `IdentifyRefreshStateMachine`; keep both in sync.) +internal final class IdentifyRefreshStateMachine { + + /// Lifecycle of the current screen's refresh allowance. + internal enum State: Equatable { + /// The current screen has not refreshed the user yet. + case refreshAllowed + /// A refresh was forwarded and still owes a push-token re-assert. + case awaitingPushTokenSync + /// A refresh was forwarded on this screen and its token sync is done. + case refreshSettled + } + + private let lock = NSLock() + private var _state: State = .refreshAllowed + + /// Current state. Exposed for assertions; callers must not derive control flow from it. + internal var state: State { + lock.lock(); defer { lock.unlock() } + return _state + } + + /// Classifies an incoming identify event and advances the allowance when it is forwarded. + internal func transition(_ request: IdentifyRefreshRequest) -> IdentifyRefreshDecision { + lock.lock(); defer { lock.unlock() } + + guard request.carriesNoNewData else { return .carriesNewData } + + // `anonymous()` re-sends the same generated id with no properties, so there is nothing + // to refresh. Suppress without touching the allowance: an anonymous call must not spend + // the screen's refresh on behalf of a real user. + guard !request.isAnonymousUser else { return .suppress } + + // A replay of a still-pending identify is the SDK reconnecting, not the host app calling + // again. It must go through, and it re-arms the token obligation because the original + // forward never reached the backend. + if request.isPendingReplay { + _state = .awaitingPushTokenSync + return .refresh + } + + switch _state { + case .refreshAllowed: + _state = .awaitingPushTokenSync + return .refresh + case .awaitingPushTokenSync, .refreshSettled: + return .suppress + } + } + + /// Claims the pending push-token re-assert, if one is owed. + /// - Returns: `true` exactly once per forwarded refresh. + internal func consumePushTokenSync() -> Bool { + lock.lock(); defer { lock.unlock() } + guard _state == .awaitingPushTokenSync else { return false } + _state = .refreshSettled + return true + } + + /// A genuinely new screen re-opens the allowance. + internal func onScreenChanged() { + lock.lock(); defer { lock.unlock() } + // A pending token sync belongs to an identify that has not reached the backend yet, not + // to the screen it was requested on. Keep suppressing until it settles — re-sending an + // identical identify before the first one lands would only add noise. + guard _state != .awaitingPushTokenSync else { return } + _state = .refreshAllowed + } + + /// Logout or user switch: the next user starts with a fresh allowance and owes nothing. + internal func onUserChanged() { + lock.lock(); defer { lock.unlock() } + _state = .refreshAllowed + } +} +``` + +`ReadWriteLock` is **not** usable here — its `write(closure:)` is `async(flags: .barrier)` and +cannot return a decision synchronously. `NSLock` is the right primitive. + +### 4.6 Kotlin implementation — `userpilot/src/main/java/com/userpilot/analytics/IdentifyRefreshStateMachine.kt` (new) + +Same doc comment and identical semantics; `@Synchronized` instead of `NSLock`, matching +`ScreenSessionStateMachine`: + +```kotlin +internal class IdentifyRefreshStateMachine { + + internal enum class State { REFRESH_ALLOWED, AWAITING_PUSH_TOKEN_SYNC, REFRESH_SETTLED } + + var state: State = State.REFRESH_ALLOWED + @Synchronized get + private set + + @Synchronized + fun transition(request: IdentifyRefreshRequest): IdentifyRefreshDecision { + if (!request.carriesNoNewData) return IdentifyRefreshDecision.CARRIES_NEW_DATA + if (request.isAnonymousUser) return IdentifyRefreshDecision.SUPPRESS + + if (request.isPendingReplay) { + state = State.AWAITING_PUSH_TOKEN_SYNC + return IdentifyRefreshDecision.REFRESH + } + + return when (state) { + State.REFRESH_ALLOWED -> { + state = State.AWAITING_PUSH_TOKEN_SYNC + IdentifyRefreshDecision.REFRESH + } + State.AWAITING_PUSH_TOKEN_SYNC, + State.REFRESH_SETTLED -> IdentifyRefreshDecision.SUPPRESS + } + } + + @Synchronized + fun consumePushTokenSync(): Boolean { + if (state != State.AWAITING_PUSH_TOKEN_SYNC) return false + state = State.REFRESH_SETTLED + return true + } + + @Synchronized + fun onScreenChanged() { + if (state == State.AWAITING_PUSH_TOKEN_SYNC) return + state = State.REFRESH_ALLOWED + } + + @Synchronized + fun onUserChanged() { + state = State.REFRESH_ALLOWED + } +} +``` + +### 4.7 Wiring — each platform's existing precedent + +| | iOS | Android | +|---|---|---| +| Ownership | `private let identifyRefreshStateMachine = IdentifyRefreshStateMachine()` on `AnalyticsPublisher`, mirroring `eventThrottle` (small stateful policy objects are not DI-registered on iOS) | Koin `scoped { IdentifyRefreshStateMachine() }` + constructor param on `AnalyticsPublisher`, mirroring `ScreenSessionStateMachine` | +| Scope | per `Userpilot` instance | per Koin scope = per `Userpilot` instance | + +The class and its API are identical; only the wiring follows each repo's convention. Both are +directly unit-testable in isolation, which is the point. + +--- + +## 5. iOS changes + +### 5.1 `Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift` — new + +Contains `IdentifyRefreshRequest`, `IdentifyRefreshDecision`, `IdentifyRefreshStateMachine` (§4.3, §4.5). + +### 5.2 `Sources/Userpilot/Analytics/AnalyticsPublisher.swift` + +**(a) Lazy push-monitor resolver**, next to `experiencesPublisher` / `sessionMonitorer` (~line 135): + +```swift +/// Push notification monitoring, used to re-assert the device token for a returning user. +/// +/// `AnalyticsPublishing` is registered *before* `PushNotificationMonitoring` in +/// `initializeContainer()`, and `PushNotificationMonitor.init` resolves this publisher, so this +/// must never be resolved from `init` — only on demand. +private weak var pushNotificationMonitor: PushNotificationMonitoring? { + return container?.resolve(PushNotificationMonitoring.self) +} +``` + +`DIContainer.resolve` `fatalError`s on an unregistered type +([DIContainer.swift:124](../../../Sources/Userpilot/DI/DIContainer.swift#L124)), and +[Userpilot.swift:228-230](../../../Sources/Userpilot/Userpilot.swift#L228-L230) registers +`AnalyticsPublishing` **one line before** `PushNotificationMonitoring` — resolving from `init` +would crash on SDK init. + +**(b) Own the state machine**, next to `eventThrottle` (~line 155): + +```swift +/// Policy for identify events that carry no new user data. +private let identifyRefreshStateMachine = IdentifyRefreshStateMachine() +``` + +**(c) Replace `didHandleIdentifyEvent(_:)`** ([:335-354](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L335-L354)) +with a classifier plus a decision-returning handler: + +```swift +/// Builds the refresh request from the collaborators the state machine deliberately does not own. +private func identifyRefreshRequest(for event: Event) -> IdentifyRefreshRequest { + IdentifyRefreshRequest( + carriesNoNewData: storage.user.isNotEmpty + && User.fromJson(storage.user).isSameIdentifyEvent(event: event), + isAnonymousUser: storage.anonymousUserId.isNotEmpty + && event.userId == storage.anonymousUserId, + // A pending identify has not reached the backend yet, so this is `onSocketClosed` + // replaying it, not a fresh host-app call. + isPendingReplay: cachedIdentifyEvent != nil && !socketManager.isSocketOpened + ) +} + +/** + * Runs an identify event through the refresh policy and, unless it is suppressed, caches it as + * the pending identify event. + * + * - Parameter event: The identify event to handle + * - Returns: The policy decision for this event + */ +private func handleIdentifyEvent(_ event: Event) -> IdentifyRefreshDecision { + let decision = identifyRefreshStateMachine.transition(identifyRefreshRequest(for: event)) + guard decision != .suppress else { return decision } + + // Update cached user + storage.temporaryUser = event.toUser().toJson() + cachedIdentifyEvent = event + + return decision +} +``` + +**(d) `publish(_:)`** ([:301-333](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L301-L333)) — +every socket guard keeps its order; only the de-dup call site changes: + +```swift +// nil for non-identify events +let refreshDecision = event.isIdentifyEvent ? handleIdentifyEvent(event) : nil + +// Nothing new for the backend, and this screen already refreshed the user +guard refreshDecision != .suppress else { return } + +// … existing isShutdownState / isJoiningSocket / isSocketOpened guards unchanged … + +processEvent(event, isIdentifyRefresh: refreshDecision == .refresh) +``` + +**(e) `processEvent`** gains a defaulted parameter and forwards it: + +```swift +private func processEvent(_ event: Event, isIdentifyRefresh: Bool = false) { + switch event.type { + case .identify: + identify(event, isIdentifyRefresh: isIdentifyRefresh) + … +``` + +**(f) `identify(_:)`** ([:429-441](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L429-L441)): + +```swift +private func identify(_ event: Event, isIdentifyRefresh: Bool = false) { + tryCatch { + guard let userId = event.userId else { return } + + // If new user ID detected, close socket and clean up + if storage.userId.isNotEmpty && userId != storage.userId { + userpilot?.clean() + logout(socketState: .switchingUser) + return + } + + if isIdentifyRefresh { + // Nothing about the user changed, so the backend has nothing to re-evaluate: + // send the identify alone and skip the fake-reload screen event. + flushPriorityEvents(canRequestScreenEvent: false) + syncPushTokenIfNeeded() + return + } + + flushPriorityEvents(fakeReloadScreenEvent: true) + } +} +``` + +Suppressing the screen frame reuses the **existing** `canRequestScreenEvent:` parameter, which +also skips `screenNameTracker.updateScreen`, `experiencesPublisher?.updateSceen(_:)` and the screen +`broadcastEvent` — they all live inside the `if let screenViewEntity, canRequestScreenEvent` block +([:638-665](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L638-L665)). No new plumbing. + +The internal recursion at [:626](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L626) +calls `identify(cachedIdentifyEvent)` and picks up the `false` default — correct, that branch is a +user switch. + +**(g) New helper:** + +```swift +/** + * Re-publishes the device push token owed by a forwarded identify refresh. + * + * `PushNotificationMonitor.setPushToken` only publishes when the token value changes, so a + * returning user whose token is unchanged would otherwise never re-pair token ↔ user on the + * backend. Called after the identify is on the wire so the backend sees the user first. + */ +private func syncPushTokenIfNeeded() { + // Check the socket first: an obligation must not be consumed while it cannot be fulfilled. + guard canRequestEvent, identifyRefreshStateMachine.consumePushTokenSync() else { return } + pushNotificationMonitor?.resyncPushToken() +} +``` + +**(h) `onSocketOpened()`** ([:760-763](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L760-L763)) — append: + +```swift +// Cold-start path: the refresh was cached while the socket was down and has just been flushed. +syncPushTokenIfNeeded() +``` + +**(i) `setupScreenEvent(_:)`** — inside the `if isScreenTitleChanged` branch +([:569](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L569)): + +```swift +// A genuinely new screen re-opens the refresh allowance. +identifyRefreshStateMachine.onScreenChanged() +``` + +Deliberately **not** in the `else` branch: a repeat of the same screen title is the same screen. + +**(j) `logout(...)`** ([:244-245](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L244-L245)), +next to `startSession = true`: + +```swift +// A new user starts with a fresh allowance and owes no token sync. +identifyRefreshStateMachine.onUserChanged() +``` + +This also closes the theoretical hole where `storage.userId` and `storage.user.userId` diverge, +`identify` takes the user-switch branch, and a stale obligation would re-assert the old token +against the new user. + +**(k) `reset()`** ([:276-279](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L276-L279)) — +add `identifyRefreshStateMachine.onUserChanged()`. No production caller today (tests only), but +leaving new state out of a method named `reset` is a latent bug. + +**(l) DEBUG accessors** in the existing `#if DEBUG` extension ([:852-862](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L852-L862)): + +```swift +func mockGetCachedIdentifyEvent() -> Event? { cachedIdentifyEvent } +func mockIdentifyRefreshState() -> IdentifyRefreshStateMachine.State { identifyRefreshStateMachine.state } +``` + +### 5.3 `Sources/Userpilot/PushNotification/PushNotificationMonitor.swift` + +Protocol addition to `PushNotificationMonitoring` (~line 28): + +```swift +/// Re-publishes the current device token even when its value has not changed. +/// +/// `setPushToken(_:)` is value-guarded and is therefore a no-op for a returning user whose +/// token is unchanged. This re-asserts the token ↔ user pairing on the backend. +func resyncPushToken() +``` + +Implementation — extract the hex conversion so both senders share it: + +```swift +/// Hex representation of a raw APNs device token. +private func hexString(from deviceToken: Data) -> String { + deviceToken.map { String(format: "%02x", $0) }.joined() +} + +func resyncPushToken() { + // Prefer the token the OS handed us this launch; fall back to the persisted one for a warm + // start where `didRegisterForRemoteNotificationsWithDeviceToken` has not fired yet. + let token = cachedToken.map(hexString(from:)) ?? storage.pushToken + guard + let token, + token.isNotEmpty, + analyticsPublisher.canRequestEvent + else { return } + + analyticsPublisher.publishInternalSDKEvent( + PushNotificationTokenEvent( + appToken: config.token, + userId: storage.userId, + token: token), + socketSubscription: self) +} +``` + +`setPushToken(_:)` keeps its `storage.pushToken != newToken` guard untouched and only swaps its +inline hex expression for `hexString(from:)`. `onSocketEventSent` already persists +`payload["token"]` for `user_token`, so a re-sync harmlessly rewrites the same value. + +### 5.4 `Tests/UserpilotTests/MockUserpilot.swift` + +```swift +// MockPushNotificationMonitor +var onResyncPushToken: (() -> Void)? +func resyncPushToken() { + onResyncPushToken?() +} +``` + +--- + +## 6. Android changes + +### 6.1 `userpilot/src/main/java/com/userpilot/analytics/IdentifyRefreshStateMachine.kt` — new + +Contains `IdentifyRefreshRequest`, `IdentifyRefreshDecision`, `IdentifyRefreshStateMachine` (§4.3, §4.6). + +### 6.2 `userpilot/src/main/java/com/userpilot/di/AnalyticsKoin.kt` + +```kotlin +scoped { IdentifyRefreshStateMachine() } +``` +placed next to `scoped { ScreenSessionStateMachine() }`, and threaded into the `AnalyticsPublisher` +definition as: + +```kotlin +identifyRefreshStateMachine = get(), +pushNotificationHandlerProvider = { get() }, +``` + +**Why a provider lambda for the handler, not `get()`:** `PushNotificationHandler.init` calls +`socketManager.setSocketSubscriptionListener(this)`, which `delegate.add(...)`s it to the listener +list. Passing `get()` directly would construct the handler *before* `AnalyticsPublisher.init` +registers itself, changing the global `onSocketEventSent` callback order in a shipped SDK for no +benefit. A `() -> PushNotificationHandler` defers construction to first use — the first identify +refresh — preserving today's ordering, and is trivially fakeable in tests. (There is no Koin cycle +either way: the handler resolves `analyticsPublisher` via lazy `by scope.inject`.) + +### 6.3 `userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt` + +**(a) Constructor** ([:56-68](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L56-L68)) — +two new params after `screenSessionStateMachine`: + +```kotlin +private val identifyRefreshStateMachine: IdentifyRefreshStateMachine, +/** Deferred so the handler is not constructed before this publisher registers its socket listener. */ +private val pushNotificationHandlerProvider: () -> PushNotificationHandler, +``` + +The class already carries `@Suppress("TooManyFunctions", "LongParameterList")`. + +**(b) `publish(event)`** ([:220-250](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L220-L250)): + +```kotlin +// null for non-identify events +val refreshDecision = if (event.isIdentifyEvent) handleIdentifyEvent(event) else null + +// Nothing new for the backend, and this screen already refreshed the user +if (refreshDecision == IdentifyRefreshDecision.SUPPRESS) return + +// … existing isShutdownState / isJoiningSocket / isSocketOpened guards unchanged … + +processEvent(event, isIdentifyRefresh = refreshDecision == IdentifyRefreshDecision.REFRESH) +``` + +**(c) Replace `handleIdentifyEvent`** ([:258-269](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L258-L269)): + +```kotlin +private fun identifyRefreshRequest(event: Event) = IdentifyRefreshRequest( + carriesNoNewData = storage.user.isNotEmpty() && storage.user.toUser().isSameIdentifyEvent(event), + isAnonymousUser = storage.anonymousUserId.isNotEmpty() && event.userId == storage.anonymousUserId, + // A pending identify has not reached the backend yet, so this is `onSocketClosed` + // replaying it, not a fresh host-app call. + isPendingReplay = cachedIdentifyEvent != null && !socketManager.isSocketOpened +) + +private fun handleIdentifyEvent(event: Event): IdentifyRefreshDecision { + val decision = identifyRefreshStateMachine.transition(identifyRefreshRequest(event)) + if (decision == IdentifyRefreshDecision.SUPPRESS) return decision + + // Update cached user + storage.temporaryUser = event.toUser().toJson() + cachedIdentifyEvent = event + + return decision +} +``` + +**(d) `processEvent` / `identify`** ([:309-315](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L309-L315), +[:340-352](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L340-L352)): + +```kotlin +private fun processEvent(event: Event, isIdentifyRefresh: Boolean = false) { + when (event.type) { + is EventType.Identify -> identify(event, isIdentifyRefresh) + … + } +} + +private fun identify(event: Event, isIdentifyRefresh: Boolean = false) { + tryCatch { + val userId = event.userId ?: return@tryCatch + + if (storage.userId.isNotEmpty() && userId != storage.userId) { + userpilot.clean() + logout(SocketState.SWITCHING_USER) + return@tryCatch + } + + if (isIdentifyRefresh) { + // Nothing about the user changed, so the backend has nothing to re-evaluate: + // send the identify alone and skip the fake-reload screen event. + flushPriorityEvents(canRequestScreenEvent = false) + syncPushTokenIfNeeded() + return@tryCatch + } + + flushPriorityEvents(fakeReloadScreenEvent = true) + } +} +``` + +Note the existing `identify` uses `return` inside `tryCatch`; keep whichever form the file's +`tryCatch` signature already accepts (it is `return` today because the lambda is inline). + +**(e) New helper:** + +```kotlin +/** + * Re-publishes the FCM token owed by a forwarded identify refresh. + * + * [PushNotificationHandler.sendFCMToken] is value-guarded, so a returning user whose token is + * unchanged would otherwise never re-pair token ↔ user on the backend. + */ +private fun syncPushTokenIfNeeded() { + // Check the socket first: an obligation must not be consumed while it cannot be fulfilled. + if (!canRequestEvent()) return + if (!identifyRefreshStateMachine.consumePushTokenSync()) return + pushNotificationHandlerProvider().resyncPushToken() +} +``` + +**(f) `onSocketOpened()`** ([:662-666](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L662-L666)) — +append `syncPushTokenIfNeeded()` after `flushPriorityEvents(...)` and before/after +`permissionsHandler.requestPermissions()` (order is irrelevant; put it right after the flush). + +**(g) `updateScreenViewState(event)`** ([:451](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L451)) — +this returns `isSameScreenAsPrevious`, the **inverse** of iOS's `setupScreenEvent`: + +```kotlin +if (!isSameScreenAsPrevious) { + // A genuinely new screen re-opens the refresh allowance. + identifyRefreshStateMachine.onScreenChanged() +} +return isSameScreenAsPrevious +``` + +**(h) `logout(...)`** ([:171-172](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L171-L172)) — +next to `startSession = true`: + +```kotlin +// A new user starts with a fresh allowance and owes no token sync. +identifyRefreshStateMachine.onUserChanged() +``` + +### 6.4 `userpilot/src/main/java/com/userpilot/pushNotifications/PushNotificationHandler.kt` + +```kotlin +/** + * Re-publishes the current FCM token even when its value has not changed. + * + * [sendFCMToken] is value-guarded and is therefore a no-op for a returning user whose token is + * unchanged. This re-asserts the token ↔ user pairing on the backend. + */ +fun resyncPushToken() { + tryCatch { + if (!analyticsPublisher.canRequestEvent()) return@tryCatch + val token = cachedFCMToken ?: storage.pushNotificationToken + if (token.isNullOrEmpty()) return@tryCatch + // `onSocketEventSent` persists `cachedFCMToken` when `user_token` is acked, so seed it + // here — otherwise a resync sourced from storage would null the stored token out. + cachedFCMToken = token + analyticsPublisher.publishInternalSDKEvent( + PushNotificationTokenEvent(config.token, storage.userId, token) + ) + } +} +``` + +That `cachedFCMToken = token` line is load-bearing: `onSocketEventSent` does +`storage.pushNotificationToken = cachedFCMToken`, so re-syncing a storage-sourced token while +`cachedFCMToken == null` would wipe the persisted token on ack. + +`checkFCMToken()` and `sendFCMToken()` are otherwise untouched. + +### 6.5 Android test doubles + +`AnalyticsPublisherTest` constructs the publisher directly; add +`identifyRefreshStateMachine = IdentifyRefreshStateMachine()` and +`pushNotificationHandlerProvider = { fakeHandler }` (or a relaxed mock) to every construction site. + +--- + +## 7. Cross-platform parity checklist + +| Concern | iOS | Android | +|---|---|---| +| State machine class | `IdentifyRefreshStateMachine.swift` | `IdentifyRefreshStateMachine.kt` | +| Thread safety | `NSLock` | `@Synchronized` | +| Wiring | owned `private let` (like `EventThrottle`) | Koin `scoped` + ctor (like `ScreenSessionStateMachine`) | +| Redundancy check | `User.isSameIdentifyEvent(event:)` | `User.isSameIdentifyEvent(event)` | +| Anonymous check | `storage.anonymousUserId` | `storage.anonymousUserId` | +| Screen-change hook | `setupScreenEvent` → `isScreenTitleChanged == true` | `updateScreenViewState` → `isSameScreenAsPrevious == false` | +| Screen suppression | `flushPriorityEvents(canRequestScreenEvent: false)` | `flushPriorityEvents(canRequestScreenEvent = false)` | +| Token re-assert | `PushNotificationMonitor.resyncPushToken()` | `PushNotificationHandler.resyncPushToken()` | +| Token source order | `cachedToken` → `storage.pushToken` | `cachedFCMToken` → `storage.pushNotificationToken` | +| Token event name | `user_token` | `user_token` | + +The **wire protocol is byte-identical on both platforms**: one extra `identify` frame with no +accompanying screen frame, followed by one `user_token` frame. No backend change is required. + +--- + +## 8. Regression matrix (applies to both platforms) + +| Scenario | Before | After | +|---|---|---| +| First `identify` ever (`storage.user` empty) | `identify` + screen(`fake_reload: true`) | **unchanged** | +| `identify` with new/changed properties, same `userId` | `identify` + screen(`fake_reload: true`) | **unchanged** | +| `identify` with a different `userId` | `clean()` + `logout(switching user)` | **unchanged** (`isSameIdentifyEvent` fails on `userId`) | +| **`anonymous()` repeated** | dropped | **unchanged — dropped**, and the screen allowance is not consumed | +| `anonymous()` then `identify(realUser)` | normal path | **unchanged** | +| Refresh, socket open, first on this screen | dropped | `identify` published; **no** screen frame; `user_token` published | +| Refresh, socket open, 2nd+ on same screen | dropped | dropped | +| Refresh after navigating to a different screen | dropped | forwarded again (once) | +| Refresh after the *same* screen is re-emitted | dropped | still suppressed | +| Refresh, socket closed (cold/warm start) | dropped; socket not opened by it | cached + connect; on open → `identify` + screen(`fake_reload: false`) + `user_token` | +| Refresh, socket joining | dropped | cached; flushed on open + `user_token` | +| Refresh while app backgrounded | cached (policy never runs) | **unchanged** | +| Refresh while socket shutting down | dropped | dropped; obligation stays owed and is fulfilled on the next socket open | +| Screen changes while a token sync is still owed | n/a | allowance stays closed until the sync settles, then the next screen re-opens it | +| `onSocketClosed` replay of a pending refresh | n/a (never cached) | bypasses the allowance — reconnect keeps the user | +| `logout()` then `identify` same user | `storage.user` cleared → carries new data | **unchanged** | +| Background → foreground on the same screen, `identify` again | dropped | dropped (decision #2) | +| Multi-instance | per-instance state | **unchanged** — one machine per SDK instance | + +### Behaviour changes worth telling the team about + +1. **The analytics listener now fires for refreshes.** iOS `UserpilotAnalyticsDelegate.didTrack` / + Android `UserpilotAnalyticsListener` see an `identify` they previously never saw, because the + refresh reaches `onSocketEventSent` → broadcast. +2. **Traffic ceiling.** Worst case for an app that identifies on every screen: one extra `identify` + + one extra `user_token` per *screen change*. If that proves too chatty the lever is the + existing `EventThrottle`; not implemented now, the per-screen allowance is the primary bound. +3. **`temporaryUser` is now written for refreshes** and cleared on successful send. If the process + dies in that window, `AnalyticsPublisher.init` restores it and publishes on the next socket + open — same user, harmless. + +--- + +## 9. Test plan + +### 9.1 State machine — new file, both platforms + +`Tests/UserpilotTests/Analytics/IdentifyRefreshStateMachineTests.swift` / +`userpilot/src/test/java/com/userpilot/analytics/IdentifyRefreshStateMachineTest.kt` + +One test per row of the §4.4 transition table, plus: + +- initial state is `refreshAllowed` +- `carriesNewData` never mutates state (assert state before/after) +- anonymous suppression never mutates state +- `onScreenChanged` is a no-op while awaiting a token sync, and effective once settled +- `consumePushTokenSync` returns `true` exactly once, `false` on the second call +- `onUserChanged` resets from every state +- a replay re-arms the obligation from `refreshSettled` + +### 9.2 iOS `AnalyticsPublisherTests` + +Replace `testPublish_withSameIdentifyEvent_shouldNotReprocess` +([:734-753](../../../Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift#L734-L753)) — it +currently passes for the wrong reason, since `isSocketOpened` defaults to `false` so nothing +publishes either way. Every new test must set `userpilot.socketManager.isSocketOpened = true` where +it means "socket open". + +| Test | Asserts | +|---|---| +| `testPublish_identifyRefresh_socketOpen_shouldPublishIdentifyWithoutScreenEvent` | one publish, name `EventType.identifyEvent`, no screen frame | +| `testPublish_identifyRefresh_twiceOnSameScreen_shouldPublishOnce` | second call publishes nothing | +| `testPublish_identifyRefresh_afterNewScreen_shouldPublishAgain` | screen A → identify → screen B → identify ⇒ two identify frames | +| `testPublish_identifyRefresh_afterSameScreenTitleRepeated_shouldStaySuppressed` | re-emitting the title does not re-open the allowance | +| `testPublish_identifyRefresh_shouldResyncPushToken` | `onResyncPushToken` fires once, **after** the identify publish | +| `testPublish_identifyRefresh_secondOnSameScreen_shouldNotResyncPushToken` | obligation is consumed once | +| `testPublish_anonymousIdentify_shouldStayDropped` | no publish, no token event, and a following real-user refresh still gets its allowance | +| `testPublish_identifyRefresh_whenSocketClosed_shouldCacheAndConnect` | `cachedIdentifyEvent != nil`, `connect()` called, nothing published | +| `testOnSocketOpened_afterCachedIdentifyRefresh_shouldResyncPushToken` | flush publishes identify, then one token sync | +| `testOnSocketClosed_replayOfPendingRefresh_shouldNotBeSuppressed` | allowance already consumed + pending event ⇒ replay accepted | +| `testPublish_identifyWithNewProperties_shouldStillPublishFakeReloadScreenEvent` | **regression**: screen frame with `fake_reload == true` | +| `testPublish_identifyRefresh_afterLogout_shouldPublishAgain` | `logout` resets the machine | +| `testPublish_identifyRefresh_whenShutdownState_shouldNotPublishOrResync` | no publish, no token event | + +`MockUserpilot.container` already registers `MockPushNotificationMonitor` for +`PushNotificationMonitoring`, so no harness wiring beyond §5.4. + +### 9.3 iOS `PushNotificationMonitorTests` + +| Test | Asserts | +|---|---| +| `testResyncPushToken_withCachedToken_shouldPublishTokenEvent` | `user_token` with the hex of `setCachedToken` | +| `testResyncPushToken_withStoredTokenOnly_shouldPublishTokenEvent` | falls back to `storage.pushToken` | +| `testResyncPushToken_withNoToken_shouldNotPublish` | both sources empty ⇒ nothing | +| `testResyncPushToken_whenSocketClosed_shouldNotPublish` | `canRequestEvent == false` ⇒ nothing | +| `testSetPushToken_withUnchangedToken_shouldStillNotPublish` | **regression**: the value guard survives the refactor | + +### 9.4 Android `AnalyticsPublisherTest` / `PushNotificationHandlerTest` + +Same matrix as §9.2 / §9.3, plus one Android-specific case: + +- `resyncPushToken seeds cachedFCMToken so ack does not wipe stored token` — resync with + `cachedFCMToken == null`, ack `user_token`, assert `storage.pushNotificationToken` is unchanged + rather than null. + +### 9.5 Verification commands + +**iOS** +```bash +swiftlint +xcodebuild build -scheme Userpilot -destination 'generic/platform=iOS Simulator' +xcodebuild test -scheme Userpilot -destination 'platform=iOS Simulator,name=iPhone 16' \ + -only-testing:UserpilotTests/IdentifyRefreshStateMachineTests \ + -only-testing:UserpilotTests/AnalyticsPublisherTests \ + -only-testing:UserpilotTests/PushNotificationMonitorTests +xcodebuild test -scheme Userpilot -destination 'platform=iOS Simulator,name=iPhone 16' +``` +Pick a live simulator with `xcrun simctl list devices available`. + +**Android** +```bash +./gradlew detekt +./gradlew :userpilot:testDebugUnitTest --tests '*IdentifyRefreshStateMachineTest' +./gradlew :userpilot:testDebugUnitTest +./gradlew build +``` + +### 9.6 Manual check (both sample apps, logging enabled) + +1. Launch, `identify` the same user twice on screen A → exactly one `identify` frame, **no** screen + frame between them, one `user_token` frame. +2. Navigate to screen B, `identify` again → a second `identify` frame. +3. Return to A, `identify` again → a third. +4. Background → foreground on B, `identify` again → **no** new frame (decision #2). +5. `anonymous()` twice → **no** frames after the first identify (decision #4). +6. `identify` with a changed property → `identify` **plus** screen frame with `fake_reload: true`. +7. Kill and relaunch, `identify` the same user → `identify` reaches the backend on socket open + (previously it did not) plus `user_token`. +8. Switch user → clean + reconnect, token registered for the new user (unchanged behaviour). + +Android extras: exercise both an Activity→Activity change and an Activity→Fragment change on the +same host, since `ScreenSessionStateMachine` treats the latter as the **same** logical surface — +so the fragment case must **not** re-open the refresh allowance. + +--- + +## 10. Shipping + +- **Lint.** iOS: `AnalyticsPublisher.swift` already has `// swiftlint:disable file_length`; + `publish(_:)` grows ~4 lines and stays far under the 50-line `function_body_length` warning. + Android: `AnalyticsPublisher` already carries `@Suppress("TooManyFunctions", "LongParameterList")`; + detekt runs with `maxIssues: 0`, so run `./gradlew detekt` before pushing. +- **No public surface change** on either platform ⇒ no `Userpilot.docc` / README update required. + Optional: one line in the `identify` doc comment on each platform noting that a repeated + identical `identify` is forwarded at most once per screen and that `anonymous()` is exempt. +- **Versions** are owned by CI (`bump-version.yml` on both repos) — do not hand-edit + `Version.swift` or the Android version constant. iOS releases via `release/release-*`; + Android via `maven-publish-release.yml`. +- **Commits.** iOS uses emoji prefixes matched by the CI changelog filter; framed as a fix for + "an identical identify never reaches the backend" ⇒ `🐛`. Ship the two platforms as separate PRs + that reference each other, and land them together so behaviour does not diverge between SDKs. + +## 11. Risks + +| Risk | Mitigation | +|---|---| +| Extra backend traffic for apps that identify on every screen | per-screen allowance bounds it to one `identify` + one `user_token` per screen change; `EventThrottle` is the follow-up lever | +| Reconnect loses the user because the replay is suppressed | explicit `isPendingReplay` bypass (§4.4) + dedicated test on both platforms | +| Token obligation silently dropped on navigation | encoded as a state transition, not a flag: `onScreenChanged` is a no-op while `awaitingPushTokenSync` | +| iOS `DIContainer.resolve` `fatalError` during SDK init | `pushNotificationMonitor` is resolved on demand, never from `init` (§5.2a) | +| Android socket-listener registration order changes | `pushNotificationHandlerProvider` defers construction to first use (§6.2) | +| Android stored token wiped on ack | `resyncPushToken` seeds `cachedFCMToken` before publishing (§6.4) | +| iOS/Android drift after this change | §7 parity checklist; both state machines carry a "keep in sync" doc comment | +| `weak` on an iOS computed property | mirrors the existing `experiencesPublisher` / `sessionMonitorer` declarations in the same file; drop the keyword if the compiler objects | From b7fc0c1665bd3aed00b419075906808caebd2b10 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 26 Aug 2026 08:51:51 +0300 Subject: [PATCH 10/95] =?UTF-8?q?=F0=9F=99=88=20Update=20gitignore=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index d7602e7e..bca99af0 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,11 @@ fastlane/test_output # Mac OS X *.DS_Store + +# AI agent tooling (local while evaluating — not tracked) +# Keeps files on disk across branch switches; use git rm --cached if they were previously tracked. +.agents/ +.claude/ +.cursor/ +AGENTS.md +CLAUDE.md From c57af985932f069df3714d26bd54fa796da9ac02 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 26 Aug 2026 14:49:12 +0300 Subject: [PATCH 11/95] =?UTF-8?q?=F0=9F=9A=91=EF=B8=8F=20fix(socket):=20Se?= =?UTF-8?q?rialize=20socket=20lifecycle=20and=20guard=20shared=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production crashes shared one defect: socket lifecycle ran on three queues at once. openSocket() executed on whatever queue fetchSettings called back on - the URLSession delegate queue, or the caller's on the cached path - while closeSocket() ran on main. So connect() raced teardown(): the URLSession was invalidated between being created and used (NSGenericException), and readyState was read on an already freed transport (EXC_BAD_ACCESS). Phoenix already drives its reconnect timer, heartbeat and delegate callbacks on main, so main is the queue that serializes its state machine. Open, close and publish now all hop there. The state getters read a lock-guarded snapshot instead of dereferencing the Phoenix objects from arbitrary queues, socketState is guarded by the same lock, and connect() claims the slot in one locked step instead of a check-then-act. Closing now also releases the socket and channel, which were kept alive and let publish push into a dead channel's send buffer. No public API change, so no wrapper impact. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Socket/SocketManager.swift | 192 +++++++++++++++---- 1 file changed, 156 insertions(+), 36 deletions(-) diff --git a/Sources/Userpilot/Socket/SocketManager.swift b/Sources/Userpilot/Socket/SocketManager.swift index 34877660..6eccdfe0 100644 --- a/Sources/Userpilot/Socket/SocketManager.swift +++ b/Sources/Userpilot/Socket/SocketManager.swift @@ -178,8 +178,34 @@ internal class SocketManager { /// socket susbcriber @Multicast var socketSubscription: SocketSubscription - // track socket state - private var socketState: SocketState = .closed + // track socket state - guarded by `stateLock`, always go through `socketState` + private var socketStateValue: SocketState = .closed + + /// The current socket state. Both reading and writing take `stateLock`. + /// + /// Never touch this from inside a `withStateLock` block - use `socketStateValue` there instead, + /// as `NSLock` is not recursive. + private var socketState: SocketState { + get { withStateLock { socketStateValue } } + set { withStateLock { socketStateValue = newValue } } + } + + /// Guards the socket state snapshot below. + private let stateLock = NSLock() + + /// Mirrors `phoenixSocket.isConnected`. + /// + /// The Phoenix objects are only ever touched on the main queue, but socket state is read from + /// any queue (the analytics flush reads it from a `ReadWriteLock` barrier). These flags let + /// those readers answer without dereferencing `phoenixSocket`/`phoenixChannel` while main is + /// tearing them down, which used to crash in `PhoenixTransport.readyState`. + private var socketConnected = false + + /// Mirrors `phoenixChannel?.isJoined`. `nil` until a channel exists. + private var channelJoined: Bool? + + /// Mirrors `phoenixChannel.isJoining`. + private var channelJoining = false // MARK: - Initialization @@ -197,6 +223,40 @@ internal class SocketManager { self.logger = config.logger } + // MARK: - State snapshot + + /** + Runs `body` while holding `stateLock`. + + Only read or assign the snapshot flags inside `body`; never call a socket API from it, as + `NSLock` is not recursive. + + - Parameter body: The closure to execute under the lock. + - Returns: The value returned by `body`. + */ + private func withStateLock(_ body: () -> T) -> T { + stateLock.lock() + defer { stateLock.unlock() } + return body() + } + + /// Resets the socket state snapshot. Called when the socket is created and when it is closed. + private func resetStateSnapshot() { + withStateLock { + socketConnected = false + channelJoined = nil + channelJoining = false + } + } + + /// Records that the channel is no longer joined nor joining, leaving the socket flag untouched. + private func setChannelDetached() { + withStateLock { + channelJoined = false + channelJoining = false + } + } + } // MARK: - Socket Connection and Callbacks @@ -206,10 +266,26 @@ extension SocketManager { /* Opens a WebSocket connection and joins the specified channel. - - Parameter completion: A closure that is called when the connection attempt completes. + Always hops to the main queue. Phoenix drives its reconnect timer and heartbeat on main, + `closeSocket()` hops to main, and the transport forwards its delegate callbacks to main, so + main is the queue that already serializes Phoenix lifecycle work. `fetchSettings` delivers its + callback on the URLSession delegate queue, so without this hop `connect()` runs concurrently + with `disconnect()`/`teardown()` and the socket is torn down mid-connect. */ - // swiftlint:disable:next function_body_length private func openSocket() { + performOn(.main) { [weak self] in + guard let self else { return } + // A shutdown was requested while the settings request was in flight - do not reopen. + if self.socketState == .shuttingDown { return } + self.createAndConnectSocket() + } + } + + /* + Creates the socket and channel and starts connecting. Must only be called on the main queue. + */ + // swiftlint:disable:next function_body_length + private func createAndConnectSocket() { guard storage.socketURL.isNotEmpty, config.token.isNotEmpty, @@ -237,13 +313,21 @@ extension SocketManager { return } + resetStateSnapshot() + // Setup delegates for socket events phoenixSocket.delegateOnOpen(to: self) { (self) in self.logger.info("✅ SOCKET opened") + self.withStateLock { self.socketConnected = true } } phoenixSocket.delegateOnClose(to: self) { (self) in self.logger.error("🛑 SOCKET closed") + self.withStateLock { + self.socketConnected = false + self.channelJoined = false + self.channelJoining = false + } self.updateSocketState(.closed) if self.socketState != .shuttingDown { self.$socketSubscription.invoke { $0.onSocketClosed() } @@ -270,11 +354,19 @@ extension SocketManager { // Connect to the channel phoenixChannel = channel + withStateLock { + channelJoined = false + channelJoining = true + } phoenixChannel?.join()? .delegateReceive( SocketManager.successKey, to: self, callback: { (self, _) in self.logger.info("🚀 SOCKET channel joined") + self.withStateLock { + self.channelJoined = true + self.channelJoining = false + } self.updateSocketState(.opened) self.$socketSubscription.invoke { $0.onSocketOpened() } } @@ -284,18 +376,21 @@ extension SocketManager { callback: { (self, message) in self.logger.error( "⚠️ SOCKET channel join failed: %{public}@", message.payload) + self.setChannelDetached() self.updateSocketState(.error) self.closeSocket() }) phoenixChannel?.onError { [weak self] message in self?.logger.error("❗ SOCKET Channel error: %{public}@", message.payload) + self?.setChannelDetached() self?.updateSocketState(.error) self?.closeSocket() } phoenixChannel?.onClose { [weak self] message in self?.logger.debug("🛑 SOCKET Channel close: %{public}@", message.payload) + self?.setChannelDetached() self?.updateSocketState(.closed) } @@ -311,15 +406,22 @@ extension SocketManager { */ private func closeSocket() { performOn(.main) { [weak self] in + guard let self else { return } + self.resetStateSnapshot() tryCatch { - if let channel = self?.phoenixChannel { + if let channel = self.phoenixChannel { if channel.canPush && !channel.isClosed { channel.leave() } - self?.phoenixSocket?.remove(channel) + self.phoenixSocket?.remove(channel) } - self?.phoenixSocket?.disconnect() + self.phoenixSocket?.disconnect() } + // Release both: `createAndConnectSocket()` always builds a fresh socket and channel, so + // keeping the closed ones only held their transport alive and let `publish` push into a + // dead channel's send buffer. + self.phoenixChannel = nil + self.phoenixSocket = nil } } @@ -331,12 +433,12 @@ extension SocketManager: SocketEvents { /// Logic to determine if the channel state is joining var isJoiningSocket: Bool { - phoenixChannel?.isJoining == true || socketState == .connecting + withStateLock { channelJoining || socketStateValue == .connecting } } /// Logic to check if the socket is currently open var isSocketOpened: Bool { - phoenixSocket?.isConnected == true && phoenixChannel?.isJoined == true + withStateLock { socketConnected && channelJoined == true } } /// Checks if the socket is closed due to error reason @@ -350,23 +452,26 @@ extension SocketManager: SocketEvents { } /// Update socket state + /// + /// The read and the write happen in one locked step so two queues cannot both decide the + /// transition is allowed and then clobber each other. func updateSocketState( _ newSocketState: SocketManager.SocketState, forceUpdateState: Bool = false ) { - tryCatch { + withStateLock { if forceUpdateState || newSocketState == .error { - socketState = newSocketState + socketStateValue = newSocketState return } - if socketState == newSocketState || socketState == .error { return } - socketState = newSocketState + if socketStateValue == newSocketState || socketStateValue == .error { return } + socketStateValue = newSocketState } } /// Checks if the socket is currently opened without channel var isSocketConnectedWithUnknownChannel: Bool { - phoenixSocket?.isConnected == true && phoenixChannel?.isJoined == false + withStateLock { socketConnected && channelJoined == false } } /// Implementation to open a WebSocket connection @@ -376,13 +481,21 @@ extension SocketManager: SocketEvents { return } - // Prevent multiple connection attempts when already connecting or connected - guard socketState != .connecting && !isSocketOpened else { + // Prevent multiple connection attempts when already connecting or connected. + // Claiming the slot has to be one locked step: checking the state and then marking it + // `.connecting` separately let two queues both pass the gate and dial twice. + let didClaimConnect = withStateLock { () -> Bool in + if socketStateValue == .connecting { return false } + if socketConnected && channelJoined == true { return false } + socketStateValue = .connecting + return true + } + + guard didClaimConnect else { logger.debug("🔄 SOCKET already connecting or opened, skipping connect") return } - updateSocketState(.connecting) sdkSettingsDetector.fetchSettings { [weak self] in self?.openSocket() } @@ -395,36 +508,43 @@ extension SocketManager: SocketEvents { } /// Implementation to publish an event over the WebSocket + /// + /// Hops to main like the rest of the socket lifecycle: pushing reads the socket's transport and + /// mutates its internal message ref and send buffer, so it must not run concurrently with + /// `closeSocket()`. Pushes stay in order because the main queue is FIFO. See `openSocket()`. func publish( _ eventName: String, payload: Payload, socketSubscription: SocketSubscription? ) { - _ = tryCatch { - phoenixChannel? - .push(eventName, payload: payload ?? [:])? - .receive(SocketManager.successKey) { [weak self] message in - if self?.socketState != .shuttingDown { - if let socketSubscription { - socketSubscription.onSocketEventSent(eventName, payload, message, true) - } else { - self?.$socketSubscription.invoke { - $0.onSocketEventSent(eventName, payload, message, true) + performOn(.main) { [weak self] in + guard let self else { return } + _ = tryCatch { + self.phoenixChannel? + .push(eventName, payload: payload ?? [:])? + .receive(SocketManager.successKey) { [weak self] message in + if self?.socketState != .shuttingDown { + if let socketSubscription { + socketSubscription.onSocketEventSent(eventName, payload, message, true) + } else { + self?.$socketSubscription.invoke { + $0.onSocketEventSent(eventName, payload, message, true) + } } } } - } - .receive(SocketManager.errorKey) { [weak self] message in - if self?.socketState != .shuttingDown { - if let socketSubscription { - socketSubscription.onSocketEventSent(eventName, payload, message, false) - } else { - self?.$socketSubscription.invoke { - $0.onSocketEventSent(eventName, payload, message, false) + .receive(SocketManager.errorKey) { [weak self] message in + if self?.socketState != .shuttingDown { + if let socketSubscription { + socketSubscription.onSocketEventSent(eventName, payload, message, false) + } else { + self?.$socketSubscription.invoke { + $0.onSocketEventSent(eventName, payload, message, false) + } } } } - } + } } } From 5efad37222ad1ece62849c894359bff57e05b0ab Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 26 Aug 2026 14:49:24 +0300 Subject: [PATCH 12/95] =?UTF-8?q?=F0=9F=90=9B=20fix(extensions):=20Reuse?= =?UTF-8?q?=20a=20single=20background=20dispatch=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueueType.background was a computed property, so every performOn(.background) call built its own queue and nothing dispatched there was ever serialized against anything else. Store it once. This also serializes ImageLoader's blurCache/imageCache writes, which were being mutated from N different queues. Relabelled com.app.queue to com.userpilot.sdk.background so the queue is identifiable in host-app crash reports. Co-Authored-By: Claude Opus 5 (1M context) --- .../Extensions/DispatchQueue+Extensions.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift b/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift index 8ed83ffd..2d136ba8 100644 --- a/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift +++ b/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift @@ -27,14 +27,22 @@ internal enum QueueType { case lowPriority case highPriority + /// The shared serial background queue. + /// + /// Stored, not built per access: as a computed value every `performOn(.background)` call got its + /// own queue, so work dispatched to `.background` was never serialized against other work on it. + private static let backgroundQueue = DispatchQueue( + label: "com.userpilot.sdk.background", + qos: .background, + target: nil + ) + var queue: DispatchQueue { switch self { case .main: return DispatchQueue.main case .background: - return DispatchQueue(label: "com.app.queue", - qos: .background, - target: nil) + return QueueType.backgroundQueue case .lowPriority: return DispatchQueue.global(qos: .utility) case .highPriority: From f9b8ce2d03ab5283cba6691590a976427f71029d Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 26 Aug 2026 14:49:25 +0300 Subject: [PATCH 13/95] =?UTF-8?q?=F0=9F=90=9B=20fix(socket):=20Capture=20t?= =?UTF-8?q?he=20receive=20task=20before=20detaching=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit receive() read self.task from inside a detached Task while connect() reassigned it from another queue - an unsynchronized read of a strong reference being replaced concurrently, the same hazard the existing notifyDelegate comment documents. Capture it on the calling thread instead. Each recursion re-captures the live task and the receiveMessageTask didSet still cancels a superseded loop. Touches Vendor/SwiftPhoenixClient (third-party): a two-line change to a file that already carries local patches. Co-Authored-By: Claude Opus 5 (1M context) --- .../Vendor/SwiftPhoenixClient/PhoenixTransport.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift b/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift index 0d54ca8c..fd901bc4 100644 --- a/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift +++ b/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift @@ -313,11 +313,17 @@ open class URLSessionTransport: NSObject, PhoenixTransport, URLSessionWebSocketD } } + /// Captures the current `task` on the calling thread rather than reading `self.task` from inside + /// the detached `Task`. `connect()` reassigns `self.task` from another queue, so reading it inside + /// the receive loop was an unsynchronized read of a strong reference being replaced concurrently - + /// the same hazard `notifyDelegate` documents above. Each recursion re-captures the live task, and + /// `receiveMessageTask`'s `didSet` still cancels a superseded loop. private func receive() { + guard let currentTask = self.task else { return } receiveMessageTask = Task { [weak self] in guard let self else { return } do { - let message = try await task?.receive() + let message = try await currentTask.receive() switch message { case .data: print("Data received. This method is unsupported by the Client") From 746e32c811a04cbed2345ff1a27a5460dcee6e33 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 26 Aug 2026 14:54:00 +0300 Subject: [PATCH 14/95] =?UTF-8?q?=F0=9F=99=88=20Update=20gitignore=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index d7602e7e..2a9d2af5 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,10 @@ fastlane/test_output # Mac OS X *.DS_Store + +# AI agent tooling (Cursor / Claude / local agent memory) +.agents/ +.claude/ +.cursor/ +AGENTS.md +CLAUDE.md From 61d4aabe1beb54e518dd2c9dc995586193478cce Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:40:50 +0300 Subject: [PATCH 15/95] =?UTF-8?q?=F0=9F=90=9B=20fix(autocapture):=20Restor?= =?UTF-8?q?e=20text=20and=20accessibility=20policy=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge 506e10a took UIViewController+Extensions.swift from the feature branch but UIKitViewResolver.swift from the 1.4.0 line, deleting the six capture-policy helpers while keeping their two call sites. Neither parent was broken; the merged result does not compile. Restores main's file verbatim, which also brings back the omit-vs-redact distinction: a disabled capture flag omits the property entirely, while the per-view redact opt-in emits the "****" placeholder. Co-Authored-By: Claude Opus 5 (1M context) --- .../AutoCapture/Utils/UIKitViewResolver.swift | 100 +++++++++++++----- 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift index b5b7460c..f53378b9 100644 --- a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift +++ b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift @@ -97,7 +97,7 @@ internal enum UIKitViewResolver { if let label = node.accessibilityLabel? .trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty, - label != Constants.AutoCapture.reductText, + label != AutoCaptureConstants.reductText, !accessibilityRedacted { attributes += "attr__accessibility_label=\"\(label.replacingOccurrences(of: "\"", with: "\\\""))\"" } @@ -105,7 +105,7 @@ internal enum UIKitViewResolver { if let id = node.accessibilityIdentifier? .trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, - id != Constants.AutoCapture.reductText, + id != AutoCaptureConstants.reductText, !accessibilityRedacted { attributes += "attr__id=\"\(id.replacingOccurrences(of: "\"", with: "\\\""))\"" } @@ -307,18 +307,31 @@ internal extension UIView { return false } - /// Checks if text should be redacted: Config (`enableInteractionTextCapture`) - /// and API (userpilotRedactText on responder chain). - /// - Returns: True if text should be redacted + /// Checks if text should be hidden for this view: Config (`enableInteractionTextCapture`) + /// is off, or `userpilotRedactText` is set on the responder chain. + /// + /// Prefer ``resolvedInteractionText(_:)`` / ``getTextContent()`` when publishing `target_text`: + /// those **omit** the field when capture is disabled and only emit the redaction placeholder + /// for the per-view redact opt-in. + /// - Returns: True if text should be hidden func shouldRedactText() -> Bool { + isInteractionTextCaptureDisabled() || hasUserpilotRedactTextOptIn() + } + + /// True when the owning instance has `enableInteractionTextCapture` set to `false`. + func isInteractionTextCaptureDisabled() -> Bool { // Resolve the OWNING Userpilot instance from this view so the redaction // policy follows that tenant's config, not the host's. Critical for // multi-instance integrations: the host might allow text capture while // the embedded SDK requires redaction (or vice versa). - if let config = InstanceResolver.shared.target(forSource: self)?.config, - !config.enableInteractionTextCapture { - return true + if let config = InstanceResolver.shared.target(forSource: self)?.config { + return !config.enableInteractionTextCapture } + return false + } + + /// True when `userpilotRedactText` is set on this responder or an ancestor. + func hasUserpilotRedactTextOptIn() -> Bool { var responder: UIResponder? = self while let current = responder { if current.userpilotRedactText { return true } @@ -327,16 +340,49 @@ internal extension UIView { return false } - /// Checks if accessibility labels should be redacted: - /// Config (`enableInteractionAccessibilityLabelCapture`) and API - /// (userpilotRedactAccessibilityLabel on responder chain). - /// - Returns: True if accessibility labels should be redacted + /// Applies text-capture policy to a raw string for autocapture payloads. + /// - Global text capture off → `nil` (omit `target_text`) + /// - `userpilotRedactText` on the responder chain → redaction placeholder + /// - otherwise → `raw` + func resolvedInteractionText(_ raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + if isInteractionTextCaptureDisabled() { return nil } + if hasUserpilotRedactTextOptIn() { return AutoCaptureConstants.reductText } + return raw + } + + /// Text published when capture resolves to an ignore-inner-hierarchy ancestor. + /// + /// When text capture is enabled, the leaf text is masked with the redaction placeholder so + /// inner content stays hidden. When text capture is disabled, returns `nil` so `target_text` + /// is omitted — same omit policy as ``resolvedInteractionText(_:)`` / ``getTextContent()``. + func ignoreInnerHierarchyTextPlaceholder() -> String? { + if isInteractionTextCaptureDisabled() { return nil } + return AutoCaptureConstants.reductText + } + + /// Checks if accessibility labels should be hidden: Config + /// (`enableInteractionAccessibilityLabelCapture`) is off, or + /// `userpilotRedactAccessibilityLabel` is set on the responder chain. + /// + /// Prefer ``getAccessibilityLabelContent()`` when publishing `accessibility_label`: that + /// **omits** the field when capture is disabled and only emits the redaction placeholder for + /// the per-view redact opt-in. + /// - Returns: True if accessibility labels should be hidden func shouldRedactAccessibilityLabel() -> Bool { - // Same reasoning as `shouldRedactText`: route via the owning instance. - if let config = InstanceResolver.shared.target(forSource: self)?.config, - !config.enableInteractionAccessibilityLabelCapture { - return true + isInteractionAccessibilityLabelCaptureDisabled() || hasUserpilotRedactAccessibilityLabelOptIn() + } + + /// True when the owning instance has `enableInteractionAccessibilityLabelCapture` set to `false`. + func isInteractionAccessibilityLabelCaptureDisabled() -> Bool { + if let config = InstanceResolver.shared.target(forSource: self)?.config { + return !config.enableInteractionAccessibilityLabelCapture } + return false + } + + /// True when `userpilotRedactAccessibilityLabel` is set on this responder or an ancestor. + func hasUserpilotRedactAccessibilityLabelOptIn() -> Bool { var responder: UIResponder? = self while let current = responder { if current.userpilotRedactAccessibilityLabel { return true } @@ -385,13 +431,16 @@ internal extension UIView { return nil } - /// Returns the text content of this view, redacted if necessary. + /// Returns the text content of this view, applying text-capture policy. /// Falls back to searching subviews for a UILabel when the view itself /// is a private/unknown type (e.g., _UIAlertControllerActionView). - /// - Returns: The text content or redacted placeholder + /// - Returns: The text content, redaction placeholder, or `nil` when omitted func getTextContent() -> String? { - if shouldRedactText() { - return Constants.AutoCapture.reductText + if isInteractionTextCaptureDisabled() { + return nil + } + if hasUserpilotRedactTextOptIn() { + return AutoCaptureConstants.reductText } if let direct = userpilotRawDirectText( @@ -421,11 +470,14 @@ internal extension UIView { return nil } - /// Returns the accessibility label of this view, redacted if necessary - /// - Returns: The accessibility label or redacted placeholder + /// Returns the accessibility label of this view, applying accessibility-capture policy. + /// - Returns: The label, redaction placeholder for opt-in, or `nil` when omitted func getAccessibilityLabelContent() -> String? { - if shouldRedactAccessibilityLabel() { - return Constants.AutoCapture.reductText + if isInteractionAccessibilityLabelCaptureDisabled() { + return nil + } + if hasUserpilotRedactAccessibilityLabelOptIn() { + return AutoCaptureConstants.reductText } guard let label = accessibilityLabel, !label.isEmpty else { From 22ccc075bfa1bf687531eb3b3a28f4e3cf8a211e Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:41:08 +0300 Subject: [PATCH 16/95] =?UTF-8?q?=F0=9F=9A=91=EF=B8=8F=20fix(socket):=20Se?= =?UTF-8?q?rialize=20socket=20lifecycle=20and=20guard=20shared=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production crashes shared one cause: socket lifecycle work was not serialized. fetchSettings delivers on the URLSession delegate queue, so connect() created a WebSocket task while a main-queue teardown had already invalidated the session (NSGenericException). Separately, off-main state readers dereferenced a transport that teardown had just deallocated (EXC_BAD_ACCESS in PhoenixTransport.readyState). openSocket() now hops to main, so every transport create and teardown is serialized on the queue Phoenix already uses, and the socket, channel and connection accessors are lock-guarded so a reader holds its own strong reference. Setters release the replaced value after unlocking, since NSLock is not recursive. Touches Vendor/SwiftPhoenixClient (Socket, PhoenixTransport): the dangling read and the receive-loop task capture are both in vendored code. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Socket/SocketManager.swift | 94 +++++++++++++++++-- .../SwiftPhoenixClient/PhoenixTransport.swift | 9 +- .../Vendor/SwiftPhoenixClient/Socket.swift | 32 ++++++- .../Socket/SocketManagerTests.swift | 20 ++++ 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/Sources/Userpilot/Socket/SocketManager.swift b/Sources/Userpilot/Socket/SocketManager.swift index 0977ded9..98c7ddfd 100644 --- a/Sources/Userpilot/Socket/SocketManager.swift +++ b/Sources/Userpilot/Socket/SocketManager.swift @@ -93,11 +93,56 @@ internal class SocketManager { typealias SocketFactory = (_ endpoint: String, _ params: SwiftPhoenixClientPayload?) -> Socket + /// Guards `storedPhoenixSocket` / `storedPhoenixChannel`. + /// + /// Both are replaced on the main queue by `createAndConnectSocket()` while the state getters + /// read them from other queues (the analytics watchdog, the experience queue). As plain stored + /// properties that was a load-then-retain race: the reader loads the pointer, the writer + /// releases the last reference, and the reader dereferences freed memory. Reading under the lock + /// hands the caller its own strong reference for the duration of the access. + /// + /// The setters hand the replaced value out of the critical section before releasing it: + /// `NSLock` is not recursive, so a `deinit` running under the lock that reached back into + /// either accessor would deadlock instead of crashing. + private let phoenixLock = NSLock() + + private var storedPhoenixSocket: Socket? + + private var storedPhoenixChannel: Channel? + /// The WebSocket instance for handling connections. - private var phoenixSocket: Socket? + private var phoenixSocket: Socket? { + get { + phoenixLock.lock() + defer { phoenixLock.unlock() } + return storedPhoenixSocket + } + set { + phoenixLock.lock() + let previous = storedPhoenixSocket + storedPhoenixSocket = newValue + phoenixLock.unlock() + // Released here, with the lock already dropped. + _ = previous + } + } /// The channel within the WebSocket connection. - private var phoenixChannel: Channel? + private var phoenixChannel: Channel? { + get { + phoenixLock.lock() + defer { phoenixLock.unlock() } + return storedPhoenixChannel + } + set { + phoenixLock.lock() + let previous = storedPhoenixChannel + storedPhoenixChannel = newValue + phoenixLock.unlock() + // Released here, with the lock already dropped. + _ = previous + } + } /// SDK instance. private weak var userpilot: Userpilot? @@ -174,15 +219,41 @@ extension SocketManager { - Parameter completion: A closure that is called when the connection attempt completes. */ - // swiftlint:disable:next function_body_length + /// Always hops to the main queue before touching Phoenix. + /// + /// Phoenix drives its reconnect timer and heartbeat on main, forwards every transport delegate + /// callback to main, and `closeSocket()` hops to main - so main is the queue that already + /// serializes its state machine. `fetchSettings` delivers its completion on the URLSession + /// delegate queue, so without this hop `connect()` runs concurrently with + /// `disconnect()`/`teardown()` and the socket is torn down mid-connect. private func openSocket() { + performOn(.main) { [weak self] in + guard let self else { return } + // A teardown was requested while the settings request was in flight - do not reopen. + // Release the single-flight gate too, or no later connect() could ever claim it. + if self.isShutdownState { + self.isFetchingSocketSettings.value = false + return + } + self.createAndConnectSocket() + } + } + + // Creates the socket and channel and starts connecting. + // Must only be called on the main queue. + // swiftlint:disable:next function_body_length + private func createAndConnectSocket() { guard storage.socketURL.isNotEmpty, config.token.isNotEmpty, storage.userId.isNotEmpty, let autoProperties = autoPropertyDecorator.autoProperties.toJSONString(), let appProperties = autoPropertyDecorator.appProperties.toJSONString() - else { return } + else { + // Nothing to dial - release the gate so a later attempt is not blocked forever. + isFetchingSocketSettings.value = false + return + } tryCatch { quarantinePreviousSocket() @@ -287,6 +358,7 @@ extension SocketManager { /// cannot fire stale close/error events into subscribers, then disconnect it. /// Captures the instance locally and tears down synchronously — race-free /// against a replacement, and a no-op for an already-closed socket. + /// Must only be called on the main queue - it tears the transport down. private func quarantinePreviousSocket() { guard let oldSocket = phoenixSocket else { return } if let oldChannel = phoenixChannel { @@ -357,9 +429,12 @@ extension SocketManager: SocketManaging { return } // Half-open transport (connected, channel never joined) passes the gate - // above — recover by abandoning it before dialing fresh. + // above — recover by abandoning it before dialing fresh. Hops to main: this tears the + // transport down, so it must not run concurrently with the rest of the lifecycle. if isSocketConnectedWithUnknownChannel { - quarantinePreviousSocket() + performOn(.main) { [weak self] in + self?.quarantinePreviousSocket() + } } if !isFetchingSocketSettings.compareAndSet(expected: false, new: true) { return @@ -385,6 +460,13 @@ extension SocketManager: SocketManaging { } /// Implementation to publish an event over the WebSocket + /// + /// Runs on the caller's queue - unlike the connect/teardown paths, which are all serialized on + /// main. That is safe for the transport itself (`sendBuffer` is a `SynchronizedArray` and a send + /// on a cancelled task just fails its completion), but `Socket.makeRef()` increments a plain + /// `UInt64`, so a push racing the main-queue heartbeat can duplicate a message ref and misroute + /// its ACK. Hopping to main would fix that; it would also reorder pushes relative to the caller, + /// so it is left alone deliberately rather than by omission. func publish( _ eventName: String, payload: Payload diff --git a/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift b/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift index 0d54ca8c..4409ba62 100644 --- a/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift +++ b/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift @@ -313,11 +313,18 @@ open class URLSessionTransport: NSObject, PhoenixTransport, URLSessionWebSocketD } } + /// Captures the current `task` on the calling thread rather than reading `self.task` from inside + /// the detached `Task`. `connect()` reassigns `self.task` from another queue, so reading it inside + /// the receive loop was an unsynchronized read of a strong reference being replaced concurrently - + /// the same hazard `notifyDelegate` documents above, and the same one `Socket.connection` is locked + /// for. Each recursion re-captures the live task, and `receiveMessageTask`'s `didSet` still cancels + /// a superseded loop. private func receive() { + guard let currentTask = self.task else { return } receiveMessageTask = Task { [weak self] in guard let self else { return } do { - let message = try await task?.receive() + let message = try await currentTask.receive() switch message { case .data: print("Data received. This method is unsupported by the Client") diff --git a/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift b/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift index 7a6aeb28..22879844 100644 --- a/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift +++ b/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift @@ -165,8 +165,38 @@ public class Socket: PhoenixTransportDelegate { /// Close status var closeStatus: CloseStatus = .unknown + /// Guards `_connection`. + /// + /// `teardown()` sets the connection to `nil` on the main queue, releasing the last reference to + /// the transport, while other queues read socket state through `connectionState`. A plain stored + /// property made that a load-then-retain race: the reader loaded the pointer, the writer freed the + /// object, and the reader then dereferenced it - `EXC_BAD_ACCESS` in `readyState`. Reading under + /// the lock hands the caller its own strong reference, so the transport cannot be deallocated + /// while it is being used. + /// + /// The setter hands the replaced transport out of the critical section before releasing it: + /// `NSLock` is not recursive, so a `deinit` running under the lock that reached back into this + /// accessor would deadlock instead of crashing. + private let connectionLock = NSLock() + + private var _connection: PhoenixTransport? = nil + /// The connection to the server - var connection: PhoenixTransport? = nil + var connection: PhoenixTransport? { + get { + connectionLock.lock() + defer { connectionLock.unlock() } + return _connection + } + set { + connectionLock.lock() + let previous = _connection + _connection = newValue + connectionLock.unlock() + // Released here, with the lock already dropped. + _ = previous + } + } //---------------------------------------------------------------------- diff --git a/Tests/UserpilotTests/Socket/SocketManagerTests.swift b/Tests/UserpilotTests/Socket/SocketManagerTests.swift index ea1c3258..3b103682 100644 --- a/Tests/UserpilotTests/Socket/SocketManagerTests.swift +++ b/Tests/UserpilotTests/Socket/SocketManagerTests.swift @@ -60,6 +60,7 @@ final class SocketManagerTests: XCTestCase { socketManager = makeSocketManager() socketManager.connect() + drainMainQueue() XCTAssertTrue(transports.isEmpty) XCTAssertFalse(socketManager.isJoiningSocket) @@ -74,6 +75,7 @@ final class SocketManagerTests: XCTestCase { socketManager = makeSocketManager() socketManager.connect() + drainMainQueue() XCTAssertTrue(transports.isEmpty) XCTAssertFalse(socketManager.isJoiningSocket) @@ -91,11 +93,13 @@ final class SocketManagerTests: XCTestCase { socketManager.connect() socketManager.connect() + drainMainQueue() XCTAssertEqual(fetchCount, 1) XCTAssertTrue(transports.isEmpty) completions.first?(.success(())) + drainMainQueue() XCTAssertEqual(transports.count, 1) } @@ -110,6 +114,7 @@ final class SocketManagerTests: XCTestCase { socketManager.connect() socketManager.connect() + drainMainQueue() XCTAssertEqual(fetchCount, 2) XCTAssertTrue(transports.isEmpty) @@ -266,11 +271,26 @@ final class SocketManagerTests: XCTestCase { } } + /// Yields one main-queue turn. + /// + /// `SocketManager` serializes all Phoenix lifecycle work onto the main queue (see `openSocket`), + /// so the socket and transport are created a turn after `connect()` returns. The main queue is + /// FIFO, so once this block runs the deferred lifecycle work has already run. + private func drainMainQueue( + file: StaticString = #filePath, + line: UInt = #line + ) { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + } + private func openAndJoinSocket( file: StaticString = #filePath, line: UInt = #line ) throws -> FakePhoenixTransport { socketManager.connect() + drainMainQueue(file: file, line: line) let transport = try XCTUnwrap(transports.last, file: file, line: line) transport.open() let joinPush = try XCTUnwrap( From 183419566777ded75b7805bfd21c25934b4a5aa4 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:41:18 +0300 Subject: [PATCH 17/95] =?UTF-8?q?=F0=9F=94=A7=20chore(utilities):=20Name?= =?UTF-8?q?=20the=20remaining=20dispatch=20queue=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five queues were built with inline string labels, including a copy-pasted "com.app.queue". Moves them to Constants.DispatchQueues so every queue is declared in one place and shows a meaningful label in crash reports. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Analytics/OfflineEventsHandler.swift | 2 +- Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift | 2 +- Sources/Userpilot/Monitor/NetworkMonitor.swift | 2 +- Sources/Userpilot/Utilities/Helper/Constants.swift | 3 +++ Sources/Userpilot/Utilities/Threading/EventQueue.swift | 5 ++++- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift index fd9e512b..39c95a1f 100644 --- a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift +++ b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift @@ -62,7 +62,7 @@ internal class OfflineEventsHandler: OfflineEventsHandling { /// Serial background queue for processing offline events private let offlineEventsQueue = DispatchQueue( - label: "com.userpilot.offlineevents", + label: Constants.DispatchQueues.offlineEvents, qos: .utility ) diff --git a/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift b/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift index 8ed83ffd..54d252c9 100644 --- a/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift +++ b/Sources/Userpilot/Extensions/DispatchQueue+Extensions.swift @@ -32,7 +32,7 @@ internal enum QueueType { case .main: return DispatchQueue.main case .background: - return DispatchQueue(label: "com.app.queue", + return DispatchQueue(label: Constants.DispatchQueues.background, qos: .background, target: nil) case .lowPriority: diff --git a/Sources/Userpilot/Monitor/NetworkMonitor.swift b/Sources/Userpilot/Monitor/NetworkMonitor.swift index 5a4dd57f..bdc3c564 100644 --- a/Sources/Userpilot/Monitor/NetworkMonitor.swift +++ b/Sources/Userpilot/Monitor/NetworkMonitor.swift @@ -82,7 +82,7 @@ internal class NetworkMonitor: NetworkMonitoring { ) private let stateQueue = DispatchQueue( - label: "com.userpilot.network.state", attributes: .concurrent) + label: Constants.DispatchQueues.networkMonitorState, attributes: .concurrent) private var pathMonitor: NWPathMonitor? private var debounceWorkItem: DispatchWorkItem? diff --git a/Sources/Userpilot/Utilities/Helper/Constants.swift b/Sources/Userpilot/Utilities/Helper/Constants.swift index 505d3e31..b42b55ba 100644 --- a/Sources/Userpilot/Utilities/Helper/Constants.swift +++ b/Sources/Userpilot/Utilities/Helper/Constants.swift @@ -96,6 +96,9 @@ internal enum Constants { enum DispatchQueues { static let database = "com.userpilot.database-queue" static let networkMonitor = "com.userpilot.network-monitor-queue" + static let networkMonitorState = "com.userpilot.network-monitor-state-queue" + static let offlineEvents = "com.userpilot.offline-events-queue" + static let background = "com.userpilot.background-queue" static let analyticsWatchdog = "com.userpilot.analytics-watchdog-queue" static let eventQueue = "com.userpilot.event-queue" static let experienceQueue = "com.userpilot.experience-queue" diff --git a/Sources/Userpilot/Utilities/Threading/EventQueue.swift b/Sources/Userpilot/Utilities/Threading/EventQueue.swift index 38af61da..7e328ee5 100644 --- a/Sources/Userpilot/Utilities/Threading/EventQueue.swift +++ b/Sources/Userpilot/Utilities/Threading/EventQueue.swift @@ -13,7 +13,10 @@ import Foundation internal class EventQueue { private var queue: [Event] = [] - private let dispatchQueue = DispatchQueue(label: "events_queue", attributes: .concurrent) + private let dispatchQueue = DispatchQueue( + label: Constants.DispatchQueues.eventQueue, + attributes: .concurrent + ) // MARK: - Write operations (exclusive) From 616ecae70c96161c4338ce4f7405857c6b37f008 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:41:44 +0300 Subject: [PATCH 18/95] =?UTF-8?q?=F0=9F=9A=9A=20refactor(utilities):=20Dro?= =?UTF-8?q?p=20the=20AutoCaptureConstants=20typealias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.4.0 moved the autocapture keys under Constants.AutoCapture and kept `typealias AutoCaptureConstants = Constants.AutoCapture` as a bridge, so both spellings were live (104 and 71 references). Standardizes on Constants.AutoCapture, matching Constants.Socket and Constants.Analytics, and removes the alias — the only one of its kind in the codebase. main still spells it AutoCaptureConstants in 21 files, so code merged from main will need the same rename. Co-Authored-By: Claude Opus 5 (1M context) --- .../AutoCapture/AutoCaptureCoordinater.swift | 2 +- .../AutoCapture/ScreenNameTracker.swift | 2 +- .../AutoCapture/Utils/UIKitViewResolver.swift | 12 ++--- .../UIViewController+Extensions.swift | 4 +- .../Helper/Constants+AutoCapture.swift | 2 - .../AutocaptureViewConfigurationTests.swift | 12 ++--- .../InteractionEventCacheTests.swift | 16 +++---- .../AutoCapture/InteractionPayloadTests.swift | 46 +++++++++---------- .../AutoCapture/ScreenNameTrackerTests.swift | 10 ++-- 9 files changed, 52 insertions(+), 54 deletions(-) diff --git a/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift b/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift index 9564dde2..77103c92 100644 --- a/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift +++ b/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift @@ -736,7 +736,7 @@ extension AutoCaptureCoordinater { screenNameTracker.updateScreen(with: screenPayload) let event = Event( type: .screen(title), - properties: [AutoCaptureConstants.source: AutoCaptureConstants.autoCaptureSourceValue] + properties: [Constants.AutoCapture.source: Constants.AutoCapture.autoCaptureSourceValue] ) analyticsPublisher.publish(event) diff --git a/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift b/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift index 6245c46d..16221b06 100644 --- a/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift +++ b/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift @@ -105,7 +105,7 @@ internal final class ScreenNameTracker: ScreenNameTracking { // Common (always included) var screen: [String: String] = [ - AutoCaptureConstants.screenTitle: payload.screenClass + Constants.AutoCapture.screenTitle: payload.screenClass ] return screen diff --git a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift index f53378b9..0ff1d1c0 100644 --- a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift +++ b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift @@ -97,7 +97,7 @@ internal enum UIKitViewResolver { if let label = node.accessibilityLabel? .trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty, - label != AutoCaptureConstants.reductText, + label != Constants.AutoCapture.reductText, !accessibilityRedacted { attributes += "attr__accessibility_label=\"\(label.replacingOccurrences(of: "\"", with: "\\\""))\"" } @@ -105,7 +105,7 @@ internal enum UIKitViewResolver { if let id = node.accessibilityIdentifier? .trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, - id != AutoCaptureConstants.reductText, + id != Constants.AutoCapture.reductText, !accessibilityRedacted { attributes += "attr__id=\"\(id.replacingOccurrences(of: "\"", with: "\\\""))\"" } @@ -347,7 +347,7 @@ internal extension UIView { func resolvedInteractionText(_ raw: String?) -> String? { guard let raw, !raw.isEmpty else { return nil } if isInteractionTextCaptureDisabled() { return nil } - if hasUserpilotRedactTextOptIn() { return AutoCaptureConstants.reductText } + if hasUserpilotRedactTextOptIn() { return Constants.AutoCapture.reductText } return raw } @@ -358,7 +358,7 @@ internal extension UIView { /// is omitted — same omit policy as ``resolvedInteractionText(_:)`` / ``getTextContent()``. func ignoreInnerHierarchyTextPlaceholder() -> String? { if isInteractionTextCaptureDisabled() { return nil } - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } /// Checks if accessibility labels should be hidden: Config @@ -440,7 +440,7 @@ internal extension UIView { return nil } if hasUserpilotRedactTextOptIn() { - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } if let direct = userpilotRawDirectText( @@ -477,7 +477,7 @@ internal extension UIView { return nil } if hasUserpilotRedactAccessibilityLabelOptIn() { - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } guard let label = accessibilityLabel, !label.isEmpty else { diff --git a/Sources/Userpilot/Extensions/UIViewController+Extensions.swift b/Sources/Userpilot/Extensions/UIViewController+Extensions.swift index eef36475..99a2e869 100644 --- a/Sources/Userpilot/Extensions/UIViewController+Extensions.swift +++ b/Sources/Userpilot/Extensions/UIViewController+Extensions.swift @@ -196,8 +196,8 @@ internal extension UIAlertController { } if view.hasUserpilotRedactTextOptIn() { return ( - title == nil ? nil : AutoCaptureConstants.reductText, - message == nil ? nil : AutoCaptureConstants.reductText + title == nil ? nil : Constants.AutoCapture.reductText, + message == nil ? nil : Constants.AutoCapture.reductText ) } return (title, message) diff --git a/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift b/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift index 6ed9cbdb..e1ec2fe3 100644 --- a/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift +++ b/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift @@ -10,8 +10,6 @@ import Foundation -typealias AutoCaptureConstants = Constants.AutoCapture - extension Constants { /// Dictionary / JSON keys for Auto Capture payloads (UIKit, SwiftUI helpers, and screen context). diff --git a/Tests/UserpilotTests/AutoCapture/AutocaptureViewConfigurationTests.swift b/Tests/UserpilotTests/AutoCapture/AutocaptureViewConfigurationTests.swift index c82b7ca2..eef01f57 100644 --- a/Tests/UserpilotTests/AutoCapture/AutocaptureViewConfigurationTests.swift +++ b/Tests/UserpilotTests/AutoCapture/AutocaptureViewConfigurationTests.swift @@ -60,8 +60,8 @@ final class AutocaptureViewConfigurationTests: XCTestCase { let dialogText = alert.userpilotDialogText() - XCTAssertEqual(dialogText.title, AutoCaptureConstants.reductText) - XCTAssertEqual(dialogText.message, AutoCaptureConstants.reductText) + XCTAssertEqual(dialogText.title, Constants.AutoCapture.reductText) + XCTAssertEqual(dialogText.message, Constants.AutoCapture.reductText) } func testDialogTextKeepsNilFieldsWhenRedacted() { @@ -70,7 +70,7 @@ final class AutocaptureViewConfigurationTests: XCTestCase { let dialogText = alert.userpilotDialogText() - XCTAssertEqual(dialogText.title, AutoCaptureConstants.reductText) + XCTAssertEqual(dialogText.title, Constants.AutoCapture.reductText) XCTAssertNil(dialogText.message) } @@ -79,7 +79,7 @@ final class AutocaptureViewConfigurationTests: XCTestCase { // Without a resolvable Userpilot owner, capture defaults to enabled — opt-in still redacts. XCTAssertEqual(view.resolvedInteractionText("Visible"), "Visible") view.userpilotRedactText = true - XCTAssertEqual(view.resolvedInteractionText("Visible"), AutoCaptureConstants.reductText) + XCTAssertEqual(view.resolvedInteractionText("Visible"), Constants.AutoCapture.reductText) } func testAccessibilityLabelContentUsesOptInPlaceholderAndOmitsNilOwnerDefaults() { @@ -90,13 +90,13 @@ final class AutocaptureViewConfigurationTests: XCTestCase { XCTAssertEqual(view.getAccessibilityLabelContent(), "Accessible Field") view.userpilotRedactAccessibilityLabel = true - XCTAssertEqual(view.getAccessibilityLabelContent(), AutoCaptureConstants.reductText) + XCTAssertEqual(view.getAccessibilityLabelContent(), Constants.AutoCapture.reductText) } func testIgnoreInnerHierarchyPlaceholderUsesRedactionWhenCaptureDefaultsEnabled() { let view = UIView() // Without a resolvable owner, text capture defaults to enabled. - XCTAssertEqual(view.ignoreInnerHierarchyTextPlaceholder(), AutoCaptureConstants.reductText) + XCTAssertEqual(view.ignoreInnerHierarchyTextPlaceholder(), Constants.AutoCapture.reductText) } func testResponderClassDefaultsAreUsedUntilExplicitlyOverridden() { diff --git a/Tests/UserpilotTests/AutoCapture/InteractionEventCacheTests.swift b/Tests/UserpilotTests/AutoCapture/InteractionEventCacheTests.swift index 932190ef..121824a4 100644 --- a/Tests/UserpilotTests/AutoCapture/InteractionEventCacheTests.swift +++ b/Tests/UserpilotTests/AutoCapture/InteractionEventCacheTests.swift @@ -49,15 +49,15 @@ final class InteractionEventCacheTests: XCTestCase { textLengthForDedupe: 2 ) - wait(for: [expectation], timeout: AutoCaptureConstants.interactionDebounceInterval + 0.5) + wait(for: [expectation], timeout: Constants.AutoCapture.interactionDebounceInterval + 0.5) XCTAssertEqual(publishedEvents.count, 1) XCTAssertEqual(publishedEvents.first?.interactionEventName, InteractionEventType.textChange.rawValue) XCTAssertEqual( - publishedEvents.first?.properties?[AutoCaptureConstants.targetClass] as? String, + publishedEvents.first?.properties?[Constants.AutoCapture.targetClass] as? String, "LatestTextField" ) - XCTAssertEqual(publishedEvents.first?.properties?[AutoCaptureConstants.textLength] as? Int, 2) + XCTAssertEqual(publishedEvents.first?.properties?[Constants.AutoCapture.textLength] as? Int, 2) } func testDeliveredTextLengthIsDeduplicatedUntilCacheIsFlushed() { @@ -76,7 +76,7 @@ final class InteractionEventCacheTests: XCTestCase { textLengthForDedupe: 3 ) - wait(for: [firstDelivery], timeout: AutoCaptureConstants.interactionDebounceInterval + 0.5) + wait(for: [firstDelivery], timeout: Constants.AutoCapture.interactionDebounceInterval + 0.5) XCTAssertEqual(publishedEvents.count, 1) let duplicateDelivery = XCTestExpectation(description: "duplicate text length should not publish") @@ -91,7 +91,7 @@ final class InteractionEventCacheTests: XCTestCase { textLengthForDedupe: 3 ) - wait(for: [duplicateDelivery], timeout: AutoCaptureConstants.interactionDebounceInterval + 0.2) + wait(for: [duplicateDelivery], timeout: Constants.AutoCapture.interactionDebounceInterval + 0.2) InteractionEventCache.flushAll() @@ -107,7 +107,7 @@ final class InteractionEventCacheTests: XCTestCase { textLengthForDedupe: 3 ) - wait(for: [afterFlushDelivery], timeout: AutoCaptureConstants.interactionDebounceInterval + 0.5) + wait(for: [afterFlushDelivery], timeout: Constants.AutoCapture.interactionDebounceInterval + 0.5) XCTAssertEqual(publishedEvents.count, 2) } @@ -127,12 +127,12 @@ final class InteractionEventCacheTests: XCTestCase { ) InteractionEventCache.flushAll() - wait(for: [expectation], timeout: AutoCaptureConstants.interactionDebounceInterval + 0.2) + wait(for: [expectation], timeout: Constants.AutoCapture.interactionDebounceInterval + 0.2) } private func textPayload(length: Int, targetClass: String = "UITextField") -> InteractionPayload { var payload = InteractionPayload(interactionType: .textFieldChanged, elementType: targetClass) - payload.sourceProperties = [AutoCaptureConstants.textLength: length] + payload.sourceProperties = [Constants.AutoCapture.textLength: length] return payload } } diff --git a/Tests/UserpilotTests/AutoCapture/InteractionPayloadTests.swift b/Tests/UserpilotTests/AutoCapture/InteractionPayloadTests.swift index ae3c97d4..a1253306 100644 --- a/Tests/UserpilotTests/AutoCapture/InteractionPayloadTests.swift +++ b/Tests/UserpilotTests/AutoCapture/InteractionPayloadTests.swift @@ -43,19 +43,19 @@ final class InteractionPayloadTests: XCTestCase { let dict = payload.toDictionary() - XCTAssertEqual(dict[AutoCaptureConstants.targetClass] as? String, "UIButton") - XCTAssertEqual(dict[AutoCaptureConstants.targetText] as? String, "Continue") - XCTAssertEqual(dict[AutoCaptureConstants.accessibilityLabel] as? String, "Continue label") - XCTAssertEqual(dict[AutoCaptureConstants.accessibilityIdentifier] as? String, "continue_button") - XCTAssertEqual(dict[AutoCaptureConstants.hierarchy] as? String, "Root/UIButton[0]") - XCTAssertEqual(dict[AutoCaptureConstants.targetAction] as? String, "continueTapped:") - XCTAssertEqual(dict[AutoCaptureConstants.ownerTargetClass] as? String, "ViewController") - XCTAssertEqual(dict[AutoCaptureConstants.targetViewName] as? String, "continueButton") - XCTAssertEqual(dict[AutoCaptureConstants.placeholder] as? String, "Placeholder") - XCTAssertEqual(dict[AutoCaptureConstants.dialogTitle] as? String, "Dialog") - XCTAssertEqual(dict[AutoCaptureConstants.dialogMessage] as? String, "Message") - XCTAssertEqual(dict[AutoCaptureConstants.section] as? Int, 2) - XCTAssertEqual(dict[AutoCaptureConstants.selectedIndex] as? Int, 4) + XCTAssertEqual(dict[Constants.AutoCapture.targetClass] as? String, "UIButton") + XCTAssertEqual(dict[Constants.AutoCapture.targetText] as? String, "Continue") + XCTAssertEqual(dict[Constants.AutoCapture.accessibilityLabel] as? String, "Continue label") + XCTAssertEqual(dict[Constants.AutoCapture.accessibilityIdentifier] as? String, "continue_button") + XCTAssertEqual(dict[Constants.AutoCapture.hierarchy] as? String, "Root/UIButton[0]") + XCTAssertEqual(dict[Constants.AutoCapture.targetAction] as? String, "continueTapped:") + XCTAssertEqual(dict[Constants.AutoCapture.ownerTargetClass] as? String, "ViewController") + XCTAssertEqual(dict[Constants.AutoCapture.targetViewName] as? String, "continueButton") + XCTAssertEqual(dict[Constants.AutoCapture.placeholder] as? String, "Placeholder") + XCTAssertEqual(dict[Constants.AutoCapture.dialogTitle] as? String, "Dialog") + XCTAssertEqual(dict[Constants.AutoCapture.dialogMessage] as? String, "Message") + XCTAssertEqual(dict[Constants.AutoCapture.section] as? Int, 2) + XCTAssertEqual(dict[Constants.AutoCapture.selectedIndex] as? Int, 4) } func testInteractionPayloadSourceDictionaryReturnsControlProperties() { @@ -83,15 +83,15 @@ final class InteractionPayloadTests: XCTestCase { let dict = payload.toDictionary() - XCTAssertEqual(dict[AutoCaptureConstants.screenName] as? String, "Home") - XCTAssertEqual(dict[AutoCaptureConstants.screenClass] as? String, "HomeViewController") - XCTAssertEqual(dict[AutoCaptureConstants.screenType] as? String, "UIViewController") - XCTAssertEqual(dict[AutoCaptureConstants.navigationTitle] as? String, "Dashboard") - XCTAssertEqual(dict[AutoCaptureConstants.vcAccessibilityIdentifier] as? String, "home_vc") - XCTAssertEqual(dict[AutoCaptureConstants.vcAccessibilityLabel] as? String, "Home VC") - XCTAssertEqual(dict[AutoCaptureConstants.screenNameMatchesPreviousScreen] as? Bool, true) - XCTAssertEqual(dict[AutoCaptureConstants.uiFramework] as? String, Userpilot.AppFramework.SwiftUI.rawValue) - XCTAssertEqual(dict[AutoCaptureConstants.source] as? String, AutoCaptureConstants.autoCaptureSourceValue) + XCTAssertEqual(dict[Constants.AutoCapture.screenName] as? String, "Home") + XCTAssertEqual(dict[Constants.AutoCapture.screenClass] as? String, "HomeViewController") + XCTAssertEqual(dict[Constants.AutoCapture.screenType] as? String, "UIViewController") + XCTAssertEqual(dict[Constants.AutoCapture.navigationTitle] as? String, "Dashboard") + XCTAssertEqual(dict[Constants.AutoCapture.vcAccessibilityIdentifier] as? String, "home_vc") + XCTAssertEqual(dict[Constants.AutoCapture.vcAccessibilityLabel] as? String, "Home VC") + XCTAssertEqual(dict[Constants.AutoCapture.screenNameMatchesPreviousScreen] as? Bool, true) + XCTAssertEqual(dict[Constants.AutoCapture.uiFramework] as? String, Userpilot.AppFramework.SwiftUI.rawValue) + XCTAssertEqual(dict[Constants.AutoCapture.source] as? String, Constants.AutoCapture.autoCaptureSourceValue) } func testManualScreenTrackingPayloadUsesScreenTitleAsNameAndClass() { @@ -100,6 +100,6 @@ final class InteractionPayloadTests: XCTestCase { XCTAssertEqual(payload.currentScreen, "Manual Screen") XCTAssertEqual(payload.screenClass, "Manual Screen") - XCTAssertEqual(dict[AutoCaptureConstants.uiFramework] as? String, Userpilot.AppFramework.UIKit.rawValue) + XCTAssertEqual(dict[Constants.AutoCapture.uiFramework] as? String, Userpilot.AppFramework.UIKit.rawValue) } } diff --git a/Tests/UserpilotTests/AutoCapture/ScreenNameTrackerTests.swift b/Tests/UserpilotTests/AutoCapture/ScreenNameTrackerTests.swift index 5e7d9e2b..ee9a8518 100644 --- a/Tests/UserpilotTests/AutoCapture/ScreenNameTrackerTests.swift +++ b/Tests/UserpilotTests/AutoCapture/ScreenNameTrackerTests.swift @@ -26,13 +26,13 @@ final class ScreenNameTrackerTests: XCTestCase { tracker.updateScreen(with: payload) XCTAssertEqual(tracker.getCurrentPayload(), payload) - XCTAssertEqual(tracker.buildScreenDictionary()[AutoCaptureConstants.screenName] as? String, "Home") + XCTAssertEqual(tracker.buildScreenDictionary()[Constants.AutoCapture.screenName] as? String, "Home") XCTAssertEqual( - tracker.buildScreenDictionaryForEvent()[AutoCaptureConstants.screenTitle], + tracker.buildScreenDictionaryForEvent()[Constants.AutoCapture.screenTitle], "HomeViewController" ) XCTAssertEqual( - tracker.buildScreenDictionaryForEvent()[AutoCaptureConstants.navigationTitle], + tracker.buildScreenDictionaryForEvent()[Constants.AutoCapture.navigationTitle], "Dashboard" ) @@ -61,7 +61,7 @@ final class ScreenNameTrackerTests: XCTestCase { tracker.updateScreen(with: payload) let eventDict = tracker.buildScreenDictionaryForEvent() - XCTAssertEqual(eventDict[AutoCaptureConstants.screenTitle], "HomeViewController") - XCTAssertNil(eventDict[AutoCaptureConstants.navigationTitle]) + XCTAssertEqual(eventDict[Constants.AutoCapture.screenTitle], "HomeViewController") + XCTAssertNil(eventDict[Constants.AutoCapture.navigationTitle]) } } From 7fba6a65a5547a5e1ce5e43e4cb807c0499277c4 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:41:55 +0300 Subject: [PATCH 19/95] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20fix(autocapture):?= =?UTF-8?q?=20Enforce=20capture=20policy=20on=20text=20and=20label=20paylo?= =?UTF-8?q?ads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several capture sites bypassed the policy. Three gates in UIApplication+AutoCapture were inverted and published the raw menu or bar item title only when the customer had disabled text capture. The rest called shouldRedactText(), which collapses "capture disabled" and "redact this view" into the same "****" value, so the backend could not tell them apart. Every site now goes through resolvedInteractionText, ignoreInnerHierarchyTextPlaceholder and getAccessibilityLabelContent: a disabled flag omits the property and only the per-view opt-in emits the placeholder. Accessibility labels get the same treatment. Co-Authored-By: Claude Opus 5 (1M context) --- .../Extensions/UICollectionView+AutoCapture.swift | 6 ++---- .../Extensions/UIControl+AutoCapture.swift | 8 +++----- .../Extensions/UIPickerView+AutoCapture.swift | 6 +++--- .../Extensions/UITableView+AutoCapture.swift | 6 ++---- .../Extensions/UIApplication+AutoCapture.swift | 12 ++++++------ .../Userpilot/Extensions/UIWindow+Extensions.swift | 10 ++++++---- 6 files changed, 22 insertions(+), 26 deletions(-) diff --git a/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift index e7976c74..6ea531aa 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift @@ -49,12 +49,10 @@ internal extension UICollectionViewCell { if let labelViewType = (touchedView ?? self).resolveUserpilotLabelViewType() { payload.targetClass = labelViewType } - payload.elementText = (touchedView ?? self).shouldRedactText() - ? Constants.AutoCapture.reductText - : userpilotLabel + payload.elementText = (touchedView ?? self).resolvedInteractionText(userpilotLabel) } else if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = Constants.AutoCapture.reductText + payload.elementText = effectiveView.ignoreInnerHierarchyTextPlaceholder() } else { payload.elementText = touchedView?.getTextContent() payload.accessibilityIdentifier = accessibilityIdentifier diff --git a/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift index b0e8af96..ebc50b17 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift @@ -112,7 +112,7 @@ internal extension UIControl { if config.enableInteractionValueCapture { payload.sourceProperties[Constants.AutoCapture.selectedIndex] = segmentedControl.selectedSegmentIndex let raw = segmentedControl.titleForSegment(at: segmentedControl.selectedSegmentIndex) - let title: String? = shouldRedactText() ? (raw != nil ? Constants.AutoCapture.reductText : nil) : raw + let title = resolvedInteractionText(raw) if let title { payload.sourceProperties[Constants.AutoCapture.selectedValue] = title } @@ -174,16 +174,14 @@ internal extension UIControl { payload.hierarchy = UIKitViewResolver.resolvePath(view: effectiveView, leafIndexOverride: leafIndexOverride) if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = Constants.AutoCapture.reductText + payload.elementText = ignoreInnerHierarchyTextPlaceholder() } if let userpilotLabel = resolveUserpilotLabel() { if let labelViewType = resolveUserpilotLabelViewType() { payload.targetClass = labelViewType } - payload.elementText = shouldRedactText() - ? Constants.AutoCapture.reductText - : userpilotLabel + payload.elementText = resolvedInteractionText(userpilotLabel) } // IBOutlet reference name (e.g., "submitButton", "searchTextField") diff --git a/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift index cc647077..9a9b766e 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift @@ -107,9 +107,9 @@ internal extension UIPickerView { // `UILabel`), and may also use `attributedTitleForRow:forComponent:`. Try all three // so `selected_value` is populated for both UIKit and SwiftUI pickers. if config.enableInteractionValueCapture, - let title = userpilotResolvedSelectedTitle(forRow: row, component: component) { - payload.sourceProperties[Constants.AutoCapture.selectedValue] = - shouldRedactText() ? Constants.AutoCapture.reductText : title + let title = userpilotResolvedSelectedTitle(forRow: row, component: component), + let resolvedTitle = resolvedInteractionText(title) { + payload.sourceProperties[Constants.AutoCapture.selectedValue] = resolvedTitle } // View path and accessibility diff --git a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift index fcfd5afe..8b470888 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift @@ -49,12 +49,10 @@ internal extension UITableViewCell { if let labelViewType = (touchedView ?? self).resolveUserpilotLabelViewType() { payload.targetClass = labelViewType } - payload.elementText = (touchedView ?? self).shouldRedactText() - ? Constants.AutoCapture.reductText - : userpilotLabel + payload.elementText = (touchedView ?? self).resolvedInteractionText(userpilotLabel) } else if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = Constants.AutoCapture.reductText + payload.elementText = effectiveView.ignoreInnerHierarchyTextPlaceholder() } else { payload.elementText = touchedView?.getTextContent() payload.accessibilityIdentifier = accessibilityIdentifier diff --git a/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift b/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift index 140f1389..831063b3 100644 --- a/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift +++ b/Sources/Userpilot/Extensions/UIApplication+AutoCapture.swift @@ -199,7 +199,7 @@ extension UIApplication { if let target = target { payload.ownerTargetClass = String(describing: type(of: target)) } - if !config.enableInteractionTextCapture { + if config.enableInteractionTextCapture { payload.elementText = menuAction.title } payload.accessibilityIdentifier = menuAction.identifier.rawValue @@ -222,7 +222,7 @@ extension UIApplication { if let target = target { payload.ownerTargetClass = String(describing: type(of: target)) } - if !config.enableInteractionTextCapture { + if config.enableInteractionTextCapture { payload.elementText = menu.title } payload.accessibilityIdentifier = menu.identifier.rawValue @@ -254,7 +254,7 @@ extension UIApplication { payload.hierarchy = path if useRedactedInner { - payload.elementText = Constants.AutoCapture.reductText + payload.elementText = view.ignoreInnerHierarchyTextPlaceholder() } else { payload.elementText = view.getTextContent() payload.accessibilityIdentifier = view.accessibilityIdentifier @@ -281,10 +281,10 @@ extension UIApplication { if let target = target { payload.ownerTargetClass = String(describing: type(of: target)) } - if !config.enableInteractionTextCapture { + if config.enableInteractionTextCapture { payload.elementText = item.title } - if !config.enableInteractionAccessibilityLabelCapture { + if config.enableInteractionAccessibilityLabelCapture { payload.accessibilityLabel = item.accessibilityLabel } payload.accessibilityIdentifier = item.accessibilityIdentifier @@ -320,7 +320,7 @@ extension UIApplication { payload.hierarchy = path if useRedactedInner { - payload.elementText = Constants.AutoCapture.reductText + payload.elementText = view.ignoreInnerHierarchyTextPlaceholder() } else { payload.elementText = view.getTextContent() payload.accessibilityIdentifier = view.accessibilityIdentifier diff --git a/Sources/Userpilot/Extensions/UIWindow+Extensions.swift b/Sources/Userpilot/Extensions/UIWindow+Extensions.swift index e8c44829..67e30211 100644 --- a/Sources/Userpilot/Extensions/UIWindow+Extensions.swift +++ b/Sources/Userpilot/Extensions/UIWindow+Extensions.swift @@ -208,11 +208,13 @@ extension UIWindow { if let labelViewType = capture.viewType { eventProperties[Constants.AutoCapture.targetClass] = labelViewType } - eventProperties[Constants.AutoCapture.targetText] = capture.labeledView.shouldRedactText() - ? Constants.AutoCapture.reductText - : capture.label + if let resolvedLabel = capture.labeledView.resolvedInteractionText(capture.label) { + eventProperties[Constants.AutoCapture.targetText] = resolvedLabel + } } else if useRedactedInner { - eventProperties[Constants.AutoCapture.targetText] = Constants.AutoCapture.reductText + if let placeholder = view.ignoreInnerHierarchyTextPlaceholder() { + eventProperties[Constants.AutoCapture.targetText] = placeholder + } } else { if let accessibilityIdentifier = view.accessibilityIdentifier, !accessibilityIdentifier.isEmpty { eventProperties[Constants.AutoCapture.accessibilityIdentifier] = accessibilityIdentifier From c6123e34a2c8390ffdb010aeb596715c91f6b3ff Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:42:58 +0300 Subject: [PATCH 20/95] =?UTF-8?q?=F0=9F=90=9B=20fix(experiences):=20Bring?= =?UTF-8?q?=20forward=20seen-status=20dedup=20and=20preview=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that never reached 1.4.0, ported from 1.3.0 (092066f, f2dc033). They land together because both rewrite the same response-handling region of ExperiencesPublisher; separating them would leave a commit that does not compile. Automatic selection stopped at the first decoded content type, so an unseen Survey or NPS never showed once a Flow had been seen on that screen. It now decodes all candidates and picks the first unseen one, leaves manual fetchExperienceContent responses unfiltered, rejects content that duplicates the active, pending or cached experience, and drops a cached experience that became seen before it could be replayed. A preview also travels through resetState, a network fetch and the experience queue before it renders, and any of those hops could be overtaken by a second deep link or a lifecycle reset — the abandoned preview's late response still rendered. PreviewSessionTracker gives each attempt an id that every hop re-checks. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 30 +++ .../Experiences/ExperiencesPublisher.swift | 181 +++++++++++++++--- .../Experiences/PreviewSessionTracker.swift | 70 +++++++ .../ExperiencesPublisherTests.swift | 77 ++++++++ Tests/UserpilotTests/MockUserpilot.swift | 5 + 5 files changed, 339 insertions(+), 24 deletions(-) create mode 100644 Sources/Userpilot/Experiences/PreviewSessionTracker.swift diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 91da015f..4233b85d 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -58,6 +58,16 @@ internal protocol AnalyticsPublishing: AnyObject { _ experienceId: Int ) + /** + * Returns whether the experience was already displayed on the current screen session. + * + * Flows use `seenExperiences`, Surveys use `seenSurveys`, and NPS returns `false` because NPS + * is deduplicated by `ExperiencesPublisher` using its last tracked screen. The screen session + * retains seen ids when the same screen is reported again and starts with empty sets when the + * screen title changes. + */ + func isExperienceSeen(_ experienceContent: ExperienceContent) -> Bool + /// For experience which are come from start session var isStartSession: Bool { get } @@ -1061,6 +1071,26 @@ extension AnalyticsPublisher { startSession } + /** + * Checks the current screen's type-specific seen set for the supplied experience. + * + * A Flow id is checked only in `seenExperiences`, and a Survey id only in `seenSurveys`, so a + * Flow and Survey sharing a numeric id stay distinct. NPS always returns `false` here and + * continues through the existing per-screen NPS deduplication in `ExperiencesPublisher`. + * With no screen session yet, Flow and Survey are treated as unseen. + */ + func isExperienceSeen(_ experienceContent: ExperienceContent) -> Bool { + switch experienceContent { + case .flow(let content): + return screenSessionStateMachine?.seenExperiences.contains(content.id) == true + case .survey(let content): + return screenSessionStateMachine?.seenSurveys.contains(content.id) == true + case .nps: + // NPS deduplication is managed by ExperiencesPublisher per screen. + return false + } + } + /** * Publishes internal SDK events through the socket. * diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index b150298f..b2286ea2 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -123,6 +123,9 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Queue to track pending experience content waiting to be displayed private var pendingExperiences: [ExperienceContent] = [] + /// Rejects preview responses that were overtaken by a newer preview or a lifecycle reset. + private let previewSessionTracker = PreviewSessionTracker() + /// Utility for managing display delays for surveys and NPS experiences private lazy var delayUtils = DelayUtils() @@ -199,6 +202,9 @@ internal class ExperiencesPublisher: ExperiencesPublishing { return requestFakeScreenReloadEventDate?.isMoreThanOneSecond(from: Date()) ?? true && !hasActiveExperience && !experienceStateMachine.isActive() + // A preview being set up is not yet "active", but requesting screen events during it + // would drive normal content on top of the draft being previewed. + && !isPreviewExperienceMode() && !experienceStateMachine.hasCachedExperience() } @@ -347,7 +353,9 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Preview mode suppresses normal analytics while draft content is being rendered. func isPreviewExperienceMode() -> Bool { - experienceStateMachine.isPreviewMode() + // The tracker covers the window before the state machine is marked: a preview is already + // in flight while `resetState` and the fetch are still running. + experienceStateMachine.isPreviewMode() || previewSessionTracker.isActive() } // MARK: - Deep Link Handling @@ -447,10 +455,28 @@ extension ExperiencesPublisher: SocketSubscription { tryCatch { if currentScreen == screenName { return } currentScreen = screenName + // A preview renders on whatever screen the deep link landed on, so the screen change + // that reveals it must resume it instead of resetting it away. + if isPreviewExperienceMode() { + resumePendingPreviewExperience() + return + } resetState() } } + /// Re-opens a preview that was waiting for the screen it was triggered from. + private func resumePendingPreviewExperience() { + experienceQueue.async { [weak self] in + guard + let self, + case .pendingPreview = self.experienceStateMachine.getCurrentState(), + !self.pendingExperiences.isEmpty + else { return } + self.openExperienceFlow() + } + } + /// The current screen used for experience targeting. var getCurrentScreen: String { currentScreen @@ -495,20 +521,23 @@ extension ExperiencesPublisher: SocketSubscription { if eventName == Constants.Event.screenEvent || eventName == SDKEventsName.fetchExperienceContent.rawValue { - // Determine the new experience based on response - let experience: ExperienceContent? = { - if let flowContent = response.toFlowContent()?.flowContent { - return .flow(content: flowContent) - } else if let surveyContent = response.toSurveyContent()?.surveyContent { - return .survey(content: surveyContent) - } else if let npsContent = response.toNPSContent()?.npsContent { - return .nps(content: npsContent) - } - return nil - }() + // Decode every type the response carries instead of stopping at the first, so + // automatic selection can skip past a candidate that was already seen. + let triggerType = self.triggerTypeForEvent(eventName) + let candidates = self.experienceCandidates(response) + let experience: ExperienceContent? = triggerType == .automatic + ? candidates.first { !self.analyticsPublisher.isExperienceSeen($0) } + : candidates.first if let experience { - if eventName == SDKEventsName.fetchExperienceContent.rawValue { + if self.isDuplicateExperience(experience) { + self.logger.info( + "Ignoring duplicate experience: %@", + experience.experienceId().toString() + ) + return + } + if triggerType == .manual { self.experienceStateMachine.markManualTrigger( experience.experienceId().toString() ) @@ -986,6 +1015,12 @@ extension ExperiencesPublisher { if pendingExperiences.isEmpty { return } experienceQueue.async { [weak self] in guard let self else { return } + // A live preview owns the queue: re-assert preview mode instead of draining the + // pending experience out from under it. + if self.previewSessionTracker.isActive() { + self.experienceStateMachine.markPreviewMode() + return + } self.resetProcessingExperienceStatus() if self.pendingExperiences.count == 1 { self.pendingExperiences.removeAll() @@ -1006,6 +1041,8 @@ extension ExperiencesPublisher { * becomes invalid from view models. */ private func resetState() { + // A lifecycle reset abandons any preview in flight; its late response must not render. + previewSessionTracker.cancel() resetState(completion: nil) } @@ -1031,7 +1068,76 @@ extension ExperiencesPublisher { experienceStateMachine.markIdle() } - private func resetProcessingPreviewExperienceStatus() { + /** + * Decodes every experience type present in the response, in Flow → Survey → NPS order. + * + * Returning all of them (rather than stopping at the first that decodes) is what lets automatic + * selection fall through to an unseen Survey or NPS when the Flow was already shown on this + * screen. + */ + private func experienceCandidates(_ response: String) -> [ExperienceContent] { + var candidates: [ExperienceContent] = [] + if let flowContent = response.toFlowContent()?.flowContent { + candidates.append(.flow(content: flowContent)) + } + if let surveyContent = response.toSurveyContent()?.surveyContent { + candidates.append(.survey(content: surveyContent)) + } + if let npsContent = response.toNPSContent()?.npsContent { + candidates.append(.nps(content: npsContent)) + } + return candidates + } + + /// `fetchExperienceContent` is a manual API response and bypasses seen-screen filtering; screen + /// events are automatic and must select an unseen candidate. + private func triggerTypeForEvent(_ eventName: String) -> TriggerType { + eventName == SDKEventsName.fetchExperienceContent.rawValue ? .manual : .automatic + } + + /** + * Whether this experience is already active, queued, or cached. + * + * Distinct from a seen check: seen content was already displayed on this screen, whereas a + * duplicate is the same content arriving twice before it has been shown. + */ + private func isDuplicateExperience(_ experience: ExperienceContent) -> Bool { + if sameExperience(experienceStateMachine.getActiveContent(), experience) { + return true + } + if pendingExperiences.contains(where: { sameExperience($0, experience) }) { + return true + } + return sameExperience(experienceStateMachine.getCachedExperienceContent(), experience) + } + + /// Identity comparison per type — a Flow and a Survey sharing a numeric id are not the same + /// experience, and NPS is identified by its survey key rather than an id. + private func sameExperience( + _ first: ExperienceContent?, + _ second: ExperienceContent + ) -> Bool { + switch (first, second) { + case (.flow(let firstContent), .flow(let secondContent)): + return firstContent.id == secondContent.id + case (.survey(let firstContent), .survey(let secondContent)): + return firstContent.id == secondContent.id + case (.nps(let firstContent), .nps(let secondContent)): + return firstContent.content.survey.key == secondContent.content.survey.key + default: + return false + } + } + + /// - Parameter previewSessionId: When given, the reset only applies if that session is still the + /// active one — a superseded preview must not tear down the one that replaced it. When `nil` + /// the caller is abandoning whatever preview is active. + private func resetProcessingPreviewExperienceStatus(previewSessionId: UInt64? = nil) { + if let previewSessionId { + guard previewSessionTracker.finish(previewSessionId) else { return } + } else { + previewSessionTracker.cancel() + } resetProcessingExperienceStatus() } @@ -1040,6 +1146,16 @@ extension ExperiencesPublisher { switch state { case .cachedPendingAutomatic(let experience): activeExperience = nil + // It waited behind another experience; if it was shown in the meantime, replaying it + // would show the same content twice on one screen. + if analyticsPublisher.isExperienceSeen(experience) { + logger.info( + "Ignoring cached experience already seen: %@", + experience.experienceId().toString() + ) + resetProcessingExperienceStatus() + return + } pendingExperiences.append(experience) experienceStateMachine.markAutomaticTrigger(experience) checkCachedThemes(experience.experienceThemeId()) @@ -1060,8 +1176,11 @@ extension ExperiencesPublisher { extension ExperiencesPublisher { func triggerPreviewExperience(_ experienceId: String, _ queryItems: [URLQueryItem]) { + // Claim the session before `resetState` — that call is asynchronous, so a second deep link + // arriving during it must supersede this attempt rather than race it. + let previewSessionId = previewSessionTracker.begin() resetState { [weak self] in - guard let self else { return } + guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } self.experienceStateMachine.markPreviewMode() self.userpilotRemoteSource.fetchPreviewExperience( params: PreviewExperienceQueryParams( @@ -1071,24 +1190,34 @@ extension ExperiencesPublisher { contentId: experienceId ), completion: { [weak self] result in - guard let self else { return } + guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } switch result { case .success(let previewExperience): - self.processPreviewExperience(previewExperience) + self.processPreviewExperience( + previewExperience, + previewSessionId: previewSessionId + ) case .failure(let error): - self.showExperienceTriggeringDebugMessage(error.localizedDescription) + self.showExperienceTriggeringDebugMessage( + error.localizedDescription, + previewSessionId: previewSessionId + ) } } ) } } - private func processPreviewExperience(_ previewExperience: PreviewExperience) { + private func processPreviewExperience( + _ previewExperience: PreviewExperience, + previewSessionId: UInt64 + ) { tryCatch { + guard previewSessionTracker.isCurrent(previewSessionId) else { return } guard let theme = previewExperience.theme, previewExperience.flow != nil || previewExperience.survey != nil else { - resetProcessingPreviewExperienceStatus() + resetProcessingPreviewExperienceStatus(previewSessionId: previewSessionId) return } @@ -1102,12 +1231,12 @@ extension ExperiencesPublisher { } guard let experience else { - resetProcessingPreviewExperienceStatus() + resetProcessingPreviewExperienceStatus(previewSessionId: previewSessionId) return } experienceQueue.async { [weak self] in - guard let self else { return } + guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } self.pendingExperiences.append(experience) self.themeHandler.saveTheme(theme) self.openExperienceFlow() @@ -1115,8 +1244,12 @@ extension ExperiencesPublisher { } } - private func showExperienceTriggeringDebugMessage(_ message: String) { - resetProcessingPreviewExperienceStatus() + private func showExperienceTriggeringDebugMessage( + _ message: String, + previewSessionId: UInt64 + ) { + guard previewSessionTracker.isCurrent(previewSessionId) else { return } + resetProcessingPreviewExperienceStatus(previewSessionId: previewSessionId) performOn(.main) { [weak self] in guard let self, diff --git a/Sources/Userpilot/Experiences/PreviewSessionTracker.swift b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift new file mode 100644 index 00000000..b6ab5f07 --- /dev/null +++ b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift @@ -0,0 +1,70 @@ +// +// PreviewSessionTracker.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// +// [Brief Description] +// Tracks the active preview request so stale asynchronous responses can be rejected. +// + +import Foundation + +/// Tracks the active preview request and rejects stale asynchronous responses. +/// +/// A preview is opened from a deep link and then travels through `resetState`, a network fetch, and +/// the experience queue before anything renders. Any of those hops can be overtaken by a second +/// deep link or by a lifecycle reset, and the late response from the abandoned preview would +/// otherwise still render. Each attempt takes an id and every hop re-checks that it is still the +/// current one. +/// +/// Thread-safe: the hops it guards run on the URLSession delegate queue, the experience queue, and +/// main. +internal final class PreviewSessionTracker { + + private let lock = NSLock() + private var nextId: UInt64 = 0 + private var activeId: UInt64? + + /// Starts a new preview session, superseding any previous one. + /// - Returns: The id to carry through this attempt's async hops. + func begin() -> UInt64 { + lock.lock() + defer { lock.unlock() } + nextId += 1 + activeId = nextId + return nextId + } + + /// Whether `id` is still the active session — false once superseded or cancelled. + func isCurrent(_ id: UInt64) -> Bool { + lock.lock() + defer { lock.unlock() } + return activeId == id + } + + /// Whether any preview session is in flight. + func isActive() -> Bool { + lock.lock() + defer { lock.unlock() } + return activeId != nil + } + + /// Closes `id` if it is still active. + /// - Returns: `true` exactly once for the session that was current. + @discardableResult + func finish(_ id: UInt64) -> Bool { + lock.lock() + defer { lock.unlock() } + guard activeId == id else { return false } + activeId = nil + return true + } + + /// Abandons whatever session is active, so its pending responses are ignored. + func cancel() { + lock.lock() + activeId = nil + lock.unlock() + } +} diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index e2db9c28..937c08ed 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -622,6 +622,83 @@ final class ExperiencesPublisherTests: XCTestCase { } wait(for: [resultExpectation], timeout: 1.0) } + + // MARK: - Preview session tracking + + func testCanRequestScreenEvent_shouldReturnFalse_WhenPreviewIsPending() { + // Arrange + userpilot.experienceStateMachine.markPreviewMode() + + // Act + let result = experiencesPublisher.canRequestScreenEvent() + + // Assert + XCTAssertFalse(result) + } + + func testUpdateScreen_shouldPreservePendingPreview() { + // Arrange + userpilot.experienceStateMachine.markPreviewMode() + + // Act + experiencesPublisher.updateScreen("PreviewScreen") + + // Assert — a screen change must not cancel a preview that is still being set up + XCTAssertTrue(userpilot.experienceStateMachine.isPreviewMode()) + } + + func testTriggerPreviewExperience_shouldIgnoreStaleResponse_WhenNewerPreviewStarts() throws { + // Arrange — hold both fetch completions so they can be resolved out of order + let fetchExpectation = XCTestExpectation(description: "both preview fetches requested") + fetchExpectation.expectedFulfillmentCount = 2 + var completions: [ + String: (Result) -> Void + ] = [:] + userpilot.remoteSource.onFetchPreviewExperience = { params, completion in + completions[params.contentId] = completion + fetchExpectation.fulfill() + } + + let latestThemeSaved = XCTestExpectation(description: "latest preview theme saved") + var savedThemeIds: [Int] = [] + userpilot.themeHandler.onSaveTheme = { theme in + if let id = theme.id { + savedThemeIds.append(id) + if id == 22 { + latestThemeSaved.fulfill() + } + } + } + + // Act — the second preview supersedes the first, then the stale one answers + experiencesPublisher.triggerPreviewExperience("first", []) + experiencesPublisher.triggerPreviewExperience("second", []) + wait(for: [fetchExpectation], timeout: 1.0) + completions["first"]?(.success(try makePreviewExperience(themeId: 11))) + completions["second"]?(.success(try makePreviewExperience(themeId: 22))) + + // Assert — only the current preview renders; the abandoned one is dropped + wait(for: [latestThemeSaved], timeout: 1.0) + XCTAssertFalse(savedThemeIds.contains(11)) + } + + private func makePreviewExperience(themeId: Int) throws -> PreviewExperience { + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + return PreviewExperience( + flow: flow, + survey: nil, + contentType: "flow", + theme: ThemeContent( + id: themeId, + themeData: ThemeData(carousel: nil, slideOut: nil, survey: nil) + ) + ) + } } // swiftlint:disable all diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index db9380d9..e51edc10 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -338,6 +338,11 @@ class MockAnalyticsPublisher: AnalyticsPublishing { onExperiencePublished?(experienceType, experienceId) } + var onIsExperienceSeen: ((ExperienceContent) -> Bool)? + func isExperienceSeen(_ experienceContent: ExperienceContent) -> Bool { + onIsExperienceSeen?(experienceContent) ?? false + } + var isStartSession: Bool = false var screenSessionStateMachine: ScreenSessionStateMachine? From c5e794e68b13469c7ee6c06ff42b9eaeb8bb0983 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:43:07 +0300 Subject: [PATCH 21/95] =?UTF-8?q?=F0=9F=90=9B=20fix(experiences):=20Stop?= =?UTF-8?q?=20preview=20thank-you=20screens=20from=20sending=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thank-you state replaces the active state that knew its triggerType, so a previewed survey's thank-you screen looked like a real one and published real events. showingThankYou now carries the preview flag, captured before the outgoing state is overwritten. Ported from 1.3.0 (31de0b2) onto 1.4.0's renamed state machine. Co-Authored-By: Claude Opus 5 (1M context) --- .../Experiences/ExperienceStateMachine.swift | 19 ++++++++++---- .../ExperienceStateMachineTests.swift | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperienceStateMachine.swift b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift index 4c918eaf..843b58c4 100644 --- a/Sources/Userpilot/Experiences/ExperienceStateMachine.swift +++ b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift @@ -46,7 +46,11 @@ internal enum ExperienceFlowState { case active(triggerType: TriggerType, content: ExperienceContent) /// Thank you message is displayed after survey completion. - case showingThankYou + /// + /// Carries the preview flag forward: the thank-you state replaces the `active` state that knew + /// its `triggerType`, so without it a previewed survey's thank-you screen looks like a real one + /// and publishes real events. + case showingThankYou(isPreview: Bool) /// Manual experience cached because another experience is in progress. case cachedPendingManual(experienceId: String) @@ -78,6 +82,8 @@ extension ExperienceFlowState { return true case .waitingDelay(let triggerType), .active(let triggerType, _): return triggerType == .preview + case .showingThankYou(let isPreview): + return isPreview default: return false } @@ -136,8 +142,8 @@ extension ExperienceFlowState: CustomStringConvertible { return "WaitingDelay(\(triggerType))" case .active(let triggerType, let content): return "Active(\(triggerType), content=\(type(of: content)))" - case .showingThankYou: - return "ShowingThankYou" + case .showingThankYou(let isPreview): + return "ShowingThankYou(preview=\(isPreview))" case .cachedPendingManual(let experienceId): return "CachedPendingManual(id=\(experienceId))" case .cachedPendingAutomatic: @@ -297,8 +303,11 @@ extension ExperienceStateMachine: ExperienceStateManaging { } func markShowingThankYou() { - state.value = .showingThankYou - logger.info("Experience state: ShowingThankYou") + // Read the flag before overwriting the state — the outgoing `active`/`waitingDelay` state is + // the only thing that knows this was a preview. + let isPreview = state.value.isPreviewMode() + state.value = .showingThankYou(isPreview: isPreview) + logger.info("Experience state: ShowingThankYou(preview=%@)", String(isPreview)) } func markCachedManual(_ experienceId: String) { diff --git a/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift index 3d5bf13e..434185a5 100644 --- a/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift @@ -145,6 +145,31 @@ final class ExperienceStateMachineTests: XCTestCase { XCTAssertTrue(stateManager.isActivelyRendered()) } + func testMarkShowingThankYou_preservesPreviewMode() { + // Arrange + stateManager.markPreviewMode() + stateManager.markActiveFromCurrentState(content: makeExperienceContent()) + + // Act — the thank-you state replaces the active state that knew it was a preview + stateManager.markShowingThankYou() + + // Assert + XCTAssertTrue(stateManager.isPreviewMode()) + XCTAssertTrue(stateManager.shouldBypassScreenValidation()) + } + + func testMarkShowingThankYou_doesNotInventPreviewModeForRealExperience() { + // Arrange + stateManager.markAutomaticTrigger(makeExperienceContent()) + stateManager.markActiveFromCurrentState(content: makeExperienceContent()) + + // Act + stateManager.markShowingThankYou() + + // Assert + XCTAssertFalse(stateManager.isPreviewMode()) + } + func testMarkCachedManual_setsCachedExperienceId() { // Act stateManager.markCachedManual("cached-exp") From aa505809117618e0c76d29ce7834f9026b3ad862 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:43:15 +0300 Subject: [PATCH 22/95] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20refactor(remote):=20?= =?UTF-8?q?Remove=20the=20empty=20SDKSettingsDetector=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file held only a comment noting that settings fetching had moved to UserpilotRemoteSource. Nothing references it. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Remote/SDKSettingsDetector.swift | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 Sources/Userpilot/Remote/SDKSettingsDetector.swift diff --git a/Sources/Userpilot/Remote/SDKSettingsDetector.swift b/Sources/Userpilot/Remote/SDKSettingsDetector.swift deleted file mode 100644 index a2a3a8c9..00000000 --- a/Sources/Userpilot/Remote/SDKSettingsDetector.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// SDKSettingsDetector.swift -// Userpilot SDK -// -// Created by Motasem Hamed on 17/09/2024. -// Copyright © 2024 Userpilot. All rights reserved. -// - -// SDK settings fetching now lives in UserpilotRemoteSource. From 04d609d0fac42483989ddf3399e2cc356ce53654 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:43:56 +0300 Subject: [PATCH 23/95] =?UTF-8?q?=E2=9C=85=20test(analytics):=20Cover=20th?= =?UTF-8?q?e=20identify=20refresh=20state=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdentifyRefreshStateMachine shipped with no tests and a DEBUG-only mockIdentifyRefreshState() hook that nothing used. Adds 20 tests over the per-screen allowance, the anonymous and pending-replay exceptions, the once-per-refresh push token claim, the rule that a screen change must not discard an unsettled token obligation, and the documented thread safety. Mutation-checked: removing the anonymous guard, the onScreenChanged guard or the consumePushTokenSync state check fails 15 of the 20. Co-Authored-By: Claude Opus 5 (1M context) --- .../IdentifyRefreshStateMachineTests.swift | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 Tests/UserpilotTests/Analytics/IdentifyRefreshStateMachineTests.swift diff --git a/Tests/UserpilotTests/Analytics/IdentifyRefreshStateMachineTests.swift b/Tests/UserpilotTests/Analytics/IdentifyRefreshStateMachineTests.swift new file mode 100644 index 00000000..c9134d84 --- /dev/null +++ b/Tests/UserpilotTests/Analytics/IdentifyRefreshStateMachineTests.swift @@ -0,0 +1,265 @@ +// +// IdentifyRefreshStateMachineTests.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +final class IdentifyRefreshStateMachineTests: XCTestCase { + + private var sut: IdentifyRefreshStateMachine! + + override func setUp() { + super.setUp() + sut = IdentifyRefreshStateMachine() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Helpers + + /// An identify that adds nothing to the cached user — the case the policy exists for. + private func unchangedIdentify() -> IdentifyRefreshRequest { + IdentifyRefreshRequest(carriesNoNewData: true, isAnonymousUser: false, isPendingReplay: false) + } + + private func changedIdentify() -> IdentifyRefreshRequest { + IdentifyRefreshRequest(carriesNoNewData: false, isAnonymousUser: false, isPendingReplay: false) + } + + private func anonymousIdentify() -> IdentifyRefreshRequest { + IdentifyRefreshRequest(carriesNoNewData: true, isAnonymousUser: true, isPendingReplay: false) + } + + private func pendingReplay() -> IdentifyRefreshRequest { + IdentifyRefreshRequest(carriesNoNewData: true, isAnonymousUser: false, isPendingReplay: true) + } + + /// Drives the machine to `.refreshSettled`: one forwarded refresh whose token sync completed. + private func settleARefresh() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + XCTAssertTrue(sut.consumePushTokenSync()) + XCTAssertEqual(sut.state, .refreshSettled) + } + + // MARK: - Initial state + + func testNewMachineAllowsARefreshAndOwesNoTokenSync() { + XCTAssertEqual(sut.state, .refreshAllowed) + XCTAssertFalse(sut.consumePushTokenSync()) + } + + // MARK: - Events carrying new data + + func testIdentifyWithNewDataTakesTheNormalPathAndDoesNotSpendTheAllowance() { + XCTAssertEqual(sut.transition(changedIdentify()), .carriesNewData) + XCTAssertEqual(sut.state, .refreshAllowed) + + // The allowance must survive: an unchanged identify on this same screen is still owed a + // refresh. If `carriesNewData` consumed the allowance this would come back `.suppress`. + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + } + + // MARK: - Anonymous users + + func testAnonymousIdentifyIsSuppressedWithoutSpendingTheScreenAllowance() { + XCTAssertEqual(sut.transition(anonymousIdentify()), .suppress) + XCTAssertEqual(sut.state, .refreshAllowed) + + // An `anonymous()` call must not burn a real user's refresh for this screen. + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + } + + func testAnonymousIdentifyIsSuppressedEvenWhileATokenSyncIsOutstanding() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + + XCTAssertEqual(sut.transition(anonymousIdentify()), .suppress) + // The outstanding obligation is untouched by the anonymous call. + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + XCTAssertTrue(sut.consumePushTokenSync()) + } + + // MARK: - Once per screen + + func testFirstUnchangedIdentifyOnAScreenIsForwardedAndOwesATokenSync() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + } + + func testSecondUnchangedIdentifyOnTheSameScreenIsSuppressed() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + + XCTAssertEqual(sut.transition(unchangedIdentify()), .suppress) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + } + + func testUnchangedIdentifyIsStillSuppressedOnTheSameScreenAfterTheTokenSyncSettles() { + settleARefresh() + + XCTAssertEqual(sut.transition(unchangedIdentify()), .suppress) + XCTAssertEqual(sut.state, .refreshSettled) + } + + // MARK: - Pending replay + + func testPendingReplayIsForwardedEvenWhenTheScreenAllowanceIsAlreadySpent() { + settleARefresh() + + // The SDK reconnecting must not be throttled by the host app's per-screen allowance. + XCTAssertEqual(sut.transition(pendingReplay()), .refresh) + } + + func testPendingReplayReArmsTheTokenObligationBecauseTheFirstForwardNeverLanded() { + settleARefresh() + + XCTAssertEqual(sut.transition(pendingReplay()), .refresh) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + XCTAssertTrue(sut.consumePushTokenSync()) + } + + func testAnonymousPendingReplayIsStillSuppressed() { + // Anonymous is checked before the replay bypass — a generated id has nothing to refresh. + let anonymousReplay = IdentifyRefreshRequest( + carriesNoNewData: true, + isAnonymousUser: true, + isPendingReplay: true + ) + + XCTAssertEqual(sut.transition(anonymousReplay), .suppress) + XCTAssertEqual(sut.state, .refreshAllowed) + } + + // MARK: - Push token obligation + + func testTokenSyncIsClaimableExactlyOncePerForwardedRefresh() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + + XCTAssertTrue(sut.consumePushTokenSync()) + XCTAssertFalse(sut.consumePushTokenSync()) + XCTAssertEqual(sut.state, .refreshSettled) + } + + func testTokenSyncIsNotClaimableWhenNoRefreshWasForwarded() { + // `.refreshAllowed` — nothing forwarded yet. + XCTAssertFalse(sut.consumePushTokenSync()) + + // `.carriesNewData` takes the normal path and owes no re-assert of its own. + XCTAssertEqual(sut.transition(changedIdentify()), .carriesNewData) + XCTAssertFalse(sut.consumePushTokenSync()) + } + + // MARK: - Screen changes + + func testScreenChangeReopensTheAllowanceOnceTheTokenSyncHasSettled() { + settleARefresh() + + sut.onScreenChanged() + XCTAssertEqual(sut.state, .refreshAllowed) + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + } + + func testScreenChangeDoesNotDiscardAnUnsettledTokenObligation() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + + // Deliberate: the obligation belongs to an identify that has not reached the backend yet, + // not to the screen it was requested on. Re-sending before the first one lands is noise. + sut.onScreenChanged() + + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + XCTAssertEqual(sut.transition(unchangedIdentify()), .suppress) + XCTAssertTrue(sut.consumePushTokenSync()) + } + + func testAllowanceReopensOnTheScreenChangeThatFollowsAResolvedObligation() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + + // Screen change while the obligation is outstanding: held. + sut.onScreenChanged() + XCTAssertEqual(sut.transition(unchangedIdentify()), .suppress) + + // Obligation settles, then the next screen change reopens the allowance. + XCTAssertTrue(sut.consumePushTokenSync()) + sut.onScreenChanged() + + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + } + + func testRepeatedScreenChangesDoNotAccumulateExtraRefreshes() { + sut.onScreenChanged() + sut.onScreenChanged() + sut.onScreenChanged() + + // A screen change opens one allowance, not one per call. + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + XCTAssertEqual(sut.transition(unchangedIdentify()), .suppress) + } + + // MARK: - User changes + + func testUserChangeResetsTheAllowanceFromASettledRefresh() { + settleARefresh() + + sut.onUserChanged() + + XCTAssertEqual(sut.state, .refreshAllowed) + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + } + + func testUserChangeClearsAnUnsettledTokenObligation() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + + // Logout / user switch: the previous user's obligation must not follow the next user. + sut.onUserChanged() + + XCTAssertEqual(sut.state, .refreshAllowed) + XCTAssertFalse(sut.consumePushTokenSync()) + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + } + + // MARK: - Thread safety + + func testConcurrentTransitionsForwardExactlyOneRefresh() { + let iterations = 200 + let lock = NSLock() + var refreshCount = 0 + + DispatchQueue.concurrentPerform(iterations: iterations) { _ in + let decision = self.sut.transition(self.unchangedIdentify()) + lock.lock() + if decision == .refresh { refreshCount += 1 } + lock.unlock() + } + + // The whole point of the allowance: one forwarded refresh, no matter how many callers race. + XCTAssertEqual(refreshCount, 1) + XCTAssertEqual(sut.state, .awaitingPushTokenSync) + } + + func testConcurrentTokenSyncClaimsSucceedExactlyOnce() { + XCTAssertEqual(sut.transition(unchangedIdentify()), .refresh) + + let iterations = 200 + let lock = NSLock() + var claims = 0 + + DispatchQueue.concurrentPerform(iterations: iterations) { _ in + let claimed = self.sut.consumePushTokenSync() + lock.lock() + if claimed { claims += 1 } + lock.unlock() + } + + // A double claim would publish the push token twice for one identify refresh. + XCTAssertEqual(claims, 1) + XCTAssertEqual(sut.state, .refreshSettled) + } +} From 64b101121249bc002be2cf541818d1268838c49e Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:44:06 +0300 Subject: [PATCH 24/95] =?UTF-8?q?=E2=9C=85=20test(analytics):=20Repair=20t?= =?UTF-8?q?he=20publish=20helper=20and=20extend=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MockSocketManager never resolved a push, so the ACK-gated queue released its single-flight gate only when the 12s watchdog fired. Four autocapture throttle tests timed out at 2s, and the two single-publish tests passed for the wrong reason — the gate capped them at one regardless of whether the throttle worked. The helper now simulates the push receipt asynchronously, the way the real socket callback arrives. Also carries the unchanged-identify and seen-experience cases for the dedup fix, and the tail of the Constants.AutoCapture rename. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisherTests.swift | 117 +++++++++++++++--- 1 file changed, 97 insertions(+), 20 deletions(-) diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index b064db3d..f5b52a5e 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -421,6 +421,72 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertFalse(didPublishEvent) } + func testIsExperienceSeen_shouldUseSeenSetForContentType() throws { + // Arrange — the screen session (which owns the seen sets) is only created once the + // screen event actually goes out, which requires an open socket. + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Test Screen"))) + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + let survey = MockContentFactory.makeSurveyContent(id: 456) + + // Act + analyticsPublisher.experiencePublished(.flow, flow.id) + analyticsPublisher.experiencePublished(.survey, survey.id) + + // Assert — each type reads its own seen set, and an unseen id stays unseen + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.flow(content: flow))) + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.survey(content: survey))) + XCTAssertFalse( + analyticsPublisher.isExperienceSeen( + .survey(content: MockContentFactory.makeSurveyContent(id: 999)) + ) + ) + } + + func testIsExperienceSeen_shouldNotCrossTypesForTheSameNumericId() throws { + // Arrange — the screen session (which owns the seen sets) is only created once the + // screen event actually goes out, which requires an open socket. + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Test Screen"))) + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + + // Act — only the Flow is marked seen + analyticsPublisher.experiencePublished(.flow, flow.id) + + // Assert — a Survey sharing that id must not inherit the Flow's seen state + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.flow(content: flow))) + XCTAssertFalse( + analyticsPublisher.isExperienceSeen( + .survey(content: MockContentFactory.makeSurveyContent(id: flow.id)) + ) + ) + } + + func testIsExperienceSeen_shouldReturnFalseForNPS() throws { + // Arrange + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Test Screen"))) + let nps = try XCTUnwrap( + MockContentFactory.makeNPSContentPayload() + .toJSONString()? + .toNPSContent()? + .npsContent + ) + + // Assert — NPS dedup is owned by ExperiencesPublisher, so this always reports unseen + XCTAssertFalse(analyticsPublisher.isExperienceSeen(.nps(content: nps))) + } + func testPublishFakeReloadScreenEvent_shouldPublishWhenScreenSessionStateMachineExists() { // Arrange userpilot.socketManager.isSocketOpened = true @@ -575,7 +641,7 @@ class AnalyticsPublisherTests: XCTestCase { private func makeAutoCaptureEvent( properties: Payload = nil, - screen: Payload = [AutoCaptureConstants.screenClass: "HomeViewController"], + screen: Payload = [Constants.AutoCapture.screenClass: "HomeViewController"], interactionEventName: String = "tap" ) -> Event { return Event( @@ -587,6 +653,14 @@ class AnalyticsPublisherTests: XCTestCase { } /// Counts socket publishes triggered by publishing the given events back-to-back. + /// + /// The pipeline is ACK-gated: `processEvent` claims a single-flight gate and only releases it + /// when the socket resolves the in-flight head through `onSocketEventSent`. `MockSocketManager` + /// has no backend, so the ACK is simulated here — asynchronously, the way the real push receipt + /// arrives. Without it only the first event is ever published and every later one waits for the + /// `pushTimeout + 2` (12s) watchdog, so any multi-event expectation fails on timeout, and any + /// single-event expectation passes for the wrong reason (the gate caps it at one regardless of + /// whether the throttle works). private func publishAndCountSocketPublishes( _ events: [Event], expectedCount: Int, @@ -598,9 +672,12 @@ class AnalyticsPublisherTests: XCTestCase { let expectation = XCTestExpectation(description: "events flushed to socket") expectation.expectedFulfillmentCount = expectedCount expectation.assertForOverFulfill = false - userpilot.socketManager.onPublish = { _, _ in + userpilot.socketManager.onPublish = { [weak self] eventName, payload in publishCount += 1 expectation.fulfill() + DispatchQueue.main.async { + self?.analyticsPublisher.onSocketEventSent(eventName, payload, Message(), true) + } } events.forEach { analyticsPublisher.publish($0) } @@ -626,17 +703,17 @@ class AnalyticsPublisherTests: XCTestCase { let hierarchy = "UIAlertController:attr__index=\"0\";HomeViewController" let deleteDialog = makeAutoCaptureEvent( properties: [ - AutoCaptureConstants.hierarchy: hierarchy, - AutoCaptureConstants.rawInteractionType: "view_presented", - AutoCaptureConstants.dialogTitle: "Delete item?" + Constants.AutoCapture.hierarchy: hierarchy, + Constants.AutoCapture.rawInteractionType: "view_presented", + Constants.AutoCapture.dialogTitle: "Delete item?" ], interactionEventName: "view_presented" ) let logoutDialog = makeAutoCaptureEvent( properties: [ - AutoCaptureConstants.hierarchy: hierarchy, - AutoCaptureConstants.rawInteractionType: "view_presented", - AutoCaptureConstants.dialogTitle: "Log out?" + Constants.AutoCapture.hierarchy: hierarchy, + Constants.AutoCapture.rawInteractionType: "view_presented", + Constants.AutoCapture.dialogTitle: "Log out?" ], interactionEventName: "view_presented" ) @@ -649,13 +726,13 @@ class AnalyticsPublisherTests: XCTestCase { func testTrackEvent_autoCaptureUnresolvableScreenName_shouldStillUseOtherDiscriminators() { // Screen dict is non-empty (passes the flush guard) but has no // class/title/name, so the screen segment cannot be resolved - let screen: Payload = [AutoCaptureConstants.screenType: "modal"] + let screen: Payload = [Constants.AutoCapture.screenType: "modal"] let saveTap = makeAutoCaptureEvent( - properties: [AutoCaptureConstants.targetText: "Save"], + properties: [Constants.AutoCapture.targetText: "Save"], screen: screen ) let cancelTap = makeAutoCaptureEvent( - properties: [AutoCaptureConstants.targetText: "Cancel"], + properties: [Constants.AutoCapture.targetText: "Cancel"], screen: screen ) @@ -667,9 +744,9 @@ class AnalyticsPublisherTests: XCTestCase { func testTrackEvent_autoCaptureIdenticalEvents_secondShouldThrottle() { let tap = makeAutoCaptureEvent( properties: [ - AutoCaptureConstants.hierarchy: "UIButton:attr__index=\"0\";HomeViewController", - AutoCaptureConstants.rawInteractionType: "tap", - AutoCaptureConstants.targetText: "Save" + Constants.AutoCapture.hierarchy: "UIButton:attr__index=\"0\";HomeViewController", + Constants.AutoCapture.rawInteractionType: "tap", + Constants.AutoCapture.targetText: "Save" ] ) @@ -681,17 +758,17 @@ class AnalyticsPublisherTests: XCTestCase { func testTrackEvent_autoCaptureTabEvents_differentTabsShouldBothPublish() { let homeTab = makeAutoCaptureEvent( properties: [ - AutoCaptureConstants.tabName: "Home", - AutoCaptureConstants.tabIndex: 0, - AutoCaptureConstants.rawInteractionType: "tab_selected" + Constants.AutoCapture.tabName: "Home", + Constants.AutoCapture.tabIndex: 0, + Constants.AutoCapture.rawInteractionType: "tab_selected" ], interactionEventName: "tab_selected" ) let profileTab = makeAutoCaptureEvent( properties: [ - AutoCaptureConstants.tabName: "Profile", - AutoCaptureConstants.tabIndex: 1, - AutoCaptureConstants.rawInteractionType: "tab_selected" + Constants.AutoCapture.tabName: "Profile", + Constants.AutoCapture.tabIndex: 1, + Constants.AutoCapture.rawInteractionType: "tab_selected" ], interactionEventName: "tab_selected" ) From 371746b72f0b661acd0b12b0698ff546bde6bed1 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 08:44:16 +0300 Subject: [PATCH 25/95] =?UTF-8?q?=E2=9C=85=20test(utilities):=20Remove=20t?= =?UTF-8?q?he=20timing=20race=20from=20the=20flush=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testFlushPendingClearsPendingWorkSoValuesDeliverOnce raced its own flushPending() against a 0.05s debounce delay, so under load the timer won and the test silently exercised the timer path instead of the flush path it names — and left a timer in flight past the end of the test. Widens the delay so the flush deterministically wins, drops a main-queue hop that never synchronized with the internal queue, and shuts down every debounce at test end so none can fire during the next test. Co-Authored-By: Claude Opus 5 (1M context) --- .../Threading/EventDebounceTests.swift | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/Tests/UserpilotTests/Utilities/Threading/EventDebounceTests.swift b/Tests/UserpilotTests/Utilities/Threading/EventDebounceTests.swift index d512518d..26805424 100644 --- a/Tests/UserpilotTests/Utilities/Threading/EventDebounceTests.swift +++ b/Tests/UserpilotTests/Utilities/Threading/EventDebounceTests.swift @@ -24,6 +24,7 @@ final class EventDebounceTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual(delivered, ["second"]) + debounce.shutdown() } func testIndependentKeysBothDeliver() { @@ -43,6 +44,7 @@ final class EventDebounceTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual(delivered, ["A", "B"]) + debounce.shutdown() } func testCancelAllPreventsDelivery() { @@ -98,6 +100,7 @@ final class EventDebounceTests: XCTestCase { // Called from the main thread with a main delivery queue, so delivery is inline: the caller can // publish its own event next and stay ordered after the flushed value. XCTAssertEqual(delivered, ["latest"]) + debounce.shutdown() } func testFlushPendingWithNothingPendingDeliversNothing() { @@ -117,26 +120,32 @@ final class EventDebounceTests: XCTestCase { func testFlushPendingClearsPendingWorkSoValuesDeliverOnce() { var delivered: [String] = [] + // The delay must be comfortably longer than the time it takes this test to reach + // `flushPending()`. With a 0.05s delay the debounce timer could win that race under load, + // so the test would silently exercise the timer path instead of the flush path it names — + // and the timer would still be in flight when the test ended. + let delay: TimeInterval = 0.5 let debounce = EventDebounce( - delay: 0.05, + delay: delay, deliveryQueue: .main ) { value in delivered.append(value) } debounce.schedule(key: "field", value: "A") - let scheduled = expectation(description: "scheduled") - DispatchQueue.main.async { scheduled.fulfill() } - wait(for: [scheduled], timeout: 1) - + // `flushPending()` takes `queue.sync`, so it is already ordered behind `schedule()`'s hop + // onto the internal queue — no extra synchronization is needed for the value to be seen. debounce.flushPending() + XCTAssertEqual(delivered, ["A"], "flushPending should deliver the buffered value inline") + // Wait past the original delay: the cancelled timer must not deliver a second time. let settled = expectation(description: "settled") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { settled.fulfill() } - wait(for: [settled], timeout: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + delay + 0.2) { settled.fulfill() } + wait(for: [settled], timeout: delay + 2) - XCTAssertEqual(delivered, ["A"]) + XCTAssertEqual(delivered, ["A"], "cancelled timer must not deliver again") + debounce.shutdown() } func testDeliveryUsesConfiguredQueue() { @@ -155,5 +164,6 @@ final class EventDebounceTests: XCTestCase { debounce.schedule(key: "a", value: "A") wait(for: [expectation], timeout: 1) + debounce.shutdown() } } From 0a6d2fffa15edeb373a947d2e88e7a87a7d5d477 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 10:17:31 +0300 Subject: [PATCH 26/95] =?UTF-8?q?=E2=9C=A8=20feat(api):=20Enable=20interac?= =?UTF-8?q?tion=20value=20capture=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag already documented itself as defaulting to true while the code said false. Flips the default to match, so control values (switch state, slider, stepper, segment, picker, date) are captured once interaction autocapture is on. Values still follow the text-capture policy: they are omitted when enableInteractionTextCapture is off and redacted for a userpilotRedactText view. BREAKING CHANGE: none to the API surface — additive and unchanged selectors. Behavior changes on upgrade: apps with interaction autocapture enabled that never set this flag begin sending control values. Wrappers (Flutter / React Native / Capacitor) need no code change; call enableInteractionValueCapture(false) to keep the old behavior. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Userpilot+Config.swift | 2 +- Tests/UserpilotTests/Config/UserpilotConfigTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Userpilot/Userpilot+Config.swift b/Sources/Userpilot/Userpilot+Config.swift index 87dbc600..b4eb77c3 100644 --- a/Sources/Userpilot/Userpilot+Config.swift +++ b/Sources/Userpilot/Userpilot+Config.swift @@ -92,7 +92,7 @@ extension Userpilot { /// - UIStepper: captures the selected step value /// - UIPickerView: captures the selected value /// - UIDatePicker: captures the selected date/time value - var enableInteractionValueCapture: Bool = false + var enableInteractionValueCapture: Bool = true /// When true (default), a tap event is not sent for UITextField/UITextView when the action is /// a text-editing action (e.g. textChanged:, editingChanged:). Only the text_field_changed / diff --git a/Tests/UserpilotTests/Config/UserpilotConfigTests.swift b/Tests/UserpilotTests/Config/UserpilotConfigTests.swift index 664618f8..5ef5746c 100644 --- a/Tests/UserpilotTests/Config/UserpilotConfigTests.swift +++ b/Tests/UserpilotTests/Config/UserpilotConfigTests.swift @@ -24,7 +24,7 @@ final class UserpilotConfigTests: XCTestCase { XCTAssertFalse(config.enableInteractionAutoCapture) XCTAssertTrue(config.enableInteractionTextCapture) XCTAssertTrue(config.enableInteractionAccessibilityLabelCapture) - XCTAssertFalse(config.enableInteractionValueCapture) + XCTAssertTrue(config.enableInteractionValueCapture) XCTAssertTrue(config.ignoreTapForTextInputEditingActions) XCTAssertTrue(config.preferUIKitOverSwiftUIForNavigationBar) XCTAssertTrue(config.isDefault) From 45ba22836142c8185cae04a27d368cebc18a87c6 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 27 Aug 2026 15:48:00 +0300 Subject: [PATCH 27/95] =?UTF-8?q?=F0=9F=94=A7=20chore(gitignore):=20Stop?= =?UTF-8?q?=20tracking=20locally=20generated=20agent=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/superpowers/specs held a single design note generated locally by the superpowers plugin rather than project documentation. The file was already deleted on disk; this records the deletion and ignores the directory so future locally-generated specs don't get committed. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + ...12-unchanged-identify-per-screen-design.md | 944 ------------------ 2 files changed, 3 insertions(+), 944 deletions(-) delete mode 100644 docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md diff --git a/.gitignore b/.gitignore index bca99af0..4ff360aa 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ fastlane/test_output .cursor/ AGENTS.md CLAUDE.md + +# Locally generated agent specs/plans — not project documentation +docs/superpowers/ diff --git a/docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md b/docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md deleted file mode 100644 index 7e03acc8..00000000 --- a/docs/superpowers/specs/2026-08-12-unchanged-identify-per-screen-design.md +++ /dev/null @@ -1,944 +0,0 @@ -# Identify refresh — forward an unchanged `identify` once per screen, re-assert the push token - -**Date:** 2026-08-12 -**Platforms:** iOS (`ios-sdk`) **and** Android (`userpilot-android-sdk`) — one design, mirrored -**Public API impact:** none on either platform (no new/changed `@objc` or public Kotlin surface, no config option) - -Vocabulary used throughout: an **identify refresh** is an `identify` event that carries no new user -data but is forwarded anyway so the backend re-affirms the user. It is not a "duplicate" — the SDK -sends it on purpose. - ---- - -## 1. Current behaviour (identical on both platforms) - -**iOS** — [`AnalyticsPublisher.didHandleIdentifyEvent`](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L341-L354): - -```swift -guard !(storage.user.isNotEmpty && User.fromJson(storage.user).isSameIdentifyEvent(event: event)) else { - return true // ignore -} -``` - -**Android** — [`AnalyticsPublisher.handleIdentifyEvent`](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L258-L269): - -```kotlin -if (storage.user.isNotEmpty() && storage.user.toUser().isSameIdentifyEvent(event)) { - return true // ignore -} -``` - -`User.isSameIdentifyEvent` uses **partial-update** semantics on both platforms — the event's -properties/company must be a *subset* of the cached user with equal values. So "identical" really -means *"adds nothing to the cached user"*. - -Three consequences of the unconditional drop: - -1. **The backend never sees the identify.** `storage.user` survives process death (UserDefaults / - SharedPreferences), so on a warm launch an app that identifies the same user produces no - `identify` frame for the entire session. -2. **The drop happens before the socket-state guards**, so `handleClosedSocket` → connect never - runs for it either. -3. **The device push token is never re-asserted.** Both platforms value-guard the token send — - iOS [`setPushToken`](../../../Sources/Userpilot/PushNotification/PushNotificationMonitor.swift#L99-L117) - (`storage.pushToken != newToken`), Android - [`sendFCMToken` / `checkFCMToken`](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/pushNotifications/PushNotificationHandler.kt) - (`it != storage.pushNotificationToken`, checked twice). A returning user whose token has not - changed can never re-pair token ↔ user on the backend. - -For an identify that **does** carry new data with the same `userId`, both publishers call -`flushPriorityEvents(fakeReloadScreenEvent: true)`, which publishes the `identify` frame **and** a -screen frame with `fake_reload: true` + `is_session_start` + `seen_contents` + `seen_surveys`. That -screen frame is what drives backend experience re-evaluation. - ---- - -## 2. Goal - -An `identify` from the host app that carries no new user data must: - -- still reach the backend, **at most once per screen**; -- **also re-publish the push token event** (`user_token`); -- **not** emit the fake-reload screen frame (nothing changed ⇒ nothing to re-evaluate); -- **not apply to anonymous users** — `anonymous()` keeps today's drop-it behaviour. - -## 3. Decisions - -| # | Decision | Chosen | -|---|---|---| -| 1 | When is `user_token` re-published? | Only when the refresh is actually forwarded. Property-change identifies keep today's value-guarded token behaviour. | -| 2 | What re-opens the once-per-screen allowance? | A genuine **screen change**, and **logout / user switch**. *Not* app foreground. | -| 3 | Refresh while the socket is closed (cold start)? | Cache it, open the socket, let `onSocketOpened` flush it; the token sync fires after that flush. | -| 4 | Anonymous users | **Excluded.** A refresh request for the anonymous id is suppressed and does **not** consume the screen allowance. | -| 5 | Where does the state live? | A dedicated `IdentifyRefreshStateMachine` per SDK instance — one enum value, no boolean flags. | - ---- - -## 4. `IdentifyRefreshStateMachine` — the shared design - -### 4.1 Why a state machine and not two booleans - -The naive implementation needs two independent facts: *"has this screen already refreshed?"* and -*"does a forwarded refresh still owe a token sync?"*. Two booleans make four combinations, one of -which is nonsense (`settled && !forwarded`), and they hide an ordering rule that is easy to get -wrong: **a screen change must not silently discard an unsettled token obligation.** Folding both -into a single three-state value makes the illegal combination unrepresentable and turns that -ordering rule into an explicit transition. - -Android already has the precedent — [`ScreenSessionStateMachine`](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/ScreenSessionStateMachine.kt) -is a `@Synchronized internal class`, Koin-`scoped`, constructor-injected into `AnalyticsPublisher`, -exposing `transition(...)` plus intent-named mutators and a sealed decision type. The new machine -follows that shape exactly, and iOS gets the same class so the two SDKs read the same. - -### 4.2 States - -| State | Meaning | -|---|---| -| `refreshAllowed` / `REFRESH_ALLOWED` | The current screen has not refreshed the user yet. Initial state. | -| `awaitingPushTokenSync` / `AWAITING_PUSH_TOKEN_SYNC` | A refresh was forwarded and still owes a push-token re-assert. | -| `refreshSettled` / `REFRESH_SETTLED` | A refresh was forwarded on this screen and its token sync is done. | - -### 4.3 Input and output value types - -The machine owns the **policy**; it does not own `Storage` or `SocketManager`. The three facts it -cannot compute itself arrive in one documented value type rather than as positional booleans: - -```swift -/// The facts about an incoming identify event that the refresh policy needs. -internal struct IdentifyRefreshRequest { - /// The event adds nothing to the cached user (`User.isSameIdentifyEvent`). - let carriesNoNewData: Bool - /// The event identifies the generated anonymous user, which is excluded from refreshes. - let isAnonymousUser: Bool - /// The SDK is replaying a still-pending identify after a socket close, not the host app - /// calling `identify` again. Replays bypass the per-screen allowance. - let isPendingReplay: Bool -} - -/// What the publisher should do with an identify event. -internal enum IdentifyRefreshDecision { - /// Carries new user data — take the normal path (identify + fake-reload screen event). - case carriesNewData - /// Carries nothing new, but forward it so the backend re-affirms the user. No screen event. - case refresh - /// Carries nothing new and must be dropped. - case suppress -} -``` - -Kotlin is the same shape (`internal data class IdentifyRefreshRequest`, -`internal enum class IdentifyRefreshDecision { CARRIES_NEW_DATA, REFRESH, SUPPRESS }`). - -### 4.4 Transition table - -| From | Call | Guard | To | Result | -|---|---|---|---|---| -| any | `transition` | `!carriesNoNewData` | *unchanged* | `carriesNewData` | -| any | `transition` | `isAnonymousUser` | *unchanged* | `suppress` | -| any | `transition` | `isPendingReplay` | `awaitingPushTokenSync` | `refresh` | -| `refreshAllowed` | `transition` | — | `awaitingPushTokenSync` | `refresh` | -| `awaitingPushTokenSync` | `transition` | — | *unchanged* | `suppress` | -| `refreshSettled` | `transition` | — | *unchanged* | `suppress` | -| `awaitingPushTokenSync` | `consumePushTokenSync` | — | `refreshSettled` | `true` | -| others | `consumePushTokenSync` | — | *unchanged* | `false` | -| `awaitingPushTokenSync` | `onScreenChanged` | — | *unchanged* | — | -| others | `onScreenChanged` | — | `refreshAllowed` | — | -| any | `onUserChanged` | — | `refreshAllowed` | — | - -Two transitions deserve their reasoning spelled out in code comments: - -- **`onScreenChanged` is a no-op while awaiting a token sync.** The obligation belongs to an - identify that has not reached the backend yet, not to the screen it was requested on. And - re-sending an identical identify before the first one lands would be pure noise — so continuing - to suppress until it settles is the correct behaviour, not a compromise. -- **`isPendingReplay` bypasses the allowance.** `onSocketClosed` re-enters the *public* `publish` - with the cached identify (iOS [:775](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L775), - Android `onSocketClosed` → `cachedIdentifyEvent?.let { publish(it) }`). Without the bypass that - replay would hit an already-consumed allowance, get suppressed, and the reconnect would silently - lose the user. A **pending** identify (`cachedIdentifyEvent != nil`) while the socket is **not - open** can only be an SDK replay, because a successful send clears `cachedIdentifyEvent` in - `onSocketEventSent`. - -### 4.5 Swift implementation — `Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift` (new) - -```swift -/// Owns the policy for `identify` events that carry no new user data. -/// -/// Such an event is still forwarded to the backend **once per screen** so the backend can -/// re-affirm the user, and each forwarded refresh owes one push-token re-assert (the token -/// senders are value-guarded and would otherwise never re-pair token ↔ user). -/// -/// All state lives in a single `State` value: two independent booleans would allow -/// combinations that cannot occur and would hide the rule that a screen change must not -/// discard an unsettled token obligation. -/// -/// Thread-safe: identify, screen and socket callbacks arrive from different queues. -/// (Mirrors Android's `IdentifyRefreshStateMachine`; keep both in sync.) -internal final class IdentifyRefreshStateMachine { - - /// Lifecycle of the current screen's refresh allowance. - internal enum State: Equatable { - /// The current screen has not refreshed the user yet. - case refreshAllowed - /// A refresh was forwarded and still owes a push-token re-assert. - case awaitingPushTokenSync - /// A refresh was forwarded on this screen and its token sync is done. - case refreshSettled - } - - private let lock = NSLock() - private var _state: State = .refreshAllowed - - /// Current state. Exposed for assertions; callers must not derive control flow from it. - internal var state: State { - lock.lock(); defer { lock.unlock() } - return _state - } - - /// Classifies an incoming identify event and advances the allowance when it is forwarded. - internal func transition(_ request: IdentifyRefreshRequest) -> IdentifyRefreshDecision { - lock.lock(); defer { lock.unlock() } - - guard request.carriesNoNewData else { return .carriesNewData } - - // `anonymous()` re-sends the same generated id with no properties, so there is nothing - // to refresh. Suppress without touching the allowance: an anonymous call must not spend - // the screen's refresh on behalf of a real user. - guard !request.isAnonymousUser else { return .suppress } - - // A replay of a still-pending identify is the SDK reconnecting, not the host app calling - // again. It must go through, and it re-arms the token obligation because the original - // forward never reached the backend. - if request.isPendingReplay { - _state = .awaitingPushTokenSync - return .refresh - } - - switch _state { - case .refreshAllowed: - _state = .awaitingPushTokenSync - return .refresh - case .awaitingPushTokenSync, .refreshSettled: - return .suppress - } - } - - /// Claims the pending push-token re-assert, if one is owed. - /// - Returns: `true` exactly once per forwarded refresh. - internal func consumePushTokenSync() -> Bool { - lock.lock(); defer { lock.unlock() } - guard _state == .awaitingPushTokenSync else { return false } - _state = .refreshSettled - return true - } - - /// A genuinely new screen re-opens the allowance. - internal func onScreenChanged() { - lock.lock(); defer { lock.unlock() } - // A pending token sync belongs to an identify that has not reached the backend yet, not - // to the screen it was requested on. Keep suppressing until it settles — re-sending an - // identical identify before the first one lands would only add noise. - guard _state != .awaitingPushTokenSync else { return } - _state = .refreshAllowed - } - - /// Logout or user switch: the next user starts with a fresh allowance and owes nothing. - internal func onUserChanged() { - lock.lock(); defer { lock.unlock() } - _state = .refreshAllowed - } -} -``` - -`ReadWriteLock` is **not** usable here — its `write(closure:)` is `async(flags: .barrier)` and -cannot return a decision synchronously. `NSLock` is the right primitive. - -### 4.6 Kotlin implementation — `userpilot/src/main/java/com/userpilot/analytics/IdentifyRefreshStateMachine.kt` (new) - -Same doc comment and identical semantics; `@Synchronized` instead of `NSLock`, matching -`ScreenSessionStateMachine`: - -```kotlin -internal class IdentifyRefreshStateMachine { - - internal enum class State { REFRESH_ALLOWED, AWAITING_PUSH_TOKEN_SYNC, REFRESH_SETTLED } - - var state: State = State.REFRESH_ALLOWED - @Synchronized get - private set - - @Synchronized - fun transition(request: IdentifyRefreshRequest): IdentifyRefreshDecision { - if (!request.carriesNoNewData) return IdentifyRefreshDecision.CARRIES_NEW_DATA - if (request.isAnonymousUser) return IdentifyRefreshDecision.SUPPRESS - - if (request.isPendingReplay) { - state = State.AWAITING_PUSH_TOKEN_SYNC - return IdentifyRefreshDecision.REFRESH - } - - return when (state) { - State.REFRESH_ALLOWED -> { - state = State.AWAITING_PUSH_TOKEN_SYNC - IdentifyRefreshDecision.REFRESH - } - State.AWAITING_PUSH_TOKEN_SYNC, - State.REFRESH_SETTLED -> IdentifyRefreshDecision.SUPPRESS - } - } - - @Synchronized - fun consumePushTokenSync(): Boolean { - if (state != State.AWAITING_PUSH_TOKEN_SYNC) return false - state = State.REFRESH_SETTLED - return true - } - - @Synchronized - fun onScreenChanged() { - if (state == State.AWAITING_PUSH_TOKEN_SYNC) return - state = State.REFRESH_ALLOWED - } - - @Synchronized - fun onUserChanged() { - state = State.REFRESH_ALLOWED - } -} -``` - -### 4.7 Wiring — each platform's existing precedent - -| | iOS | Android | -|---|---|---| -| Ownership | `private let identifyRefreshStateMachine = IdentifyRefreshStateMachine()` on `AnalyticsPublisher`, mirroring `eventThrottle` (small stateful policy objects are not DI-registered on iOS) | Koin `scoped { IdentifyRefreshStateMachine() }` + constructor param on `AnalyticsPublisher`, mirroring `ScreenSessionStateMachine` | -| Scope | per `Userpilot` instance | per Koin scope = per `Userpilot` instance | - -The class and its API are identical; only the wiring follows each repo's convention. Both are -directly unit-testable in isolation, which is the point. - ---- - -## 5. iOS changes - -### 5.1 `Sources/Userpilot/Analytics/IdentifyRefreshStateMachine.swift` — new - -Contains `IdentifyRefreshRequest`, `IdentifyRefreshDecision`, `IdentifyRefreshStateMachine` (§4.3, §4.5). - -### 5.2 `Sources/Userpilot/Analytics/AnalyticsPublisher.swift` - -**(a) Lazy push-monitor resolver**, next to `experiencesPublisher` / `sessionMonitorer` (~line 135): - -```swift -/// Push notification monitoring, used to re-assert the device token for a returning user. -/// -/// `AnalyticsPublishing` is registered *before* `PushNotificationMonitoring` in -/// `initializeContainer()`, and `PushNotificationMonitor.init` resolves this publisher, so this -/// must never be resolved from `init` — only on demand. -private weak var pushNotificationMonitor: PushNotificationMonitoring? { - return container?.resolve(PushNotificationMonitoring.self) -} -``` - -`DIContainer.resolve` `fatalError`s on an unregistered type -([DIContainer.swift:124](../../../Sources/Userpilot/DI/DIContainer.swift#L124)), and -[Userpilot.swift:228-230](../../../Sources/Userpilot/Userpilot.swift#L228-L230) registers -`AnalyticsPublishing` **one line before** `PushNotificationMonitoring` — resolving from `init` -would crash on SDK init. - -**(b) Own the state machine**, next to `eventThrottle` (~line 155): - -```swift -/// Policy for identify events that carry no new user data. -private let identifyRefreshStateMachine = IdentifyRefreshStateMachine() -``` - -**(c) Replace `didHandleIdentifyEvent(_:)`** ([:335-354](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L335-L354)) -with a classifier plus a decision-returning handler: - -```swift -/// Builds the refresh request from the collaborators the state machine deliberately does not own. -private func identifyRefreshRequest(for event: Event) -> IdentifyRefreshRequest { - IdentifyRefreshRequest( - carriesNoNewData: storage.user.isNotEmpty - && User.fromJson(storage.user).isSameIdentifyEvent(event: event), - isAnonymousUser: storage.anonymousUserId.isNotEmpty - && event.userId == storage.anonymousUserId, - // A pending identify has not reached the backend yet, so this is `onSocketClosed` - // replaying it, not a fresh host-app call. - isPendingReplay: cachedIdentifyEvent != nil && !socketManager.isSocketOpened - ) -} - -/** - * Runs an identify event through the refresh policy and, unless it is suppressed, caches it as - * the pending identify event. - * - * - Parameter event: The identify event to handle - * - Returns: The policy decision for this event - */ -private func handleIdentifyEvent(_ event: Event) -> IdentifyRefreshDecision { - let decision = identifyRefreshStateMachine.transition(identifyRefreshRequest(for: event)) - guard decision != .suppress else { return decision } - - // Update cached user - storage.temporaryUser = event.toUser().toJson() - cachedIdentifyEvent = event - - return decision -} -``` - -**(d) `publish(_:)`** ([:301-333](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L301-L333)) — -every socket guard keeps its order; only the de-dup call site changes: - -```swift -// nil for non-identify events -let refreshDecision = event.isIdentifyEvent ? handleIdentifyEvent(event) : nil - -// Nothing new for the backend, and this screen already refreshed the user -guard refreshDecision != .suppress else { return } - -// … existing isShutdownState / isJoiningSocket / isSocketOpened guards unchanged … - -processEvent(event, isIdentifyRefresh: refreshDecision == .refresh) -``` - -**(e) `processEvent`** gains a defaulted parameter and forwards it: - -```swift -private func processEvent(_ event: Event, isIdentifyRefresh: Bool = false) { - switch event.type { - case .identify: - identify(event, isIdentifyRefresh: isIdentifyRefresh) - … -``` - -**(f) `identify(_:)`** ([:429-441](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L429-L441)): - -```swift -private func identify(_ event: Event, isIdentifyRefresh: Bool = false) { - tryCatch { - guard let userId = event.userId else { return } - - // If new user ID detected, close socket and clean up - if storage.userId.isNotEmpty && userId != storage.userId { - userpilot?.clean() - logout(socketState: .switchingUser) - return - } - - if isIdentifyRefresh { - // Nothing about the user changed, so the backend has nothing to re-evaluate: - // send the identify alone and skip the fake-reload screen event. - flushPriorityEvents(canRequestScreenEvent: false) - syncPushTokenIfNeeded() - return - } - - flushPriorityEvents(fakeReloadScreenEvent: true) - } -} -``` - -Suppressing the screen frame reuses the **existing** `canRequestScreenEvent:` parameter, which -also skips `screenNameTracker.updateScreen`, `experiencesPublisher?.updateSceen(_:)` and the screen -`broadcastEvent` — they all live inside the `if let screenViewEntity, canRequestScreenEvent` block -([:638-665](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L638-L665)). No new plumbing. - -The internal recursion at [:626](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L626) -calls `identify(cachedIdentifyEvent)` and picks up the `false` default — correct, that branch is a -user switch. - -**(g) New helper:** - -```swift -/** - * Re-publishes the device push token owed by a forwarded identify refresh. - * - * `PushNotificationMonitor.setPushToken` only publishes when the token value changes, so a - * returning user whose token is unchanged would otherwise never re-pair token ↔ user on the - * backend. Called after the identify is on the wire so the backend sees the user first. - */ -private func syncPushTokenIfNeeded() { - // Check the socket first: an obligation must not be consumed while it cannot be fulfilled. - guard canRequestEvent, identifyRefreshStateMachine.consumePushTokenSync() else { return } - pushNotificationMonitor?.resyncPushToken() -} -``` - -**(h) `onSocketOpened()`** ([:760-763](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L760-L763)) — append: - -```swift -// Cold-start path: the refresh was cached while the socket was down and has just been flushed. -syncPushTokenIfNeeded() -``` - -**(i) `setupScreenEvent(_:)`** — inside the `if isScreenTitleChanged` branch -([:569](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L569)): - -```swift -// A genuinely new screen re-opens the refresh allowance. -identifyRefreshStateMachine.onScreenChanged() -``` - -Deliberately **not** in the `else` branch: a repeat of the same screen title is the same screen. - -**(j) `logout(...)`** ([:244-245](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L244-L245)), -next to `startSession = true`: - -```swift -// A new user starts with a fresh allowance and owes no token sync. -identifyRefreshStateMachine.onUserChanged() -``` - -This also closes the theoretical hole where `storage.userId` and `storage.user.userId` diverge, -`identify` takes the user-switch branch, and a stale obligation would re-assert the old token -against the new user. - -**(k) `reset()`** ([:276-279](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L276-L279)) — -add `identifyRefreshStateMachine.onUserChanged()`. No production caller today (tests only), but -leaving new state out of a method named `reset` is a latent bug. - -**(l) DEBUG accessors** in the existing `#if DEBUG` extension ([:852-862](../../../Sources/Userpilot/Analytics/AnalyticsPublisher.swift#L852-L862)): - -```swift -func mockGetCachedIdentifyEvent() -> Event? { cachedIdentifyEvent } -func mockIdentifyRefreshState() -> IdentifyRefreshStateMachine.State { identifyRefreshStateMachine.state } -``` - -### 5.3 `Sources/Userpilot/PushNotification/PushNotificationMonitor.swift` - -Protocol addition to `PushNotificationMonitoring` (~line 28): - -```swift -/// Re-publishes the current device token even when its value has not changed. -/// -/// `setPushToken(_:)` is value-guarded and is therefore a no-op for a returning user whose -/// token is unchanged. This re-asserts the token ↔ user pairing on the backend. -func resyncPushToken() -``` - -Implementation — extract the hex conversion so both senders share it: - -```swift -/// Hex representation of a raw APNs device token. -private func hexString(from deviceToken: Data) -> String { - deviceToken.map { String(format: "%02x", $0) }.joined() -} - -func resyncPushToken() { - // Prefer the token the OS handed us this launch; fall back to the persisted one for a warm - // start where `didRegisterForRemoteNotificationsWithDeviceToken` has not fired yet. - let token = cachedToken.map(hexString(from:)) ?? storage.pushToken - guard - let token, - token.isNotEmpty, - analyticsPublisher.canRequestEvent - else { return } - - analyticsPublisher.publishInternalSDKEvent( - PushNotificationTokenEvent( - appToken: config.token, - userId: storage.userId, - token: token), - socketSubscription: self) -} -``` - -`setPushToken(_:)` keeps its `storage.pushToken != newToken` guard untouched and only swaps its -inline hex expression for `hexString(from:)`. `onSocketEventSent` already persists -`payload["token"]` for `user_token`, so a re-sync harmlessly rewrites the same value. - -### 5.4 `Tests/UserpilotTests/MockUserpilot.swift` - -```swift -// MockPushNotificationMonitor -var onResyncPushToken: (() -> Void)? -func resyncPushToken() { - onResyncPushToken?() -} -``` - ---- - -## 6. Android changes - -### 6.1 `userpilot/src/main/java/com/userpilot/analytics/IdentifyRefreshStateMachine.kt` — new - -Contains `IdentifyRefreshRequest`, `IdentifyRefreshDecision`, `IdentifyRefreshStateMachine` (§4.3, §4.6). - -### 6.2 `userpilot/src/main/java/com/userpilot/di/AnalyticsKoin.kt` - -```kotlin -scoped { IdentifyRefreshStateMachine() } -``` -placed next to `scoped { ScreenSessionStateMachine() }`, and threaded into the `AnalyticsPublisher` -definition as: - -```kotlin -identifyRefreshStateMachine = get(), -pushNotificationHandlerProvider = { get() }, -``` - -**Why a provider lambda for the handler, not `get()`:** `PushNotificationHandler.init` calls -`socketManager.setSocketSubscriptionListener(this)`, which `delegate.add(...)`s it to the listener -list. Passing `get()` directly would construct the handler *before* `AnalyticsPublisher.init` -registers itself, changing the global `onSocketEventSent` callback order in a shipped SDK for no -benefit. A `() -> PushNotificationHandler` defers construction to first use — the first identify -refresh — preserving today's ordering, and is trivially fakeable in tests. (There is no Koin cycle -either way: the handler resolves `analyticsPublisher` via lazy `by scope.inject`.) - -### 6.3 `userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt` - -**(a) Constructor** ([:56-68](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L56-L68)) — -two new params after `screenSessionStateMachine`: - -```kotlin -private val identifyRefreshStateMachine: IdentifyRefreshStateMachine, -/** Deferred so the handler is not constructed before this publisher registers its socket listener. */ -private val pushNotificationHandlerProvider: () -> PushNotificationHandler, -``` - -The class already carries `@Suppress("TooManyFunctions", "LongParameterList")`. - -**(b) `publish(event)`** ([:220-250](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L220-L250)): - -```kotlin -// null for non-identify events -val refreshDecision = if (event.isIdentifyEvent) handleIdentifyEvent(event) else null - -// Nothing new for the backend, and this screen already refreshed the user -if (refreshDecision == IdentifyRefreshDecision.SUPPRESS) return - -// … existing isShutdownState / isJoiningSocket / isSocketOpened guards unchanged … - -processEvent(event, isIdentifyRefresh = refreshDecision == IdentifyRefreshDecision.REFRESH) -``` - -**(c) Replace `handleIdentifyEvent`** ([:258-269](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L258-L269)): - -```kotlin -private fun identifyRefreshRequest(event: Event) = IdentifyRefreshRequest( - carriesNoNewData = storage.user.isNotEmpty() && storage.user.toUser().isSameIdentifyEvent(event), - isAnonymousUser = storage.anonymousUserId.isNotEmpty() && event.userId == storage.anonymousUserId, - // A pending identify has not reached the backend yet, so this is `onSocketClosed` - // replaying it, not a fresh host-app call. - isPendingReplay = cachedIdentifyEvent != null && !socketManager.isSocketOpened -) - -private fun handleIdentifyEvent(event: Event): IdentifyRefreshDecision { - val decision = identifyRefreshStateMachine.transition(identifyRefreshRequest(event)) - if (decision == IdentifyRefreshDecision.SUPPRESS) return decision - - // Update cached user - storage.temporaryUser = event.toUser().toJson() - cachedIdentifyEvent = event - - return decision -} -``` - -**(d) `processEvent` / `identify`** ([:309-315](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L309-L315), -[:340-352](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L340-L352)): - -```kotlin -private fun processEvent(event: Event, isIdentifyRefresh: Boolean = false) { - when (event.type) { - is EventType.Identify -> identify(event, isIdentifyRefresh) - … - } -} - -private fun identify(event: Event, isIdentifyRefresh: Boolean = false) { - tryCatch { - val userId = event.userId ?: return@tryCatch - - if (storage.userId.isNotEmpty() && userId != storage.userId) { - userpilot.clean() - logout(SocketState.SWITCHING_USER) - return@tryCatch - } - - if (isIdentifyRefresh) { - // Nothing about the user changed, so the backend has nothing to re-evaluate: - // send the identify alone and skip the fake-reload screen event. - flushPriorityEvents(canRequestScreenEvent = false) - syncPushTokenIfNeeded() - return@tryCatch - } - - flushPriorityEvents(fakeReloadScreenEvent = true) - } -} -``` - -Note the existing `identify` uses `return` inside `tryCatch`; keep whichever form the file's -`tryCatch` signature already accepts (it is `return` today because the lambda is inline). - -**(e) New helper:** - -```kotlin -/** - * Re-publishes the FCM token owed by a forwarded identify refresh. - * - * [PushNotificationHandler.sendFCMToken] is value-guarded, so a returning user whose token is - * unchanged would otherwise never re-pair token ↔ user on the backend. - */ -private fun syncPushTokenIfNeeded() { - // Check the socket first: an obligation must not be consumed while it cannot be fulfilled. - if (!canRequestEvent()) return - if (!identifyRefreshStateMachine.consumePushTokenSync()) return - pushNotificationHandlerProvider().resyncPushToken() -} -``` - -**(f) `onSocketOpened()`** ([:662-666](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L662-L666)) — -append `syncPushTokenIfNeeded()` after `flushPriorityEvents(...)` and before/after -`permissionsHandler.requestPermissions()` (order is irrelevant; put it right after the flush). - -**(g) `updateScreenViewState(event)`** ([:451](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L451)) — -this returns `isSameScreenAsPrevious`, the **inverse** of iOS's `setupScreenEvent`: - -```kotlin -if (!isSameScreenAsPrevious) { - // A genuinely new screen re-opens the refresh allowance. - identifyRefreshStateMachine.onScreenChanged() -} -return isSameScreenAsPrevious -``` - -**(h) `logout(...)`** ([:171-172](../../../../userpilot-android-sdk/userpilot/src/main/java/com/userpilot/analytics/AnalyticsPublisher.kt#L171-L172)) — -next to `startSession = true`: - -```kotlin -// A new user starts with a fresh allowance and owes no token sync. -identifyRefreshStateMachine.onUserChanged() -``` - -### 6.4 `userpilot/src/main/java/com/userpilot/pushNotifications/PushNotificationHandler.kt` - -```kotlin -/** - * Re-publishes the current FCM token even when its value has not changed. - * - * [sendFCMToken] is value-guarded and is therefore a no-op for a returning user whose token is - * unchanged. This re-asserts the token ↔ user pairing on the backend. - */ -fun resyncPushToken() { - tryCatch { - if (!analyticsPublisher.canRequestEvent()) return@tryCatch - val token = cachedFCMToken ?: storage.pushNotificationToken - if (token.isNullOrEmpty()) return@tryCatch - // `onSocketEventSent` persists `cachedFCMToken` when `user_token` is acked, so seed it - // here — otherwise a resync sourced from storage would null the stored token out. - cachedFCMToken = token - analyticsPublisher.publishInternalSDKEvent( - PushNotificationTokenEvent(config.token, storage.userId, token) - ) - } -} -``` - -That `cachedFCMToken = token` line is load-bearing: `onSocketEventSent` does -`storage.pushNotificationToken = cachedFCMToken`, so re-syncing a storage-sourced token while -`cachedFCMToken == null` would wipe the persisted token on ack. - -`checkFCMToken()` and `sendFCMToken()` are otherwise untouched. - -### 6.5 Android test doubles - -`AnalyticsPublisherTest` constructs the publisher directly; add -`identifyRefreshStateMachine = IdentifyRefreshStateMachine()` and -`pushNotificationHandlerProvider = { fakeHandler }` (or a relaxed mock) to every construction site. - ---- - -## 7. Cross-platform parity checklist - -| Concern | iOS | Android | -|---|---|---| -| State machine class | `IdentifyRefreshStateMachine.swift` | `IdentifyRefreshStateMachine.kt` | -| Thread safety | `NSLock` | `@Synchronized` | -| Wiring | owned `private let` (like `EventThrottle`) | Koin `scoped` + ctor (like `ScreenSessionStateMachine`) | -| Redundancy check | `User.isSameIdentifyEvent(event:)` | `User.isSameIdentifyEvent(event)` | -| Anonymous check | `storage.anonymousUserId` | `storage.anonymousUserId` | -| Screen-change hook | `setupScreenEvent` → `isScreenTitleChanged == true` | `updateScreenViewState` → `isSameScreenAsPrevious == false` | -| Screen suppression | `flushPriorityEvents(canRequestScreenEvent: false)` | `flushPriorityEvents(canRequestScreenEvent = false)` | -| Token re-assert | `PushNotificationMonitor.resyncPushToken()` | `PushNotificationHandler.resyncPushToken()` | -| Token source order | `cachedToken` → `storage.pushToken` | `cachedFCMToken` → `storage.pushNotificationToken` | -| Token event name | `user_token` | `user_token` | - -The **wire protocol is byte-identical on both platforms**: one extra `identify` frame with no -accompanying screen frame, followed by one `user_token` frame. No backend change is required. - ---- - -## 8. Regression matrix (applies to both platforms) - -| Scenario | Before | After | -|---|---|---| -| First `identify` ever (`storage.user` empty) | `identify` + screen(`fake_reload: true`) | **unchanged** | -| `identify` with new/changed properties, same `userId` | `identify` + screen(`fake_reload: true`) | **unchanged** | -| `identify` with a different `userId` | `clean()` + `logout(switching user)` | **unchanged** (`isSameIdentifyEvent` fails on `userId`) | -| **`anonymous()` repeated** | dropped | **unchanged — dropped**, and the screen allowance is not consumed | -| `anonymous()` then `identify(realUser)` | normal path | **unchanged** | -| Refresh, socket open, first on this screen | dropped | `identify` published; **no** screen frame; `user_token` published | -| Refresh, socket open, 2nd+ on same screen | dropped | dropped | -| Refresh after navigating to a different screen | dropped | forwarded again (once) | -| Refresh after the *same* screen is re-emitted | dropped | still suppressed | -| Refresh, socket closed (cold/warm start) | dropped; socket not opened by it | cached + connect; on open → `identify` + screen(`fake_reload: false`) + `user_token` | -| Refresh, socket joining | dropped | cached; flushed on open + `user_token` | -| Refresh while app backgrounded | cached (policy never runs) | **unchanged** | -| Refresh while socket shutting down | dropped | dropped; obligation stays owed and is fulfilled on the next socket open | -| Screen changes while a token sync is still owed | n/a | allowance stays closed until the sync settles, then the next screen re-opens it | -| `onSocketClosed` replay of a pending refresh | n/a (never cached) | bypasses the allowance — reconnect keeps the user | -| `logout()` then `identify` same user | `storage.user` cleared → carries new data | **unchanged** | -| Background → foreground on the same screen, `identify` again | dropped | dropped (decision #2) | -| Multi-instance | per-instance state | **unchanged** — one machine per SDK instance | - -### Behaviour changes worth telling the team about - -1. **The analytics listener now fires for refreshes.** iOS `UserpilotAnalyticsDelegate.didTrack` / - Android `UserpilotAnalyticsListener` see an `identify` they previously never saw, because the - refresh reaches `onSocketEventSent` → broadcast. -2. **Traffic ceiling.** Worst case for an app that identifies on every screen: one extra `identify` - + one extra `user_token` per *screen change*. If that proves too chatty the lever is the - existing `EventThrottle`; not implemented now, the per-screen allowance is the primary bound. -3. **`temporaryUser` is now written for refreshes** and cleared on successful send. If the process - dies in that window, `AnalyticsPublisher.init` restores it and publishes on the next socket - open — same user, harmless. - ---- - -## 9. Test plan - -### 9.1 State machine — new file, both platforms - -`Tests/UserpilotTests/Analytics/IdentifyRefreshStateMachineTests.swift` / -`userpilot/src/test/java/com/userpilot/analytics/IdentifyRefreshStateMachineTest.kt` - -One test per row of the §4.4 transition table, plus: - -- initial state is `refreshAllowed` -- `carriesNewData` never mutates state (assert state before/after) -- anonymous suppression never mutates state -- `onScreenChanged` is a no-op while awaiting a token sync, and effective once settled -- `consumePushTokenSync` returns `true` exactly once, `false` on the second call -- `onUserChanged` resets from every state -- a replay re-arms the obligation from `refreshSettled` - -### 9.2 iOS `AnalyticsPublisherTests` - -Replace `testPublish_withSameIdentifyEvent_shouldNotReprocess` -([:734-753](../../../Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift#L734-L753)) — it -currently passes for the wrong reason, since `isSocketOpened` defaults to `false` so nothing -publishes either way. Every new test must set `userpilot.socketManager.isSocketOpened = true` where -it means "socket open". - -| Test | Asserts | -|---|---| -| `testPublish_identifyRefresh_socketOpen_shouldPublishIdentifyWithoutScreenEvent` | one publish, name `EventType.identifyEvent`, no screen frame | -| `testPublish_identifyRefresh_twiceOnSameScreen_shouldPublishOnce` | second call publishes nothing | -| `testPublish_identifyRefresh_afterNewScreen_shouldPublishAgain` | screen A → identify → screen B → identify ⇒ two identify frames | -| `testPublish_identifyRefresh_afterSameScreenTitleRepeated_shouldStaySuppressed` | re-emitting the title does not re-open the allowance | -| `testPublish_identifyRefresh_shouldResyncPushToken` | `onResyncPushToken` fires once, **after** the identify publish | -| `testPublish_identifyRefresh_secondOnSameScreen_shouldNotResyncPushToken` | obligation is consumed once | -| `testPublish_anonymousIdentify_shouldStayDropped` | no publish, no token event, and a following real-user refresh still gets its allowance | -| `testPublish_identifyRefresh_whenSocketClosed_shouldCacheAndConnect` | `cachedIdentifyEvent != nil`, `connect()` called, nothing published | -| `testOnSocketOpened_afterCachedIdentifyRefresh_shouldResyncPushToken` | flush publishes identify, then one token sync | -| `testOnSocketClosed_replayOfPendingRefresh_shouldNotBeSuppressed` | allowance already consumed + pending event ⇒ replay accepted | -| `testPublish_identifyWithNewProperties_shouldStillPublishFakeReloadScreenEvent` | **regression**: screen frame with `fake_reload == true` | -| `testPublish_identifyRefresh_afterLogout_shouldPublishAgain` | `logout` resets the machine | -| `testPublish_identifyRefresh_whenShutdownState_shouldNotPublishOrResync` | no publish, no token event | - -`MockUserpilot.container` already registers `MockPushNotificationMonitor` for -`PushNotificationMonitoring`, so no harness wiring beyond §5.4. - -### 9.3 iOS `PushNotificationMonitorTests` - -| Test | Asserts | -|---|---| -| `testResyncPushToken_withCachedToken_shouldPublishTokenEvent` | `user_token` with the hex of `setCachedToken` | -| `testResyncPushToken_withStoredTokenOnly_shouldPublishTokenEvent` | falls back to `storage.pushToken` | -| `testResyncPushToken_withNoToken_shouldNotPublish` | both sources empty ⇒ nothing | -| `testResyncPushToken_whenSocketClosed_shouldNotPublish` | `canRequestEvent == false` ⇒ nothing | -| `testSetPushToken_withUnchangedToken_shouldStillNotPublish` | **regression**: the value guard survives the refactor | - -### 9.4 Android `AnalyticsPublisherTest` / `PushNotificationHandlerTest` - -Same matrix as §9.2 / §9.3, plus one Android-specific case: - -- `resyncPushToken seeds cachedFCMToken so ack does not wipe stored token` — resync with - `cachedFCMToken == null`, ack `user_token`, assert `storage.pushNotificationToken` is unchanged - rather than null. - -### 9.5 Verification commands - -**iOS** -```bash -swiftlint -xcodebuild build -scheme Userpilot -destination 'generic/platform=iOS Simulator' -xcodebuild test -scheme Userpilot -destination 'platform=iOS Simulator,name=iPhone 16' \ - -only-testing:UserpilotTests/IdentifyRefreshStateMachineTests \ - -only-testing:UserpilotTests/AnalyticsPublisherTests \ - -only-testing:UserpilotTests/PushNotificationMonitorTests -xcodebuild test -scheme Userpilot -destination 'platform=iOS Simulator,name=iPhone 16' -``` -Pick a live simulator with `xcrun simctl list devices available`. - -**Android** -```bash -./gradlew detekt -./gradlew :userpilot:testDebugUnitTest --tests '*IdentifyRefreshStateMachineTest' -./gradlew :userpilot:testDebugUnitTest -./gradlew build -``` - -### 9.6 Manual check (both sample apps, logging enabled) - -1. Launch, `identify` the same user twice on screen A → exactly one `identify` frame, **no** screen - frame between them, one `user_token` frame. -2. Navigate to screen B, `identify` again → a second `identify` frame. -3. Return to A, `identify` again → a third. -4. Background → foreground on B, `identify` again → **no** new frame (decision #2). -5. `anonymous()` twice → **no** frames after the first identify (decision #4). -6. `identify` with a changed property → `identify` **plus** screen frame with `fake_reload: true`. -7. Kill and relaunch, `identify` the same user → `identify` reaches the backend on socket open - (previously it did not) plus `user_token`. -8. Switch user → clean + reconnect, token registered for the new user (unchanged behaviour). - -Android extras: exercise both an Activity→Activity change and an Activity→Fragment change on the -same host, since `ScreenSessionStateMachine` treats the latter as the **same** logical surface — -so the fragment case must **not** re-open the refresh allowance. - ---- - -## 10. Shipping - -- **Lint.** iOS: `AnalyticsPublisher.swift` already has `// swiftlint:disable file_length`; - `publish(_:)` grows ~4 lines and stays far under the 50-line `function_body_length` warning. - Android: `AnalyticsPublisher` already carries `@Suppress("TooManyFunctions", "LongParameterList")`; - detekt runs with `maxIssues: 0`, so run `./gradlew detekt` before pushing. -- **No public surface change** on either platform ⇒ no `Userpilot.docc` / README update required. - Optional: one line in the `identify` doc comment on each platform noting that a repeated - identical `identify` is forwarded at most once per screen and that `anonymous()` is exempt. -- **Versions** are owned by CI (`bump-version.yml` on both repos) — do not hand-edit - `Version.swift` or the Android version constant. iOS releases via `release/release-*`; - Android via `maven-publish-release.yml`. -- **Commits.** iOS uses emoji prefixes matched by the CI changelog filter; framed as a fix for - "an identical identify never reaches the backend" ⇒ `🐛`. Ship the two platforms as separate PRs - that reference each other, and land them together so behaviour does not diverge between SDKs. - -## 11. Risks - -| Risk | Mitigation | -|---|---| -| Extra backend traffic for apps that identify on every screen | per-screen allowance bounds it to one `identify` + one `user_token` per screen change; `EventThrottle` is the follow-up lever | -| Reconnect loses the user because the replay is suppressed | explicit `isPendingReplay` bypass (§4.4) + dedicated test on both platforms | -| Token obligation silently dropped on navigation | encoded as a state transition, not a flag: `onScreenChanged` is a no-op while `awaitingPushTokenSync` | -| iOS `DIContainer.resolve` `fatalError` during SDK init | `pushNotificationMonitor` is resolved on demand, never from `init` (§5.2a) | -| Android socket-listener registration order changes | `pushNotificationHandlerProvider` defers construction to first use (§6.2) | -| Android stored token wiped on ack | `resyncPushToken` seeds `cachedFCMToken` before publishing (§6.4) | -| iOS/Android drift after this change | §7 parity checklist; both state machines carry a "keep in sync" doc comment | -| `weak` on an iOS computed property | mirrors the existing `experiencesPublisher` / `sessionMonitorer` declarations in the same file; drop the keyword if the compiler objects | From a93a994029c58181cc7e4801e7fb95b560cba6bd Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 31 Aug 2026 12:17:12 +0300 Subject: [PATCH 28/95] =?UTF-8?q?=F0=9F=94=A7=20ci:=20Add=20green-gate=20s?= =?UTF-8?q?cripts=20and=20harden=20Actions=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs had no xcodebuild/SwiftLint gate, and the CocoaPods release ran as a side effect of pushing a branch. Add scripts/release-preflight.sh as the shared gate (environment, tests, lint, xcframework) that CI jobs call with --only, switch the release to workflow_dispatch, and pin permissions / native concurrency / actions/checkout@v4. Co-authored-by: Cursor --- .github/workflows/bump-version.yml | 28 +- .github/workflows/ci.yml | 127 +++++++++ .github/workflows/clean-artifacts.yml | 6 + .github/workflows/cocoapods-publish.yaml | 228 +++++++++------- .github/workflows/publish-sample.yml | 70 ++--- scripts/build-xcframework.sh | 301 +++++++++++++++++++++ scripts/release-preflight.sh | 320 +++++++++++++++++++++++ 7 files changed, 916 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/build-xcframework.sh create mode 100755 scripts/release-preflight.sh diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 25ed1f76..36419ac7 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -18,6 +18,13 @@ on: - beta - rc +concurrency: + group: bump-version-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + jobs: bump: runs-on: macos-latest # macOS is used because sed behaves differently here @@ -25,7 +32,7 @@ jobs: steps: # 📥 Checkout the latest source code from the repo - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # ⚙️ Set up Git user identity for commits - name: Set up Git config @@ -118,22 +125,3 @@ jobs: run: | git tag ${{ steps.bump_version.outputs.new_version }} git push origin ${{ steps.bump_version.outputs.new_version }} - - # 📝 Update the README with the new version badge - - name: Update README with version badge - run: | - badge_url="https://img.shields.io/github/v/tag/${{ github.repository }}?label=version" - - # Debug: print README before modification - echo "Before sed: $(cat README.md)" - - # Replace placeholder with badge image - sed -i '' "s||![version]($badge_url)|" README.md - - # Debug: print README after modification - echo "After sed: $(cat README.md)" - - # Commit the updated README if there are changes - git add README.md - git commit -m "📝 update version badge in README" || echo "No changes to commit" - git push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2b1f64ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +# ✅ The PR gate for the Userpilot iOS SDK. +# +# Same steps as `scripts/release-preflight.sh` (`pre-release-check` skill). +# The package declares only .iOS(.v13) and depends on UIKit, so `swift build` / +# `swift test` do NOT work here — everything must go through xcodebuild against +# an iOS Simulator, using the shared `Userpilot` scheme. +# +# environment .PRODUCTION, placeholders, no NX- in SocketManager, Version.swift == podspec +# test xcodebuild test (compiles + runs UserpilotTests) +# lint swiftlint --strict +# xcframework build-xcframework.sh (device + simulator Release) +# +# Jobs run in parallel; Green gate is the one required status check — it names +# every job that did not pass. +# +# Only the SDK is built here — the Sample/ apps are covered by publish-sample.yml. + +name: CI + +on: + pull_request: + push: + branches: + - main + +# 🛑 A new push to the same PR/branch supersedes the previous run +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # 🔒 Stops a DEVELOPMENT/STAGING build, a hardcoded NX- token/socket, or version drift merging + environment: + name: Environment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check environment config + run: bash scripts/release-preflight.sh --only environment + + # 🧪 Compiles the SDK and runs the full XCTest suite on a simulator + test: + name: Build & test + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode version + run: xcodebuild -version + + - name: Run unit tests + env: + RESULT_BUNDLE_PATH: ${{ runner.temp }}/Userpilot.xcresult + run: bash scripts/release-preflight.sh --only test + + # 📊 The .xcresult carries the failing assertions and is the only useful + # artifact when a test fails on a runner you cannot attach to + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcresult + path: ${{ runner.temp }}/Userpilot.xcresult + retention-days: 7 + + # 🔍 SwiftLint, scoped by .swiftlint.yml to Sources/Userpilot + Tests/UserpilotTests + # (Vendor/ is excluded there). --strict makes warnings fail the build. + lint: + name: SwiftLint + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: bash scripts/release-preflight.sh --only lint + + # 📦 Proves the binary artifact still builds (device + simulator Release). + # A one-line xcodebuild is not enough — see build-xcframework.sh. + xcframework: + name: XCFramework + runs-on: macos-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode version + run: xcodebuild -version + + - name: Build xcframework + run: bash scripts/release-preflight.sh --only xcframework + + # 🚦 One status check to protect `main` with. Names every job that failed. + green-gate: + name: Green gate + runs-on: ubuntu-latest + needs: [environment, test, lint, xcframework] + if: always() + steps: + - name: Verify every job passed + env: + RESULTS: >- + environment=${{ needs.environment.result }} + test=${{ needs.test.result }} + lint=${{ needs.lint.result }} + xcframework=${{ needs.xcframework.result }} + run: | + echo "$RESULTS" | tr ' ' '\n' + failed=0 + for pair in $RESULTS; do + name="${pair%%=*}" + result="${pair##*=}" + case "$result" in + success|skipped) ;; + *) echo "::error::$name did not pass (result: $result)"; failed=1 ;; + esac + done + if [ "$failed" -ne 0 ]; then + echo "Green gate failed — see the failing job above." + exit 1 + fi + echo "Green gate passed" diff --git a/.github/workflows/clean-artifacts.yml b/.github/workflows/clean-artifacts.yml index 6f35da2f..dadea81b 100644 --- a/.github/workflows/clean-artifacts.yml +++ b/.github/workflows/clean-artifacts.yml @@ -6,6 +6,12 @@ on: # 🕐 Schedule the workflow to run daily at 1:00 AM UTC schedule: - cron: '0 1 * * *' + # 🎛️ Allow manual cleanup runs from the GitHub UI + workflow_dispatch: + +permissions: + actions: write + contents: read jobs: remove-old-artifacts: diff --git a/.github/workflows/cocoapods-publish.yaml b/.github/workflows/cocoapods-publish.yaml index 75612459..e3aef934 100644 --- a/.github/workflows/cocoapods-publish.yaml +++ b/.github/workflows/cocoapods-publish.yaml @@ -3,75 +3,127 @@ name: Release and Publish to CocoaPods on: - # 🔄 Trigger workflow on push to 'release/release-*' paths - push: - branches: - - 'release/release-*' + # 🎛️ Manual only. A CocoaPods trunk push is irreversible — a published version can + # only be deprecated, never removed — so it must be a deliberate act, not a side + # effect of pushing a branch. + # + # Run it from Actions → "Release and Publish to CocoaPods" → Run workflow, + # selecting the branch to release from (`main` or a `release/*` branch) and the + # version bump to apply. + workflow_dispatch: + inputs: + bump: + description: 'Version bump for this release' + required: true + default: 'beta' + type: choice + options: + - major + - minor + - patch + - beta + - rc + +# 🛑 Never run two releases against the same branch at once +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write jobs: - # 🔖 First job: Trigger the bump version workflow and wait for completion + # ✅ First job: reject the wrong branch before anything is mutated. + # This must precede the bump — a manual dispatch can select ANY branch, and bumping + # first would leave a stray version commit and tag on a branch we then reject. + validate-branch: + name: Validate branch + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Validate branch + run: | + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [[ "$BRANCH" != "main" && "$BRANCH" != release/* ]]; then + echo "::error::Must release from 'main' or a 'release/*' branch. Selected: $BRANCH" + exit 1 + fi + echo "Branch validation passed: $BRANCH" + + # 🔖 Second job: Trigger the bump version workflow and wait for completion bump-version: + needs: validate-branch runs-on: ubuntu-latest outputs: version: ${{ steps.get-version.outputs.version }} steps: - # 🧩 Trigger the bump version workflow - - name: Trigger bump version workflow - uses: actions/github-script@v6 + # 🧩 Trigger the bump version workflow and wait for THIS dispatch to finish. + # We record the dispatch timestamp first, then only accept a run created after + # it — otherwise a previously completed bump run satisfies the poll instantly + # and we would publish an un-bumped version. + - name: Trigger bump version workflow and wait + uses: actions/github-script@v7 with: github-token: ${{ secrets.NPM_TOKEN }} script: | - const result = await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'bump-version.yml', - ref: context.ref - // No inputs needed - will use default from bump-version.yml + const workflow_id = 'bump-version.yml'; + const { owner, repo } = context.repo; + + // Timestamp just before dispatch (minus a small skew allowance) + const dispatchedAt = new Date(Date.now() - 10_000); + + // Pass through the bump chosen when this release was dispatched + const bump = '${{ inputs.bump }}'; + + await github.rest.actions.createWorkflowDispatch({ + owner, repo, workflow_id, + ref: context.ref, + inputs: { bump }, }); - console.log('Bump version workflow triggered'); + console.log(`Bump version workflow dispatched (bump=${bump}) at ${dispatchedAt.toISOString()}`); - # 🕐 Wait for the bump version workflow to complete - - name: Wait for bump version workflow completion - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.NPM_TOKEN }} - script: | - const maxAttempts = 30; // Wait up to 15 minutes (30 * 30 seconds) - let attempts = 0; - - while (attempts < maxAttempts) { - const runs = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'bump-version.yml', - per_page: 1, - status: 'completed' - }); - - if (runs.data.workflow_runs.length > 0) { - const latestRun = runs.data.workflow_runs[0]; - console.log(`Latest bump workflow status: ${latestRun.status}, conclusion: ${latestRun.conclusion}`); - - if (latestRun.conclusion === 'success') { - console.log('Bump version workflow completed successfully'); - break; - } else if (latestRun.conclusion === 'failure' || latestRun.conclusion === 'cancelled') { - throw new Error(`Bump version workflow failed with conclusion: ${latestRun.conclusion}`); + const maxAttempts = 40; // up to ~20 minutes (40 * 30 seconds) + let runId = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + await new Promise(r => setTimeout(r, 30_000)); + + // Find OUR run: created after dispatch + if (runId === null) { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner, repo, workflow_id, per_page: 20, + }); + const mine = data.workflow_runs + .filter(r => new Date(r.created_at) >= dispatchedAt) + .sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0]; + if (!mine) { + console.log(`Bump run not visible yet... (attempt ${attempt}/${maxAttempts})`); + continue; } + runId = mine.id; + console.log(`Tracking bump run #${runId} (${mine.html_url})`); } - - attempts++; - if (attempts >= maxAttempts) { - throw new Error('Timeout waiting for bump version workflow to complete'); - } - - console.log(`Waiting for bump version workflow... (attempt ${attempts}/${maxAttempts})`); - await new Promise(resolve => setTimeout(resolve, 30000)); // Wait 30 seconds + + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner, repo, run_id: runId, + }); + console.log(`Run #${runId}: status=${run.status}, conclusion=${run.conclusion}`); + + if (run.status !== 'completed') continue; + if (run.conclusion === 'success') return; + throw new Error(`Bump version workflow failed with conclusion: ${run.conclusion}`); } + throw new Error('Timeout waiting for bump version workflow to complete'); + # 📥 Checkout the updated code after version bump - name: Checkout updated code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref }} @@ -93,26 +145,6 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT echo "Bumped version: $version" - # ✅ Second job: Validate branch - validate-branch: - needs: bump-version - runs-on: macOS-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - - name: Validate branch - run: | - BRANCH=$(git rev-parse --abbrev-ref HEAD) - if [[ "$BRANCH" != "main" && "$BRANCH" != release/* ]]; then - echo "Error: Must be on 'main' or 'release/*' branch. Current: $BRANCH" - exit 1 - fi - echo "Branch validation passed: $BRANCH" - # 📝 Third job: Create GitHub Release create-release: needs: [bump-version, validate-branch] @@ -120,7 +152,7 @@ jobs: steps: # 📥 Checkout code with the latest changes including version bump - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref }} @@ -148,7 +180,7 @@ jobs: # 📝 Create GitHub Release using the bumped version - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.bump-version.outputs.version }} name: ${{ needs.bump-version.outputs.version }} @@ -166,7 +198,7 @@ jobs: steps: # 📥 Checkout code with the latest changes including version bump - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref }} @@ -182,42 +214,37 @@ jobs: - name: Install CocoaPods run: gem install cocoapods - # ✅ Validate podspec before publishing - - name: Validate podspec + # 🔧 Update podspec version FIRST. `pod spec lint` resolves + # spec.source's `:tag => "#{spec.version}"`, so linting before this step + # would validate the PREVIOUS release's tag and tell us nothing about the + # version we are about to publish. + - name: Update podspec version run: | - # Find the podspec file (assuming it's in the root directory) + VERSION="${{ needs.bump-version.outputs.version }}" PODSPEC=$(find . -name "*.podspec" -maxdepth 1 | head -n 1) if [ -z "$PODSPEC" ]; then - echo "Error: No .podspec file found in root directory" + echo "::error::No .podspec file found in root directory" exit 1 fi echo "Found podspec: $PODSPEC" - - # Validate the podspec - pod spec lint "$PODSPEC" --allow-warnings - echo "Podspec validation passed" - # 🔧 Update podspec version (if needed) - - name: Update podspec version - run: | - VERSION="${{ needs.bump-version.outputs.version }}" - PODSPEC=$(find . -name "*.podspec" -maxdepth 1 | head -n 1) - - if [ -n "$PODSPEC" ]; then - # Update version in podspec file - sed -i '' "s/s\.version[[:space:]]*=[[:space:]]*['\"][^'\"]*['\"]/s.version = '$VERSION'/" "$PODSPEC" - echo "Updated podspec version to $VERSION" - - # Show the updated line for verification - grep "s\.version" "$PODSPEC" || echo "Version line not found in podspec" + # Update version in podspec file + sed -i '' "s/spec\.version[[:space:]]*=[[:space:]]*['\"][^'\"]*['\"]/spec.version = \"$VERSION\"/" "$PODSPEC" + echo "Updated podspec version to $VERSION" + grep "spec\.version" "$PODSPEC" + + # The podspec version drives the git tag it resolves — they must agree + if ! grep -q "\"$VERSION\"" "$PODSPEC"; then + echo "::error::podspec version did not update to $VERSION" + exit 1 fi - # 📄 Commit podspec changes if any + # 📄 Commit podspec changes so the tag's tree carries the right version - name: Commit podspec changes run: | git config user.name "motasem-userpilot" git config user.email "motasem@userpilot.co" - + if git diff --quiet; then echo "No changes to commit" else @@ -226,6 +253,13 @@ jobs: git push fi + # ✅ Validate the podspec we are actually about to publish + - name: Validate podspec + run: | + PODSPEC=$(find . -name "*.podspec" -maxdepth 1 | head -n 1) + pod lib lint "$PODSPEC" --allow-warnings + echo "Podspec validation passed" + # 🚀 Deploy to CocoaPods using the action - name: Deploy to CocoaPods uses: michaelhenry/deploy-to-cocoapods-github-action@1.0.10 diff --git a/.github/workflows/publish-sample.yml b/.github/workflows/publish-sample.yml index 0995bb27..96e3de15 100644 --- a/.github/workflows/publish-sample.yml +++ b/.github/workflows/publish-sample.yml @@ -16,25 +16,18 @@ on: - UserpilotSample - UserpilotSample Dev -# 🛡️ Set default permissions needed for canceling workflows and reading repository contents +# 🛑 Native cancellation of superseded runs (replaces styfle/cancel-workflow-action, +# which needed a PAT and write-all permissions) +concurrency: + group: publish-sample-${{ github.ref }} + cancel-in-progress: true + permissions: - actions: write contents: read jobs: - # 🔴 Cancel any previously running workflow runs (for this workflow) on the same branch - cancel_previous: - runs-on: ubuntu-latest - permissions: write-all - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ secrets.NPM_TOKEN }} - # 🛠️ Build the selected iOS scheme into an .ipa file build_sample_app: - needs: cancel_previous runs-on: macos-latest steps: @@ -44,7 +37,7 @@ jobs: # 📥 Checkout the source code from the repository - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # 🔐 Setup signing credentials (certificate & provisioning profile) - name: Setup Secrets @@ -84,16 +77,12 @@ jobs: mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles - # ✅ Install SwiftLint for static code analysis (optional) - - name: Install SwiftLint - run: brew install swiftlint - # 🏗️ Build the selected scheme into an .xcarchive - name: Build selected scheme archive run: | ARCHIVE_PATH="$RUNNER_TEMP/${{ github.event.inputs.scheme }}.xcarchive" - xcodebuild -project ./Sample/UserpilotSample/UserpilotSample.xcodeproj \ + xcodebuild -project ./Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj \ -scheme "${{ github.event.inputs.scheme }}" \ -archivePath "$ARCHIVE_PATH" \ -sdk iphoneos \ @@ -123,13 +112,13 @@ jobs: # ☁️ Upload the built .ipa file to Firebase App Distribution upload_to_firebase: - needs: [cancel_previous, build_sample_app] + needs: build_sample_app runs-on: ubuntu-latest steps: # 📥 Checkout the repository - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -140,34 +129,21 @@ jobs: name: ${{ github.event.inputs.scheme }}.ipa path: ./build - # 🧳 Fetch base and head branches (only needed for PRs) - - name: Fetch base and head branches - if: github.event_name == 'pull_request' - run: | - git fetch origin ${{ github.event.pull_request.base.ref }} - git fetch origin ${{ github.event.pull_request.head.ref }} - - # 📝 Generate dynamic release notes + # 📝 Generate release notes from the commits on the dispatched ref. + # This workflow is workflow_dispatch-only, so there is no pull_request / + # head_commit / before / after context to read — those are always null here. - name: Generate release notes run: | - echo "📱 Firebase Release Notes" > release-notes.txt - echo "" >> release-notes.txt - - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "📦 PR Title: ${{ github.event.pull_request.title }}" >> release-notes.txt - echo "🔗 PR Link: https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}" >> release-notes.txt - echo "👤 Triggered by: ${{ github.actor }}" >> release-notes.txt - echo "" >> release-notes.txt - echo "📋 Commits in this PR:" >> release-notes.txt - git log origin/${{ github.event.pull_request.base.ref }}..origin/${{ github.event.pull_request.head.ref }} --pretty=format:"* %h - %s (by %an)" >> release-notes.txt - else - echo "📦 Commit Message: ${{ github.event.head_commit.message }}" >> release-notes.txt - echo "🔗 Commit Link: https://github.com/${{ github.repository }}/commit/${{ github.sha }}" >> release-notes.txt - echo "👤 Triggered by: ${{ github.actor }}" >> release-notes.txt - echo "" >> release-notes.txt - echo "📋 Commits in this Push:" >> release-notes.txt - git log ${{ github.event.before }}..${{ github.event.after }} --pretty=format:"* %h - %s (by %an)" >> release-notes.txt - fi + { + echo "📱 Firebase Release Notes" + echo "" + echo "🌿 Branch: ${{ github.ref_name }} (${{ github.event.inputs.scheme }})" + echo "🔗 Commit: https://github.com/${{ github.repository }}/commit/${{ github.sha }}" + echo "👤 Triggered by: ${{ github.actor }}" + echo "" + echo "📋 Recent commits:" + git log -n 20 --pretty=format:"* %h - %s (by %an)" + } > release-notes.txt # 🚀 Upload the .ipa to Firebase App Distribution - name: Upload .ipa to Firebase App Distribution diff --git a/scripts/build-xcframework.sh b/scripts/build-xcframework.sh new file mode 100755 index 00000000..46a58fbf --- /dev/null +++ b/scripts/build-xcframework.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +# +# Build Userpilot.xcframework from the Swift package. +# +# scripts/build-xcframework.sh [--output DIR] [--zip] +# +# Produces one binary artifact holding the iOS device and iOS Simulator slices, +# for consumers who integrate neither via SPM nor CocoaPods: manual drag-and-drop +# integration and the Flutter / React Native / Capacitor wrappers. +# +# Exit code: 0 = built and verified, 1 = failed. +# +# --------------------------------------------------------------------------- +# Why this is a script and not a one-line xcodebuild +# --------------------------------------------------------------------------- +# Four things about this package each break the textbook recipe: +# +# 1. The product is `.library(name:targets:)` — *automatic* linkage, which Xcode +# resolves to a static object file. `xcodebuild archive` on the stock manifest +# emits only `Userpilot.o`; there is no .framework to package. So the script +# rewrites the manifest to `type: .dynamic` for the duration of the build and +# restores it on exit, including on failure or Ctrl-C. +# +# Dynamic rather than static is also required for correctness: `Bundle.module` +# resolves resources through `Bundle(for:).resourceURL`, which is the framework +# itself only when the framework is dynamic. In a static framework that call +# returns the *host app's* bundle, and every image, xib and countries.json +# lookup would trap at runtime. +# +# 2. The archived .framework is missing two pieces Xcode would add for a real +# framework target, both copied in by `assemble_framework`: +# * Modules/Userpilot.swiftmodule — without it, `import Userpilot` fails. +# * Userpilot_Userpilot.bundle — the SPM resource bundle, which has to sit +# inside the framework for the lookup described above to find it. +# (See Sources/Userpilot/Extensions/Bundle+Extensions.swift.) +# +# 3. `xcodebuild -create-xcframework` rejects a Swift framework whose .swiftmodule +# contains no .swiftinterface ("No 'swiftinterface' files found within ..."), +# so BUILD_LIBRARY_FOR_DISTRIBUTION=YES is mandatory here, not a nicety. +# +# 4. ...but this package cannot emit a *verifiable* interface. The module and its +# public class are both named `Userpilot`, so every qualified name the interface +# printer writes — `Userpilot.UserpilotAnalytic`, `Userpilot.Socket.CloseCode`, +# `Userpilot.Userpilot.Config` — resolves to the class rather than the module, +# and emit-time verification fails with 66 errors. +# +# Since inside a .swiftinterface the module's own types resolve unqualified, +# dropping the leading `Userpilot.` qualifier makes the interface correct. +# So the build passes -no-verify-emitted-module-interface, `repair_interfaces` +# strips that qualifier, and `verify_interface` then typechecks each repaired +# interface with swift-frontend — the same check the compiler would run, so the +# suppression above is re-armed rather than skipped. A slice whose interface +# does not typecheck fails this script. +# +# The proper upstream fix for (4) is for the module and the public class to stop +# sharing a name; that is a breaking change for every consumer and every wrapper, +# so it is deliberately not attempted here. + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +SCHEME="Userpilot" +MODULE="Userpilot" +MANIFEST="Package.swift" +RESOURCE_BUNDLE="${MODULE}_${MODULE}.bundle" + +OUTPUT_DIR=".build/xcframework" +MAKE_ZIP=0 +INCLUDE_DSYMS=0 + +usage() { + cat <&2; usage >&2; exit 1 ;; + esac +done + +command -v xcodebuild >/dev/null 2>&1 || { echo "❌ xcodebuild not found — install Xcode."; exit 1; } +SWIFT_FRONTEND="$(xcrun --find swift-frontend 2>/dev/null || true)" +[ -x "$SWIFT_FRONTEND" ] || { echo "❌ swift-frontend not found — check xcode-select -p."; exit 1; } + +WORK_DIR="$(mktemp -d)" +LOG="$WORK_DIR/xcodebuild.log" +MANIFEST_BACKUP="$WORK_DIR/Package.swift.orig" + +# Restore the manifest however we leave: success, error, or interrupt. +cleanup() { + if [ -f "$MANIFEST_BACKUP" ]; then + cp "$MANIFEST_BACKUP" "$MANIFEST" + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT INT TERM + +# --------------------------------------------------------------------------- +# 1. Patch the manifest to build a dynamic framework +# --------------------------------------------------------------------------- +cp "$MANIFEST" "$MANIFEST_BACKUP" + +if grep -q "type: .dynamic" "$MANIFEST"; then + echo "→ Manifest already declares a dynamic product; leaving it alone." +else + # `targets: ["Userpilot"]` appears exactly once (the .library product); the test + # target refers to the module through `dependencies:`, so this anchor is unambiguous. + sed -i '' -e 's/^\( *\)targets: \["'"$MODULE"'"\]$/\1type: .dynamic,\ +\1targets: ["'"$MODULE"'"]/' "$MANIFEST" + + if ! grep -q "type: .dynamic" "$MANIFEST"; then + echo "❌ Could not patch $MANIFEST to a dynamic product." + echo " Expected a line reading: targets: [\"$MODULE\"]" + exit 1 + fi + echo "→ Patched $MANIFEST to type: .dynamic (restored on exit)." +fi + +# --------------------------------------------------------------------------- +# 2. Archive each platform, then complete and repair the framework +# --------------------------------------------------------------------------- + +# repair_interfaces +# +# Drop the leading `Userpilot.` module qualifier from every emitted interface. Only +# the *first* qualifier of a name is removed, so `Userpilot.Userpilot.Config` +# correctly becomes `Userpilot.Config` (Config stays nested in the class) while +# `Userpilot.UserpilotAnalytic` becomes `UserpilotAnalytic`. Every occurrence in +# the emitted interface is a type position — there are no string literals to damage. +repair_interfaces() { + local swiftmodule="$1" iface + for iface in "$swiftmodule"/*.swiftinterface; do + [ -e "$iface" ] || continue + sed -i '' -E "s/(^|[^A-Za-z0-9_.])$MODULE\./\1/g" "$iface" + done +} + +# verify_interface +# +# Typecheck a repaired interface exactly as a consuming compiler would when it has +# no matching binary .swiftmodule. The target triple is read back out of the +# interface's own `// swift-module-flags:` header. +verify_interface() { + local iface="$1" target sdk_name + target="$(sed -n 's|^// swift-module-flags:.*-target \([^ ]*\).*|\1|p' "$iface" | head -1)" + if [ -z "$target" ]; then + echo "❌ No -target in $(basename "$iface")" + return 1 + fi + case "$target" in + *simulator*) sdk_name=iphonesimulator ;; + *) sdk_name=iphoneos ;; + esac + + if ! "$SWIFT_FRONTEND" -frontend -typecheck-module-from-interface "$iface" \ + -target "$target" \ + -sdk "$(xcodebuild -version -sdk "$sdk_name" Path)" \ + -enable-library-evolution -swift-version 5 -module-name "$MODULE" \ + >"$WORK_DIR/verify.log" 2>&1; then + echo "❌ $(basename "$iface") does not typecheck:" + grep "error:" "$WORK_DIR/verify.log" | head -10 + return 1 + fi +} + +# assemble_framework +assemble_framework() { + local archive="$1" derived="$2" sdk_dir="$3" + local framework="$archive/Products/usr/local/lib/$MODULE.framework" + local products="$derived/Build/Intermediates.noindex/ArchiveIntermediates/$SCHEME/BuildProductsPath/$sdk_dir" + + [ -d "$framework" ] || { echo "❌ No framework in archive at $framework"; exit 1; } + [ -d "$products/$MODULE.swiftmodule" ] || { echo "❌ No $MODULE.swiftmodule in $products"; exit 1; } + [ -d "$products/$RESOURCE_BUNDLE" ] || { echo "❌ No $RESOURCE_BUNDLE in $products"; exit 1; } + + mkdir -p "$framework/Modules" + cp -R "$products/$MODULE.swiftmodule" "$framework/Modules/" + # ABI descriptors feed swift-api-digester; no consumer needs them to import the + # module, and they cost ~876K per architecture. + rm -f "$framework/Modules/$MODULE.swiftmodule"/*.abi.json + # -L: the build-products entry is a symlink into IntermediateBuildFilesPath. + cp -RL "$products/$RESOURCE_BUNDLE" "$framework/" + + repair_interfaces "$framework/Modules/$MODULE.swiftmodule" + + local iface + for iface in "$framework/Modules/$MODULE.swiftmodule"/*.swiftinterface; do + [ -e "$iface" ] || { echo "❌ No .swiftinterface emitted for $sdk_dir"; exit 1; } + verify_interface "$iface" || exit 1 + done +} + +# archive_for - + - + @@ -178,11 +150,10 @@ - - - + - - - - - - - + - - @@ -235,7 +201,6 @@ - @@ -246,8 +211,8 @@ - - + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/IdentifyViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/IdentifyViewController.swift index f80f9e23..5391198e 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/IdentifyViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/IdentifyViewController.swift @@ -24,18 +24,27 @@ class IdentifyViewController: BaseViewController { } @IBOutlet weak var userPropertiesStackView: UIStackView! @IBOutlet weak var companyPropertiesStackView: UIStackView! - @IBOutlet weak var anonymousButton: UIButton! { - didSet { - anonymousButton.layer.borderColor = UIColor.accent.cgColor - anonymousButton.layer.borderWidth = 1 - } - } + @IBOutlet weak var anonymousButton: UIButton! internal var userPropertiesViews: [String: PropertyView] = [:] internal var companyPropertiesViews: [String: PropertyView] = [:] // MARK: - Override + override func viewDidLoad() { + super.viewDidLoad() + title = "Identify" + let logoutItem = UIBarButtonItem( + title: "Logout", + style: .plain, + target: self, + action: #selector(onLogoutBarButtonTapped) + ) + logoutItem.tintColor = .systemRed + navigationItem.rightBarButtonItem = logoutItem + wrapPrimaryScrollContentInCard() + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) UserpilotManager.shared.screen("identify") @@ -49,10 +58,6 @@ class IdentifyViewController: BaseViewController { identifyUser() } - @IBAction func onBackButtonClicked(_ sender: UIButton) { - close() - } - @IBAction func onAddUserProperty(_ sender: UIButton) { showAddUserPropertyDiaog() } @@ -61,7 +66,7 @@ class IdentifyViewController: BaseViewController { showAddCompanyPropertyDialog() } - @IBAction func onLogout(_ sender: UIButton) { + @objc private func onLogoutBarButtonTapped() { FlowRoutingManager.shared.showAlertMessage("User logged out successfully!") UserpilotManager.shared.logout() } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/mul.lproj/IdentifyViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/mul.lproj/IdentifyViewController.xcstrings index 2d0ef4dd..b1820c11 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/mul.lproj/IdentifyViewController.xcstrings +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Identify/mul.lproj/IdentifyViewController.xcstrings @@ -3,7 +3,7 @@ "strings" : { "1JW-us-yJb.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"1JW-us-yJb\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -75,7 +75,7 @@ }, "Dmf-Y6-2UJ.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Logout\"; ObjectID = \"Dmf-Y6-2UJ\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -93,7 +93,7 @@ }, "DMR-Yt-jkv.text" : { "comment" : "Class = \"UILabel\"; text = \"Identify\"; ObjectID = \"DMR-Yt-jkv\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Base.lproj/MainViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Base.lproj/MainViewController.xib deleted file mode 100644 index 8c4d3400..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Base.lproj/MainViewController.xib +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift deleted file mode 100644 index 9ee1170c..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// MainViewController+TableView.swift -// UserpilotSample -// -// Created by Motasem Hamed on 16/09/2024. -// - -import Foundation -import UIKit - -// MARK: - UITableViewDelegate - -extension MainViewController: UITableViewDelegate, UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return content.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cellIdentifier", for: indexPath) - cell.textLabel?.text = content[indexPath.row].title - cell.accessoryType = .disclosureIndicator - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - switch content[indexPath.row] { - case .identify: - FlowRoutingManager.shared.openViewController(IdentifyViewController.newInstance()) - case .screens: - FlowRoutingManager.shared.openViewController(ScreenOneViewController.newInstance()) - case .events: - FlowRoutingManager.shared.openViewController(CustomEventViewController.newInstance()) - case .configurations: - openConfigScreen() - case .eventsLog: - FlowRoutingManager.shared.openViewController(SDKEventsViewController.newInstance()) - case .debug: - FlowRoutingManager.shared.openViewController(DebugViewController.newInstance()) - case .autoCapture: - FlowRoutingManager.shared.openViewController(AutoCaptureHubViewController.newInstance()) - } - } - -} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift index 558417da..d0faf1ac 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift @@ -8,23 +8,31 @@ import Foundation import UIKit -class MainViewController: BaseViewController { +final class MainViewController: BaseViewController { - // MARK: - IBOutlet + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let items: [Content] = [ + .configurations, + .identify, + .screens, + .events, + .eventsLog, + .debug, + .autoCapture + ] - @IBOutlet weak var contentTableView: UITableView! { - didSet { - contentTableView.register(UITableViewCell.self, forCellReuseIdentifier: "cellIdentifier") - } + init() { + super.init(nibName: nil, bundle: nil) } - // MARK: - Properties - - internal lazy var content: [Content] = [.configurations, .identify, .screens, .events, .eventsLog, .debug, .autoCapture] + required init?(coder: NSCoder) { + super.init(coder: coder) + } - // MARK: - Override override func viewDidLoad() { super.viewDidLoad() + title = "Userpilot" + setupTable() UserpilotManager.shared.settings() presentConfigIfNeeded() } @@ -34,31 +42,72 @@ class MainViewController: BaseViewController { UserpilotManager.shared.screen("main") } + private func setupTable() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.backgroundColor = SampleAppearance.screenBackground + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + /// Mirrors Android MainActivity: open Configuration when no app token is saved. private func presentConfigIfNeeded() { let appToken: String = StorageManager.shared.get(forKey: StorageManager.Keys.appToken) ?? "" guard appToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - // Defer until the navigation stack from SceneDelegate is ready. DispatchQueue.main.async { [weak self] in self?.openConfigScreen() } } - internal func openConfigScreen() { - // Avoid stacking duplicate config screens if Main already pushed one. + private func openConfigScreen() { if FlowRoutingManager.shared.visibleViewController is ConfigViewController { return } FlowRoutingManager.shared.openViewController(ConfigViewController()) } + + static func newInstance() -> MainViewController { + MainViewController() + } } -// MARK: - Instance +extension MainViewController: UITableViewDataSource, UITableViewDelegate { -extension MainViewController { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count + } - static func newInstance() -> MainViewController { - return MainViewController() + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + cell.textLabel?.text = items[indexPath.row].title + cell.accessoryType = .disclosureIndicator + return cell } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + switch items[indexPath.row] { + case .identify: + FlowRoutingManager.shared.openViewController(IdentifyViewController.newInstance()) + case .screens: + FlowRoutingManager.shared.openViewController(ScreenOneViewController.newInstance()) + case .events: + FlowRoutingManager.shared.openViewController(CustomEventViewController.newInstance()) + case .configurations: + openConfigScreen() + case .eventsLog: + FlowRoutingManager.shared.openViewController(SDKEventsViewController.newInstance()) + case .debug: + FlowRoutingManager.shared.openViewController(DebugViewController.newInstance()) + case .autoCapture: + FlowRoutingManager.shared.openViewController(AutoCaptureHubViewController.newInstance()) + } + } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/mul.lproj/MainViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/mul.lproj/MainViewController.xcstrings deleted file mode 100644 index 49148cdf..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/mul.lproj/MainViewController.xcstrings +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - "0rx-ji-MFa.text" : { - "comment" : "Class = \"UILabel\"; text = \"© 2024 Userpilot. All rights reserved.\"; ObjectID = \"0rx-ji-MFa\";", - "extractionState" : "extracted_with_value", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "© 2024 Userpilot. جميع الحقوق محفوظة." - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "© 2024 Userpilot. All rights reserved." - } - } - } - } - }, - "version" : "1.0" -} \ No newline at end of file diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Base.lproj/SDKEventsViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Base.lproj/SDKEventsViewController.xib index a27eafe6..0bb59a2f 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Base.lproj/SDKEventsViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Base.lproj/SDKEventsViewController.xib @@ -21,27 +21,9 @@ - - - + @@ -55,27 +37,21 @@ - + - - - - - - + - - - + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Cells/SDKEventTableViewCell.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Cells/SDKEventTableViewCell.swift index 60cbc28d..5fe38c8d 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Cells/SDKEventTableViewCell.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/Cells/SDKEventTableViewCell.swift @@ -1,42 +1,181 @@ // -// EventsTableViewCell.swift +// SDKEventTableViewCell.swift // UserpilotSample // // Created by Motasem Hamed on 16/11/2024. // import UIKit +import Userpilot -class SDKEventTableViewCell: UITableViewCell, ReusableTableCellView, TableViewCellFromNib { +final class SDKEventTableViewCell: UITableViewCell, ReusableTableCellView { - @IBOutlet weak var eventType: UILabel! - @IBOutlet weak var eventName: UILabel! - @IBOutlet weak var eventProperties: UILabel! - @IBOutlet weak var containerView: UIView! + // MARK: - UI + + private let cardView = CardView() + private let typeBadgeLabel = PaddedLabel() + private let titleLabel = UILabel() + private let jsonContainerView = UIView() + private let jsonLabel = UILabel() + private let emptyPropertiesLabel = UILabel() + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + + cardView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(cardView) + + let headerStack = makeHeaderStack() + setupJSONContainer() + + let contentStack = UIStackView(arrangedSubviews: [headerStack, jsonContainerView]) + contentStack.translatesAutoresizingMaskIntoConstraints = false + contentStack.axis = .vertical + contentStack.spacing = 12 + cardView.addSubview(contentStack) + + activateConstraints(contentStack: contentStack) + } + + /// The badge + title row shown at the top of the card. + private func makeHeaderStack() -> UIStackView { + typeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false + typeBadgeLabel.textInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) + typeBadgeLabel.font = .systemFont(ofSize: 12, weight: .semibold) + typeBadgeLabel.textColor = .white + typeBadgeLabel.layer.cornerRadius = 10 + typeBadgeLabel.layer.masksToBounds = true + typeBadgeLabel.setContentHuggingPriority(.required, for: .horizontal) + typeBadgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + titleLabel.textColor = .label + titleLabel.numberOfLines = 0 + + let headerStack = UIStackView(arrangedSubviews: [typeBadgeLabel, titleLabel]) + headerStack.translatesAutoresizingMaskIntoConstraints = false + headerStack.axis = .horizontal + headerStack.alignment = .center + headerStack.spacing = 10 + return headerStack + } + + /// The JSON preview box and its "No properties" empty state. + private func setupJSONContainer() { + jsonContainerView.translatesAutoresizingMaskIntoConstraints = false + jsonContainerView.backgroundColor = UIColor.tertiarySystemFill + jsonContainerView.layer.cornerRadius = 12 + jsonContainerView.layer.cornerCurve = .continuous + jsonContainerView.layer.masksToBounds = true + + jsonLabel.translatesAutoresizingMaskIntoConstraints = false + jsonLabel.numberOfLines = 0 + jsonLabel.lineBreakMode = .byWordWrapping + jsonLabel.setContentHuggingPriority(.defaultLow, for: .vertical) + // Preview JSON is for display only — don't let autocapture treat it as target_text. + jsonLabel.userpilotRedactText = true + jsonContainerView.userpilotRedactText = true + + emptyPropertiesLabel.translatesAutoresizingMaskIntoConstraints = false + emptyPropertiesLabel.text = "No properties" + emptyPropertiesLabel.font = .systemFont(ofSize: 13, weight: .medium) + emptyPropertiesLabel.textColor = .tertiaryLabel + emptyPropertiesLabel.isHidden = true + + jsonContainerView.addSubview(jsonLabel) + jsonContainerView.addSubview(emptyPropertiesLabel) + } + + private func activateConstraints(contentStack: UIStackView) { + NSLayoutConstraint.activate([ + cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6), + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6), + + contentStack.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 14), + contentStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14), + contentStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14), + contentStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -14), + + jsonLabel.topAnchor.constraint(equalTo: jsonContainerView.topAnchor, constant: 10), + jsonLabel.leadingAnchor.constraint(equalTo: jsonContainerView.leadingAnchor, constant: 12), + jsonLabel.trailingAnchor.constraint(equalTo: jsonContainerView.trailingAnchor, constant: -12), + jsonLabel.bottomAnchor.constraint(equalTo: jsonContainerView.bottomAnchor, constant: -10), + + emptyPropertiesLabel.topAnchor.constraint(equalTo: jsonContainerView.topAnchor, constant: 12), + emptyPropertiesLabel.leadingAnchor.constraint( + equalTo: jsonContainerView.leadingAnchor, constant: 12), + emptyPropertiesLabel.trailingAnchor.constraint( + equalTo: jsonContainerView.trailingAnchor, constant: -12), + emptyPropertiesLabel.bottomAnchor.constraint( + equalTo: jsonContainerView.bottomAnchor, constant: -12) + ]) + } + + // MARK: - Bind func bindCell(_ sdkEvent: UserpilotSDKEvent) { - eventType.text = sdkEvent.analytic - eventName.text = sdkEvent.value + typeBadgeLabel.text = sdkEvent.analytic + let title = sdkEvent.value.isEmpty ? "—" : sdkEvent.value + titleLabel.text = title + + accessibilityLabel = sdkEvent.value.isEmpty + ? sdkEvent.analytic + : "\(sdkEvent.analytic): \(sdkEvent.value)" + + let tint = badgeColor(for: sdkEvent.analytic) + typeBadgeLabel.backgroundColor = tint + + let properties: [String: Any]? if sdkEvent.analytic == "Identify" { - let settings = UserpilotManager.shared.settings() - if let jsonData = try? JSONSerialization.data(withJSONObject: settings, options: .withoutEscapingSlashes) { - eventProperties.text = String(data: jsonData, encoding: .utf8) - } + properties = UserpilotManager.shared.settings() } else { - if let properties = sdkEvent.properties, - let jsonData = try? JSONSerialization.data( - withJSONObject: properties, options: .withoutEscapingSlashes) { - eventProperties.text = String(data: jsonData, encoding: .utf8) - } + properties = sdkEvent.properties } - if sdkEvent.analytic == "Identify" || - sdkEvent.analytic == "Screen" || - sdkEvent.analytic == "Event" { - containerView.backgroundColor = UIColor(red: 0.4, green: 0.4, blue: 0.9, alpha: 0.15) + if let properties, !properties.isEmpty { + jsonLabel.attributedText = JSONPreview.attributedString(from: properties, fontSize: 12) + jsonLabel.isHidden = false + emptyPropertiesLabel.isHidden = true } else { - containerView.backgroundColor = UIColor(red: 0.9, green: 0.12, blue: 0.39, alpha: 0.05) + jsonLabel.attributedText = nil + jsonLabel.isHidden = true + emptyPropertiesLabel.isHidden = false } } + private func badgeColor(for analytic: String) -> UIColor { + let lowered = analytic.lowercased() + if lowered.contains("identify") { + return SampleAppearance.accentColor + } + if lowered.contains("screen") { + return .systemTeal + } + if lowered.contains("event") { + return .systemBlue + } + if lowered.contains("experience") { + return .systemOrange + } + return .systemGray + } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/SDKEventsViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/SDKEventsViewController.swift index a1a70c1c..5024489e 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/SDKEventsViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/SDKEventsViewController.swift @@ -16,8 +16,13 @@ class SDKEventsViewController: BaseViewController { @IBOutlet weak var tableView: UITableView! { didSet { tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 105 - tableView.register(cellFromNib: SDKEventTableViewCell.self) + tableView.estimatedRowHeight = 160 + tableView.separatorStyle = .none + tableView.backgroundColor = SampleAppearance.screenBackground + tableView.register( + SDKEventTableViewCell.self, + forCellReuseIdentifier: SDKEventTableViewCell.reuseIdentifier + ) } } @@ -25,17 +30,49 @@ class SDKEventsViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - if userpilotSDKEvents.isEmpty { - emptyContentLabel.isHidden = false - } + title = "Events log" + configureEmptyState() + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Clear", + style: .plain, + target: self, + action: #selector(clearEvents) + ) + navigationItem.rightBarButtonItem?.isEnabled = !userpilotSDKEvents.isEmpty + updateTitleCount() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reloadEvents() + } + + // MARK: - Actions + + @objc private func clearEvents() { + UserpilotManager.shared.clearSDKEvents() + reloadEvents() + } + + private func reloadEvents() { + userpilotSDKEvents = UserpilotManager.shared.userpilotSDKEvents + tableView.reloadData() + emptyContentLabel.isHidden = !userpilotSDKEvents.isEmpty + navigationItem.rightBarButtonItem?.isEnabled = !userpilotSDKEvents.isEmpty + updateTitleCount() } - // MARK: - IBAction - @IBAction func onBackButtonClicked(_ sender: UIButton) { - close() + private func updateTitleCount() { + let count = userpilotSDKEvents.count + title = count == 0 ? "Events log" : "Events log (\(count))" } - // MARK: - Track Event + private func configureEmptyState() { + emptyContentLabel.text = "No events have been tracked yet" + emptyContentLabel.textColor = .secondaryLabel + emptyContentLabel.font = .systemFont(ofSize: 15, weight: .medium) + emptyContentLabel.isHidden = !userpilotSDKEvents.isEmpty + } } @@ -49,13 +86,13 @@ extension SDKEventsViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let eventTableViewCell: SDKEventTableViewCell = tableView.dequeueReusableCell(for: indexPath) - eventTableViewCell.selectionStyle = .none eventTableViewCell.bindCell(userpilotSDKEvents[indexPath.row]) return eventTableViewCell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) + presentJSONPreview(for: userpilotSDKEvents[indexPath.row]) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -66,6 +103,36 @@ extension SDKEventsViewController: UITableViewDelegate, UITableViewDataSource { return CGFloat.leastNonzeroMagnitude } + private func presentJSONPreview(for event: UserpilotSDKEvent) { + let properties: [String: Any]? + if event.analytic == "Identify" { + properties = UserpilotManager.shared.settings() + } else { + properties = event.properties + } + + let alert = UIAlertController( + title: event.analytic, + message: event.value, + preferredStyle: .actionSheet + ) + alert.addAction(UIAlertAction(title: "Copy JSON", style: .default, handler: { _ in + let payload: [String: Any] = [ + "type": event.analytic, + "title": event.value, + "properties": properties ?? [:] + ] + UIPasteboard.general.string = JSONPreview.prettyString(from: payload) + })) + alert.addAction(UIAlertAction(title: "Done", style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1) + popover.permittedArrowDirections = [] + } + present(alert, animated: true) + } + } // MARK: - Instance diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/mul.lproj/SDKEventsViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/mul.lproj/SDKEventsViewController.xcstrings index d5bc30dc..88f1412c 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/mul.lproj/SDKEventsViewController.xcstrings +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/SDKEvents/mul.lproj/SDKEventsViewController.xcstrings @@ -21,7 +21,7 @@ }, "ISF-Pl-bqV.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"ISF-Pl-bqV\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -39,7 +39,7 @@ }, "O4G-I1-J0c.text" : { "comment" : "Class = \"UILabel\"; text = \"Events log\"; ObjectID = \"O4G-I1-J0c\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/Base.lproj/ScreenOneViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/Base.lproj/ScreenOneViewController.xib index 3d2015ba..fca0b661 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/Base.lproj/ScreenOneViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/Base.lproj/ScreenOneViewController.xib @@ -6,6 +6,7 @@ + @@ -19,24 +20,6 @@ - - @@ -79,10 +62,10 @@ - - + - - - - + - - - + + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/ScreenOneViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/ScreenOneViewController.swift index a9b51a95..613c2416 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/ScreenOneViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/ScreenOneViewController.swift @@ -12,6 +12,11 @@ class ScreenOneViewController: BaseViewController { // MARK: - Override + override func viewDidLoad() { + super.viewDidLoad() + title = "Screen One" + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) UserpilotManager.shared.screen("screen one") @@ -19,10 +24,6 @@ class ScreenOneViewController: BaseViewController { // MARK: - IBAction - @IBAction func onBackButtonClicked(_ sender: UIButton) { - close() - } - @IBAction func onNextButtonClicked(_ sender: UIButton) { FlowRoutingManager.shared.openViewController(ScreenTwoViewController.newInstance()) } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/mul.lproj/ScreenOneViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/mul.lproj/ScreenOneViewController.xcstrings index 9f90054c..d72a312c 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/mul.lproj/ScreenOneViewController.xcstrings +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenOne/mul.lproj/ScreenOneViewController.xcstrings @@ -3,7 +3,7 @@ "strings" : { "9ii-3T-s3F.text" : { "comment" : "Class = \"UILabel\"; text = \"Screen One\"; ObjectID = \"9ii-3T-s3F\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -57,7 +57,7 @@ }, "SMc-8F-yVD.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"SMc-8F-yVD\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -75,4 +75,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/Base.lproj/ScreenTwoViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/Base.lproj/ScreenTwoViewController.xib index 3391d593..5d2071e1 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/Base.lproj/ScreenTwoViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/Base.lproj/ScreenTwoViewController.xib @@ -6,6 +6,7 @@ + @@ -19,24 +20,6 @@ - - @@ -111,10 +94,10 @@ - - + - - - - - - + - + + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/ScreenTwoViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/ScreenTwoViewController.swift index 9e83b346..ecd68bd6 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/ScreenTwoViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/ScreenTwoViewController.swift @@ -10,6 +10,11 @@ import UIKit class ScreenTwoViewController: BaseViewController { + override func viewDidLoad() { + super.viewDidLoad() + title = "Screen Two" + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) UserpilotManager.shared.screen("screen two") @@ -17,10 +22,6 @@ class ScreenTwoViewController: BaseViewController { // MARK: - IBAction - @IBAction func onBackButtonClicked(_ sender: UIButton) { - close() - } - @IBAction func onNextButtonClicked(_ sender: UIButton) { UserpilotManager.shared.triggerExperience(experienceId: "mobile:5") } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/mul.lproj/ScreenTwoViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/mul.lproj/ScreenTwoViewController.xcstrings index 8c896412..84ebe328 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/mul.lproj/ScreenTwoViewController.xcstrings +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/ScreensFlow/ScreenTwo/mul.lproj/ScreenTwoViewController.xcstrings @@ -39,7 +39,7 @@ }, "ncF-n6-k7c.text" : { "comment" : "Class = \"UILabel\"; text = \"Screen Two\"; ObjectID = \"ncF-n6-k7c\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -57,7 +57,7 @@ }, "Pn7-f9-HTA.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"Pn7-f9-HTA\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -75,4 +75,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift index c9cb977c..63115744 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Managers/Userpilot/UserpilotManager.swift @@ -27,6 +27,10 @@ class UserpilotManager { private var userpilot: Userpilot? private(set) var userpilotSDKEvents = [UserpilotSDKEvent]() + + func clearSDKEvents() { + userpilotSDKEvents.removeAll() + } // MARK: - Life Cycle diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Utils/Utils.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Utils/Utils.swift index 23992fce..804600e5 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Utils/Utils.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Utils/Utils.swift @@ -8,78 +8,199 @@ import Foundation import UIKit -// swiftlint:disable all - func delay(_ delay: Double, closure: @escaping () -> Void) { - DispatchQueue.main.asyncAfter( deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), - execute: closure) - + execute: closure + ) } -extension Dictionary where Key == String, Value == Any { - func formattedJSONLabel() -> NSAttributedString { - let attributedText = NSMutableAttributedString() +enum JSONPreview { + + /// Text attributes per JSON token kind, built once per render. + private struct Palette { + let key: [NSAttributedString.Key: Any] + let string: [NSAttributedString.Key: Any] + let number: [NSAttributedString.Key: Any] + let bool: [NSAttributedString.Key: Any] + let null: [NSAttributedString.Key: Any] + let punctuation: [NSAttributedString.Key: Any] + + init(fontSize: CGFloat) { + key = JSONPreview.attributes(color: .systemBlue, size: fontSize, weight: .semibold) + string = JSONPreview.attributes(color: .systemGreen, size: fontSize, weight: .regular) + number = JSONPreview.attributes(color: .systemOrange, size: fontSize, weight: .regular) + bool = JSONPreview.attributes(color: .systemPurple, size: fontSize, weight: .regular) + null = JSONPreview.attributes(color: .tertiaryLabel, size: fontSize, weight: .regular) + punctuation = JSONPreview.attributes(color: .label, size: fontSize, weight: .regular) + } + } - let keyAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.systemBlue, - .font: UIFont.monospacedSystemFont(ofSize: 14, weight: .bold) - ] + static func attributedString( + from object: Any?, + fontSize: CGFloat = 12 + ) -> NSAttributedString { + guard let object else { + return NSAttributedString( + string: "null", + attributes: attributes(color: .tertiaryLabel, size: fontSize, weight: .regular) + ) + } + let result = NSMutableAttributedString() + append(object, to: result, indent: "", palette: Palette(fontSize: fontSize)) + return result + } - let stringAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.systemGreen, - .font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) - ] + static func attributedString( + from dictionary: [String: Any]?, + fontSize: CGFloat = 12 + ) -> NSAttributedString { + guard let dictionary, !dictionary.isEmpty else { + return NSAttributedString( + string: "{}", + attributes: attributes(color: .secondaryLabel, size: fontSize, weight: .regular) + ) + } + return attributedString(from: dictionary as Any, fontSize: fontSize) + } - let numberAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.systemOrange, - .font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) - ] + static func prettyString(from object: Any?) -> String { + guard let object, + JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ), + let string = String(data: data, encoding: .utf8) + else { + return object.map { String(describing: $0) } ?? "null" + } + return string + } + + // MARK: - Private - let punctuationAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.label, - .font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + private static func attributes( + color: UIColor, + size: CGFloat, + weight: UIFont.Weight + ) -> [NSAttributedString.Key: Any] { + [ + .foregroundColor: color, + .font: UIFont.monospacedSystemFont(ofSize: size, weight: weight) ] + } + + private static func append( + _ json: Any, + to attributedText: NSMutableAttributedString, + indent: String, + palette: Palette + ) { + if let dict = json as? [String: Any] { + appendObject(dict, to: attributedText, indent: indent, palette: palette) + } else if let array = json as? [Any] { + appendArray(array, to: attributedText, indent: indent, palette: palette) + } else { + appendScalar(json, to: attributedText, palette: palette) + } + } - func appendFormatted(json: Any, indent: String = "") { - if let dict = json as? [String: Any] { - attributedText.append(NSAttributedString(string: "{\n", attributes: punctuationAttributes)) - for (index, key) in dict.keys.enumerated() { - attributedText.append(NSAttributedString(string: indent + " \"\(key)\": ", attributes: keyAttributes)) - - let value = dict[key] - - // Recursive check - if let subDict = value as? [String: Any] { - appendFormatted(json: subDict, indent: indent + " ") - } else if let stringValue = value as? String { - attributedText.append(NSAttributedString(string: "\"\(stringValue)\"", attributes: stringAttributes)) - } else if let numberValue = value as? NSNumber { - attributedText.append(NSAttributedString(string: "\(numberValue)", attributes: numberAttributes)) - } else if value is NSNull { - attributedText.append(NSAttributedString(string: "null", attributes: punctuationAttributes)) - } else { - attributedText.append(NSAttributedString(string: "\"\(String(describing: value))\"", attributes: stringAttributes)) - } - - if index < dict.keys.count - 1 { - attributedText.append(NSAttributedString(string: ",", attributes: punctuationAttributes)) - } - - // ADD NEW LINE AFTER EACH PROPERTY - attributedText.append(NSAttributedString(string: "\n", attributes: punctuationAttributes)) - } - attributedText.append(NSAttributedString(string: indent + "}", attributes: punctuationAttributes)) + private static func appendObject( + _ dict: [String: Any], + to attributedText: NSMutableAttributedString, + indent: String, + palette: Palette + ) { + let keys = dict.keys.sorted() + attributedText.append(NSAttributedString(string: "{", attributes: palette.punctuation)) + guard !keys.isEmpty else { + attributedText.append(NSAttributedString(string: "}", attributes: palette.punctuation)) + return + } + attributedText.append(NSAttributedString(string: "\n", attributes: palette.punctuation)) + for (index, key) in keys.enumerated() { + attributedText.append( + NSAttributedString(string: indent + " \"\(key)\": ", attributes: palette.key) + ) + if let value = dict[key] { + append(value, to: attributedText, indent: indent + " ", palette: palette) } else { - // Handle arrays or other values if needed + attributedText.append(NSAttributedString(string: "null", attributes: palette.null)) } + appendSeparator(to: attributedText, isLast: index == keys.count - 1, palette: palette) + } + attributedText.append(NSAttributedString(string: indent + "}", attributes: palette.punctuation)) + } + + private static func appendArray( + _ array: [Any], + to attributedText: NSMutableAttributedString, + indent: String, + palette: Palette + ) { + attributedText.append(NSAttributedString(string: "[", attributes: palette.punctuation)) + guard !array.isEmpty else { + attributedText.append(NSAttributedString(string: "]", attributes: palette.punctuation)) + return } + attributedText.append(NSAttributedString(string: "\n", attributes: palette.punctuation)) + for (index, value) in array.enumerated() { + attributedText.append( + NSAttributedString(string: indent + " ", attributes: palette.punctuation) + ) + append(value, to: attributedText, indent: indent + " ", palette: palette) + appendSeparator(to: attributedText, isLast: index == array.count - 1, palette: palette) + } + attributedText.append(NSAttributedString(string: indent + "]", attributes: palette.punctuation)) + } - appendFormatted(json: self) + /// The `,` between entries (omitted after the last) plus the line break. + private static func appendSeparator( + to attributedText: NSMutableAttributedString, + isLast: Bool, + palette: Palette + ) { + if !isLast { + attributedText.append(NSAttributedString(string: ",", attributes: palette.punctuation)) + } + attributedText.append(NSAttributedString(string: "\n", attributes: palette.punctuation)) + } - return attributedText + private static func appendScalar( + _ value: Any, + to attributedText: NSMutableAttributedString, + palette: Palette + ) { + if value is NSNull { + attributedText.append(NSAttributedString(string: "null", attributes: palette.null)) + } else if let boolValue = value as? Bool { + attributedText.append( + NSAttributedString(string: boolValue ? "true" : "false", attributes: palette.bool) + ) + } else if let stringValue = value as? String { + attributedText.append( + NSAttributedString(string: "\"\(stringValue)\"", attributes: palette.string) + ) + } else if let numberValue = value as? NSNumber { + // Distinguish Bool boxed as NSNumber. + let isBool = CFGetTypeID(numberValue) == CFBooleanGetTypeID() + attributedText.append( + NSAttributedString( + string: isBool ? (numberValue.boolValue ? "true" : "false") : "\(numberValue)", + attributes: isBool ? palette.bool : palette.number + ) + ) + } else { + attributedText.append( + NSAttributedString(string: "\"\(String(describing: value))\"", attributes: palette.string) + ) + } } } -// swiftlint:enable all +extension Dictionary where Key == String, Value == Any { + func formattedJSONLabel(fontSize: CGFloat = 14) -> NSAttributedString { + JSONPreview.attributedString(from: self, fontSize: fontSize) + } +} From 80944f8bf5cbb7d0e14b81104b8b96f00db50154 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 14 Sep 2026 16:14:21 +0300 Subject: [PATCH 48/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Send=20i?= =?UTF-8?q?nternal=20SDK=20events=20after=20offline=20sync=20finishes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishInternalSDKEvent sent straight to the socket whenever the channel was open, so an internal event could overtake an offline batch that was still syncing — user_token reached the backend ahead of the events it should have followed. Route every internal SDK event through cachedSDKEvents instead. That cache is drained from processEvent after restoreOfflineEventsIfNeeded, so a syncing batch always lands first; with nothing to sync the drain runs in the same pass and events still go out immediately. The drain sends directly now, since re-entering publishInternalSDKEvent would re-cache the event and spin. Blast radius: content and theme fetches now also wait for the current processing cycle's ack, not just for an offline sync. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 28 +++-- .../Analytics/AnalyticsPublisherTests.swift | 112 ++++++++++++++++++ Tests/UserpilotTests/MockUserpilot.swift | 19 +++ 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index e4067d29..ddaa2041 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -1156,25 +1156,39 @@ extension AnalyticsPublisher { */ func publishInternalSDKEvent(_ sdkEvent: SDKEvent) { tryCatch { + // Every internal SDK event takes the cached route, never a direct send. The + // cache is drained from `processEvent` *after* `restoreOfflineEventsIfNeeded()`, + // so a syncing offline batch always reaches the backend first - no gate and no + // "is a restore running" flag needed. With nothing to sync the drain happens in + // this same pass, so the event still goes out immediately. + cachedSDKEvents.append(sdkEvent) + guard canRequestEvent else { - cachedSDKEvents.append(sdkEvent) - // connect() gates itself on the socket state - always safe to call + // The cache is drained from `onSocketOpened` once the channel joins. openSocket() return } - socketManager.publish( - sdkEvent.eventName, - payload: sdkEvent.eventPayload - ) + processEvent() } } + /// Pushes an SDK event onto the socket. + /// + /// Separate from `publishInternalSDKEvent` so the drain below cannot re-enter the + /// caching route above and put the event straight back into the cache it came from. + private func sendSDKEvent(_ sdkEvent: SDKEvent) { + socketManager.publish( + sdkEvent.eventName, + payload: sdkEvent.eventPayload + ) + } + /** Sends any cached SDK events while the socket can accept them */ private func processSDKEvent() { tryCatch { while !cachedSDKEvents.isEmpty && canRequestEvent { let sdkEvent = cachedSDKEvents.removeFirst() - publishInternalSDKEvent(sdkEvent) + sendSDKEvent(sdkEvent) } } } diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 73407790..7d37e7a2 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -421,6 +421,118 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertFalse(didPublishEvent) } + // MARK: - Push Token vs Offline Sync + + private var pushTokenEventName: String { SDKEventsName.pushNotificationToken.rawValue } + + private func makePushTokenEvent() -> PushNotificationTokenEvent { + PushNotificationTokenEvent( + appToken: "NX-00000", + userId: "test-user-123", + token: "device-push-token" + ) + } + + func testPublishInternalSDKEvent_pushTokenWithStoredOfflineEvents_shouldWaitForSyncToFinish() { + // Arrange + userpilot.socketManager.isSocketOpened = true + userpilot.offlineEventsHandler.hasCachedEvents = true + userpilot.offlineEventsHandler.holdRestoreCompletion = true + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } + + // Act + analyticsPublisher.publishInternalSDKEvent(makePushTokenEvent()) + + // Assert - the offline batch owns the wire, the token does not overtake it + XCTAssertFalse(publishedEvents.contains(pushTokenEventName)) + + // Act - the batch lands and the cached SDK events drain + userpilot.offlineEventsHandler.finishRestore() + + // Assert + XCTAssertTrue(publishedEvents.contains(pushTokenEventName)) + } + + func testPublishInternalSDKEvent_pushTokenWithNoOfflineEvents_shouldStillSendInTheSamePass() { + // Arrange - taking the cached route must not delay the token when nothing is syncing + userpilot.socketManager.isSocketOpened = true + userpilot.offlineEventsHandler.hasCachedEvents = false + var publishedPayload: [String: Any]? + userpilot.socketManager.onPublish = { eventName, payload in + if eventName == self.pushTokenEventName { publishedPayload = payload } + } + + // Act + analyticsPublisher.publishInternalSDKEvent(makePushTokenEvent()) + + // Assert + XCTAssertEqual(publishedPayload?["token"] as? String, "device-push-token") + XCTAssertEqual(publishedPayload?["user_id"] as? String, "test-user-123") + XCTAssertEqual(publishedPayload?["app_token"] as? String, "NX-00000") + } + + func testPublishInternalSDKEvent_pushTokenWhileSocketClosed_shouldSendOnceSocketOpens() { + // Arrange + userpilot.socketManager.isSocketOpened = false + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } + analyticsPublisher.publishInternalSDKEvent(makePushTokenEvent()) + XCTAssertFalse(publishedEvents.contains(pushTokenEventName)) + + // Act + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + // Assert + XCTAssertTrue(publishedEvents.contains(pushTokenEventName)) + } + + func testPublishInternalSDKEvent_pushTokenDrain_shouldNotReCacheTheEvent() { + // Arrange - the drain must send directly, or it re-enters the cached route forever + userpilot.socketManager.isSocketOpened = true + var publishCount = 0 + userpilot.socketManager.onPublish = { eventName, _ in + if eventName == self.pushTokenEventName { publishCount += 1 } + } + + // Act + analyticsPublisher.publishInternalSDKEvent(makePushTokenEvent()) + analyticsPublisher.onSocketOpened() + + // Assert - sent exactly once, and the second drain found an empty cache + XCTAssertEqual(publishCount, 1) + } + + func testPublishInternalSDKEvent_contentEventWithStoredOfflineEvents_shouldWaitForSyncToFinish() { + // Arrange - every internal SDK event queues behind a syncing offline batch + userpilot.socketManager.isSocketOpened = true + userpilot.offlineEventsHandler.hasCachedEvents = true + userpilot.offlineEventsHandler.holdRestoreCompletion = true + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } + let contentEventName = SDKEventsName.fetchExperienceContent.rawValue + let contentEvent = MockSDKEvent(eventName: contentEventName) + + // Act + analyticsPublisher.publishInternalSDKEvent(contentEvent) + + // Assert + XCTAssertFalse(publishedEvents.contains(contentEventName)) + + // Act - the batch lands and the cached SDK events drain + userpilot.offlineEventsHandler.finishRestore() + + // Assert + XCTAssertTrue(publishedEvents.contains(contentEventName)) + } + func testIsExperienceSeen_shouldUseSeenSetForContentType() throws { // Arrange — the screen session (which owns the seen sets) is only created once the // screen event actually goes out, which requires an open socket. diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index e51edc10..482ecd6f 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -449,12 +449,31 @@ class MockOfflineEventsHandler: OfflineEventsHandling { var didRestoreEvents = false var didClearLocalEvents = false + /// When true, `restoreEventsFromLocalStorage` holds its completion so a test can + /// observe the window while the batch is still on the wire. Release it with + /// `finishRestore()`, which stands in for the batch ACK. + var holdRestoreCompletion = false + private var heldRestoreCompletion: (() -> Void)? + func saveEventToLocalStorage(event: Event, clearStoredEventsFirst: Bool) { savedEvents.append((event, clearStoredEventsFirst)) } func restoreEventsFromLocalStorage(completion: (() -> Void)?) { didRestoreEvents = true + // The real handler deletes the rows as it reads them + hasCachedEvents = false + guard holdRestoreCompletion else { + completion?() + return + } + heldRestoreCompletion = completion + } + + /// Resolves a held restore the way the batch ACK does in `onSocketEventSent`. + func finishRestore() { + let completion = heldRestoreCompletion + heldRestoreCompletion = nil completion?() } From 76ae189055f761cfc4877bddaddd5743727c95d9 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 11:36:28 +0300 Subject: [PATCH 49/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Guard=20?= =?UTF-8?q?the=20SDK=20event=20cache=20with=20a=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cachedSDKEvents is a plain Swift array appended from the caller's thread while processEvent drains it from another — including the utility watchdog queue. The single-flight gate does not cover the append, which happens before the cycle is claimed. Move the storage behind an NSLock reached only through locked accessors. The drain now pops in one step, removing the isEmpty-then-removeFirst check-then-act against a concurrent clear. No public API change. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index ddaa2041..003c29f6 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -165,10 +165,20 @@ internal class AnalyticsPublisher { // MARK: - Queues & State + /// Guards `storedCachedSDKEvents`. + /// + /// The cache is appended from the caller's thread (main, for the experience view models) but + /// drained from whichever thread runs the processing cycle — including `watchdogQueue`. The + /// single-flight gate does not cover it: `publishInternalSDKEvent` appends *before* claiming + /// the cycle, so an append can overlap a drain on a plain array. + private let cachedSDKEventsLock = NSLock() + /// Internal SDK events cached while the socket is reconnecting. Responses are /// delivered to their senders via the multicast subscription, identified by /// `message.resolvedEvent` — no per-event callback is kept. - private var cachedSDKEvents = [SDKEvent]() + /// + /// Reached only through the locked accessors below, never directly. + private var storedCachedSDKEvents = [SDKEvent]() /// Event throttling mechanism to prevent spam private lazy var eventThrottle = EventThrottle(throttleDuration: 1.0) @@ -1096,7 +1106,7 @@ private extension AnalyticsPublisher { guard clearCachedIdentifyEvent else { return } eventsQueue.clear() initialQueue.clear() - cachedSDKEvents.removeAll() + clearCachedSDKEvents() self.clearCachedIdentifyEvent() } @@ -1161,7 +1171,7 @@ extension AnalyticsPublisher { // so a syncing offline batch always reaches the backend first - no gate and no // "is a restore running" flag needed. With nothing to sync the drain happens in // this same pass, so the event still goes out immediately. - cachedSDKEvents.append(sdkEvent) + cacheSDKEvent(sdkEvent) guard canRequestEvent else { // The cache is drained from `onSocketOpened` once the channel joins. @@ -1183,11 +1193,43 @@ extension AnalyticsPublisher { ) } + /// Appends an internal SDK event to the cache. + private func cacheSDKEvent(_ sdkEvent: SDKEvent) { + cachedSDKEventsLock.lock() + defer { cachedSDKEventsLock.unlock() } + storedCachedSDKEvents.append(sdkEvent) + } + + /// Pops the oldest cached SDK event, or `nil` when the cache is empty. + /// + /// One locked step: an `isEmpty` check followed by a separate `removeFirst()` would still be + /// a check-then-act against a concurrent `clearCachedSDKEvents()`. + private func dequeueCachedSDKEvent() -> SDKEvent? { + cachedSDKEventsLock.lock() + defer { cachedSDKEventsLock.unlock() } + return storedCachedSDKEvents.isEmpty ? nil : storedCachedSDKEvents.removeFirst() + } + + /// Whether an internal SDK event is still waiting to be sent. + private var hasCachedSDKEvents: Bool { + cachedSDKEventsLock.lock() + defer { cachedSDKEventsLock.unlock() } + return !storedCachedSDKEvents.isEmpty + } + + /// Drops every cached SDK event. + private func clearCachedSDKEvents() { + cachedSDKEventsLock.lock() + defer { cachedSDKEventsLock.unlock() } + storedCachedSDKEvents.removeAll() + } + /** Sends any cached SDK events while the socket can accept them */ private func processSDKEvent() { tryCatch { - while !cachedSDKEvents.isEmpty && canRequestEvent { - let sdkEvent = cachedSDKEvents.removeFirst() + // Socket readiness is checked before the pop, never after: popping an event the + // socket cannot take would drop it. + while canRequestEvent, let sdkEvent = dequeueCachedSDKEvent() { sendSDKEvent(sdkEvent) } } From 5bd6d3b687b777a601f912aedd961de5fae82aab Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 11:36:47 +0300 Subject: [PATCH 50/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Drain=20?= =?UTF-8?q?SDK=20events=20cached=20after=20the=20queue=20empties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleEmptyEventQueue is the one processing-gate release that does not re-drive processEvent. An internal SDK event cached while the gate was held therefore sat until the next analytics event or reconnect, so a content or theme fetch caught there never rendered its experience. Release through releaseAfterEmptyQueue, which re-enters processEvent only when the cache is non-empty. The re-driven cycle drains it and returns with the cache empty, so it settles. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 003c29f6..dd4876d8 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -586,16 +586,29 @@ extension AnalyticsPublisher: AnalyticsPublishing { /// Releases processing or sends the background fake reload when no live event is queued. private func handleEmptyEventQueue() { guard userSessionStateMachine.getCurrentState() == .backgroundToInitialScreen else { - resetProcessingEventStatus() + releaseAfterEmptyQueue() return } userSessionStateMachine.markNormal() if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) { - resetProcessingEventStatus() + releaseAfterEmptyQueue() } } + /// Releases the gate, then re-drives processing if an internal SDK event reached the cache + /// after this cycle already drained it. + /// + /// Every other release path ends with `processEvent()`; this one is the exception, so an + /// event `publishInternalSDKEvent` cached while the gate was held would sit there until the + /// next analytics event or reconnect — a content or theme fetch stuck that way never renders. + /// The re-driven cycle drains the cache and comes back here with it empty, so this settles. + private func releaseAfterEmptyQueue() { + resetProcessingEventStatus() + guard hasCachedSDKEvents else { return } + processEvent() + } + /// Publishes the queued event using the matching event-specific path. private func publishQueuedEvent(_ event: Event) -> Bool { switch event.type { From 6a43c6c7dabf3f6460f65bde541b9836bd5d631a Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 13:15:06 +0300 Subject: [PATCH 51/95] =?UTF-8?q?=E2=9C=85=20test(Analytics):=20Cover=20th?= =?UTF-8?q?e=20cached=20SDK=20event=20drain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two fixes already on this branch - the locked cache and the re-drive on an empty queue - shipped without tests. Add the two properties that must hold: cached SDK events reach the socket in publish order, and a socket dropping mid-drain leaves the rest cached instead of swallowing them. The second fails against a drain that takes an event before checking socket readiness, which is how it was verified. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisherTests.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 7d37e7a2..6f428545 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -533,6 +533,60 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(publishedEvents.contains(contentEventName)) } + // MARK: - Cached SDK Event Drain + + func testProcessSDKEvent_multipleCachedEvents_shouldDrainInPublishOrder() { + // Arrange - the cache is a FIFO queue: events must reach the backend in the order + // they were published, whatever the storage behind it. + userpilot.socketManager.isSocketOpened = false + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } + ["sdk-event-1", "sdk-event-2", "sdk-event-3"].forEach { + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: $0)) + } + XCTAssertTrue(publishedEvents.isEmpty) + + // Act + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + // Assert + XCTAssertEqual(publishedEvents, ["sdk-event-1", "sdk-event-2", "sdk-event-3"]) + } + + func testProcessSDKEvent_socketClosesMidDrain_shouldKeepTheRemainingEventCached() { + // Arrange - the drain checks socket readiness *before* taking an event, so a socket + // that drops mid-drain leaves the rest cached instead of swallowing them. Popping + // first and discovering the closed socket afterwards would lose the event. + userpilot.socketManager.isSocketOpened = false + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { [weak userpilot] eventName, _ in + publishedEvents.append(eventName) + // The socket drops as soon as the first event goes out + userpilot?.socketManager.isSocketOpened = false + } + ["sdk-event-1", "sdk-event-2"].forEach { + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: $0)) + } + + // Act + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + // Assert - only the first went out, and the re-drive on the empty queue settled + // instead of spinning on the still-cached second event + XCTAssertEqual(publishedEvents, ["sdk-event-1"]) + + // Act - the socket comes back + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + // Assert - the second event survived the closed window + XCTAssertEqual(publishedEvents, ["sdk-event-1", "sdk-event-2"]) + } + func testIsExperienceSeen_shouldUseSeenSetForContentType() throws { // Arrange — the screen session (which owns the seen sets) is only created once the // screen event actually goes out, which requires an open socket. From 6fb24c0a1a23e4aaf08852173a537ae926ecb7ac Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 13:15:07 +0300 Subject: [PATCH 52/95] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Hold=20c?= =?UTF-8?q?ached=20SDK=20events=20in=20a=20generic=20EventQueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventQueue already had the thread-safe atomic dequeue the SDK event cache needed, but it was hardcoded to Event, so AnalyticsPublisher hand-rolled an NSLock and four accessors instead. Parameterise EventQueue on its element and point cachedSDKEvents at it. No locking logic changed - the class keeps its concurrent queue and barrier writes, only the element type is new. Removes 44 lines of hand-written synchronisation. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 62 ++++--------------- .../Utilities/Threading/EventQueue.swift | 22 +++---- .../Utilities/Threading/EventQueueTests.swift | 4 +- 3 files changed, 26 insertions(+), 62 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index dd4876d8..3b9213eb 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -165,20 +165,15 @@ internal class AnalyticsPublisher { // MARK: - Queues & State - /// Guards `storedCachedSDKEvents`. - /// - /// The cache is appended from the caller's thread (main, for the experience view models) but - /// drained from whichever thread runs the processing cycle — including `watchdogQueue`. The - /// single-flight gate does not cover it: `publishInternalSDKEvent` appends *before* claiming - /// the cycle, so an append can overlap a drain on a plain array. - private let cachedSDKEventsLock = NSLock() - /// Internal SDK events cached while the socket is reconnecting. Responses are /// delivered to their senders via the multicast subscription, identified by /// `message.resolvedEvent` — no per-event callback is kept. /// - /// Reached only through the locked accessors below, never directly. - private var storedCachedSDKEvents = [SDKEvent]() + /// `EventQueue` and not a plain array: the cache is appended from the caller's thread (main, + /// for the experience view models) but drained from whichever thread runs the processing + /// cycle — including `watchdogQueue`. The single-flight gate does not cover it, because + /// `publishInternalSDKEvent` appends *before* claiming the cycle. + private lazy var cachedSDKEvents = EventQueue() /// Event throttling mechanism to prevent spam private lazy var eventThrottle = EventThrottle(throttleDuration: 1.0) @@ -194,10 +189,10 @@ internal class AnalyticsPublisher { /// Serialized live analytics event queue. The head stays enqueued until its /// socket ACK arrives (peek-then-dequeue-on-ACK), so a watchdog restart can /// re-attempt the same head without duplicating an acknowledged send. - private lazy var eventsQueue = EventQueue() + private lazy var eventsQueue = EventQueue() /// Holds events until the network monitor produces its first reliable state. - private lazy var initialQueue = EventQueue() + private lazy var initialQueue = EventQueue() /// Single-flight gate: exactly one processing cycle may be in flight. private lazy var isProcessingEvent: AtomicReference = AtomicReference(false) @@ -605,7 +600,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { /// The re-driven cycle drains the cache and comes back here with it empty, so this settles. private func releaseAfterEmptyQueue() { resetProcessingEventStatus() - guard hasCachedSDKEvents else { return } + guard !cachedSDKEvents.isEmpty() else { return } processEvent() } @@ -1119,7 +1114,7 @@ private extension AnalyticsPublisher { guard clearCachedIdentifyEvent else { return } eventsQueue.clear() initialQueue.clear() - clearCachedSDKEvents() + cachedSDKEvents.clear() self.clearCachedIdentifyEvent() } @@ -1184,7 +1179,7 @@ extension AnalyticsPublisher { // so a syncing offline batch always reaches the backend first - no gate and no // "is a restore running" flag needed. With nothing to sync the drain happens in // this same pass, so the event still goes out immediately. - cacheSDKEvent(sdkEvent) + cachedSDKEvents.enqueue(sdkEvent) guard canRequestEvent else { // The cache is drained from `onSocketOpened` once the channel joins. @@ -1206,43 +1201,12 @@ extension AnalyticsPublisher { ) } - /// Appends an internal SDK event to the cache. - private func cacheSDKEvent(_ sdkEvent: SDKEvent) { - cachedSDKEventsLock.lock() - defer { cachedSDKEventsLock.unlock() } - storedCachedSDKEvents.append(sdkEvent) - } - - /// Pops the oldest cached SDK event, or `nil` when the cache is empty. - /// - /// One locked step: an `isEmpty` check followed by a separate `removeFirst()` would still be - /// a check-then-act against a concurrent `clearCachedSDKEvents()`. - private func dequeueCachedSDKEvent() -> SDKEvent? { - cachedSDKEventsLock.lock() - defer { cachedSDKEventsLock.unlock() } - return storedCachedSDKEvents.isEmpty ? nil : storedCachedSDKEvents.removeFirst() - } - - /// Whether an internal SDK event is still waiting to be sent. - private var hasCachedSDKEvents: Bool { - cachedSDKEventsLock.lock() - defer { cachedSDKEventsLock.unlock() } - return !storedCachedSDKEvents.isEmpty - } - - /// Drops every cached SDK event. - private func clearCachedSDKEvents() { - cachedSDKEventsLock.lock() - defer { cachedSDKEventsLock.unlock() } - storedCachedSDKEvents.removeAll() - } - /** Sends any cached SDK events while the socket can accept them */ private func processSDKEvent() { tryCatch { - // Socket readiness is checked before the pop, never after: popping an event the - // socket cannot take would drop it. - while canRequestEvent, let sdkEvent = dequeueCachedSDKEvent() { + // Socket readiness is checked before the dequeue, never after: taking an event the + // socket cannot send would drop it. + while canRequestEvent, let sdkEvent = cachedSDKEvents.dequeue() { sendSDKEvent(sdkEvent) } } diff --git a/Sources/Userpilot/Utilities/Threading/EventQueue.swift b/Sources/Userpilot/Utilities/Threading/EventQueue.swift index 7e328ee5..a8c40294 100644 --- a/Sources/Userpilot/Utilities/Threading/EventQueue.swift +++ b/Sources/Userpilot/Utilities/Threading/EventQueue.swift @@ -6,13 +6,13 @@ // Copyright © 2025 Userpilot. All rights reserved. // // [Brief Description] -// Thread-safe event queue with optional prioritization for internal events. +// Thread-safe FIFO queue with optional prioritization for internal events. // import Foundation -internal class EventQueue { - private var queue: [Event] = [] +internal class EventQueue { + private var queue: [Element] = [] private let dispatchQueue = DispatchQueue( label: Constants.DispatchQueues.eventQueue, attributes: .concurrent @@ -20,7 +20,7 @@ internal class EventQueue { // MARK: - Write operations (exclusive) - public func enqueue(_ event: Event, isInternalEvent: Bool = false) { + public func enqueue(_ event: Element, isInternalEvent: Bool = false) { dispatchQueue.sync(flags: .barrier) { if isInternalEvent { self.queue.insert(event, at: 0) @@ -31,7 +31,7 @@ internal class EventQueue { } @discardableResult - public func dequeue() -> Event? { + public func dequeue() -> Element? { dispatchQueue.sync(flags: .barrier) { if !queue.isEmpty { return queue.removeFirst() @@ -56,7 +56,7 @@ internal class EventQueue { // MARK: - Read operations (shared reads) - public func peek() -> Event? { + public func peek() -> Element? { dispatchQueue.sync { queue.first } @@ -74,13 +74,13 @@ internal class EventQueue { } } - public func getAll() -> [Event] { + public func getAll() -> [Element] { dispatchQueue.sync { queue } } - public func getAndClear() -> [Event] { + public func getAndClear() -> [Element] { dispatchQueue.sync(flags: .barrier) { let events = queue queue.removeAll() @@ -88,19 +88,19 @@ internal class EventQueue { } } - public func contains(_ event: Event, where compare: (Event, Event) -> Bool) -> Bool { + public func contains(_ event: Element, where compare: (Element, Element) -> Bool) -> Bool { dispatchQueue.sync { queue.contains(where: { compare($0, event) }) } } - public func find(where predicate: (Event) -> Bool) -> Event? { + public func find(where predicate: (Element) -> Bool) -> Element? { dispatchQueue.sync { queue.first(where: predicate) } } - public func getFirst() -> Event? { + public func getFirst() -> Element? { dispatchQueue.sync { queue.first } diff --git a/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift b/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift index 7a2f17e1..9503ad50 100644 --- a/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift +++ b/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift @@ -13,11 +13,11 @@ import XCTest // swiftlint:disable all class EventQueueTests: XCTestCase { - var eventQueue: EventQueue! + var eventQueue: EventQueue! override func setUp() { super.setUp() - eventQueue = EventQueue() + eventQueue = EventQueue() } override func tearDown() { From 399d478bdcaaa43e4c09de622b73d0b729ffd738 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 13:15:23 +0300 Subject: [PATCH 53/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Utilities):=20Guard=20?= =?UTF-8?q?the=20multicast=20delegate=20list=20with=20a=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MulticastDelegate held an NSHashTable with no synchronisation, while invoke runs on the socket transport and push-resolution threads - and add can run on the socket thread too, since resolving a lazily built subscriber from inside a socket callback registers it right there. NSHashTable is not thread-safe. Android guards the same class, its comments recording a ConcurrentModificationException from this shape. Snapshot the delegates under the lock and invoke outside it: NSLock is not recursive, so dispatching callbacks while holding it deadlocks the socket thread - the added reentrancy test hangs against that version. Co-Authored-By: Claude Opus 5 (1M context) --- .../Utilities/Helper/MulticastDelegate.swift | 34 +++++++++++++++---- .../Helper/MulticastDelegateTests.swift | 29 ++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift b/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift index 15a46332..7bcb44f5 100644 --- a/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift +++ b/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift @@ -54,6 +54,15 @@ internal final class MulticastDelegate { // MARK: - Properties + /// Guards every access to `delegates`. Mutations and snapshot reads happen under this lock; + /// delegate callbacks run OUTSIDE it (see `invoke`). + /// + /// `NSHashTable` is not thread-safe, and this table is reached from several queues: + /// `invoke` runs on the socket transport and push-resolution threads, while `add` can run on + /// the socket thread too — resolving a lazily-built subscriber from inside a socket callback + /// registers it right there. + private let lock = NSLock() + /// A set of weak references to the delegates, preventing retain cycles. /// `NSHashTable.weakObjects()` ensures delegates are automatically removed when deallocated. private let delegates: NSHashTable = NSHashTable.weakObjects() @@ -63,26 +72,37 @@ internal final class MulticastDelegate { /// Adds a new delegate to the multicast list. /// - Parameter delegate: The delegate to be added. func add(_ delegate: T) { - // Add the delegate only if it's not already in the list. - if !delegates.contains(delegate as AnyObject) { - delegates.add(delegate as AnyObject) + lock.withLock { + // Add the delegate only if it's not already in the list. + if !delegates.contains(delegate as AnyObject) { + delegates.add(delegate as AnyObject) + } } } /// Removes a specific delegate from the multicast list. /// - Parameter delegateToRemove: The delegate to be removed. func remove(_ delegateToRemove: T) { - // Iterating in reverse to safely remove elements while iterating. - for delegate in delegates.allObjects.reversed() where delegate === delegateToRemove as AnyObject { - delegates.remove(delegate) + lock.withLock { + // `allObjects` is already a snapshot, so removing while walking it is safe. + for delegate in delegates.allObjects.reversed() where delegate === delegateToRemove as AnyObject { + delegates.remove(delegate) + } } } /// Invokes a closure on all the delegates in the multicast list. + /// + /// Iterates a snapshot taken under `lock`, so a delegate whose callback registers or removes + /// another delegate cannot mutate the table mid-iteration. Callbacks run outside the lock: + /// `NSLock` is not recursive, so a subscriber reaching back into `registerCallback` from its + /// own callback would otherwise deadlock. + /// /// - Parameter invocation: A closure that takes a delegate and performs an action. func invoke(_ invocation: (T) -> Void) { + let snapshot = lock.withLock { delegates.allObjects.reversed() } // Ensure that force-casting is safe by iterating through the delegates. - for delegate in delegates.allObjects.reversed() { + for delegate in snapshot { guard let castedDelegate = delegate as? T else { continue } invocation(castedDelegate) } diff --git a/Tests/UserpilotTests/Utilities/Helper/MulticastDelegateTests.swift b/Tests/UserpilotTests/Utilities/Helper/MulticastDelegateTests.swift index 2087db99..7fbe8dd1 100644 --- a/Tests/UserpilotTests/Utilities/Helper/MulticastDelegateTests.swift +++ b/Tests/UserpilotTests/Utilities/Helper/MulticastDelegateTests.swift @@ -32,6 +32,35 @@ final class MulticastDelegateTests: XCTestCase { XCTAssertEqual(second.calls, 2) } + /// A delegate that reaches back into the multicast from inside its own callback — the shape of + /// `AnalyticsPublisher` resolving a lazily-built subscriber while a socket callback is being + /// dispatched, which registers it on the socket thread mid-`invoke`. + private final class ReentrantDelegate { + var calls = 0 + var onCall: (() -> Void)? + } + + func testInvokeLetsADelegateRegisterAnotherFromItsOwnCallback() { + let multicast = MulticastDelegate() + let first = ReentrantDelegate() + let late = ReentrantDelegate() + multicast.add(first) + first.onCall = { multicast.add(late) } + + // Hangs if callbacks are dispatched while the lock is held (NSLock is not recursive), + // and would mutate mid-iteration if the walk used the live table instead of a snapshot. + multicast.invoke { $0.calls += 1; $0.onCall?() } + + // The late registration lands, but not in the pass that was already snapshotted. + XCTAssertEqual(first.calls, 1) + XCTAssertEqual(late.calls, 0) + + multicast.invoke { $0.calls += 1; $0.onCall?() } + + XCTAssertEqual(first.calls, 2) + XCTAssertEqual(late.calls, 1) + } + func testMulticastDoesNotRetainDelegates() { let multicast = MulticastDelegate() var delegate: Delegate? = Delegate() From 9ed4cc5ad93435217a0ba6382afb3c2922100e94 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 13:15:24 +0300 Subject: [PATCH 54/95] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(AutoCapture?= =?UTF-8?q?):=20Use=20the=20SDK's=20own=20locking=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three flags were guarded by hand-written lock/unlock pairs. The stop/resume gate and the one-shot warning flag are single values, so they become AtomicReference - and the warning claim becomes the getAndSet it always was, making "exactly one caller logs" structural. The interaction cache takes its lock through withLock, which Foundation already vends at the deployment target. Behaviour is unchanged: AtomicReference is NSLock-backed, and every critical section keeps exactly the statements it had. Co-Authored-By: Claude Opus 5 (1M context) --- .../AutoCapture/AutoCaptureCoordinater.swift | 17 +++++------------ .../AutoCapture/InstanceResolver.swift | 13 ++++--------- .../Utils/InteractionEventCache.swift | 14 +++++--------- 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift b/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift index 77103c92..cf01400d 100644 --- a/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift +++ b/Sources/Userpilot/AutoCapture/AutoCaptureCoordinater.swift @@ -96,9 +96,8 @@ internal class AutoCaptureCoordinater { /// Per-instance stop/resume gate. When `true`, this instance records no screen /// or interaction events until `resumeAutoCapture()` is called; other instances - /// are unaffected. Guarded by `stopResumeLock`. - private let stopResumeLock = NSLock() - private var _isStopped = false + /// are unaffected. + private let stoppedState = AtomicReference(false) // MARK: - Computed Helpers @@ -277,21 +276,15 @@ extension AutoCaptureCoordinater: AutoCaptureCoordinating { /// `true` while this instance's capture is paused. Thread-safe. var isStopped: Bool { - stopResumeLock.lock() - defer { stopResumeLock.unlock() } - return _isStopped + stoppedState.value } func stopAutoCapture() { - stopResumeLock.lock() - _isStopped = true - stopResumeLock.unlock() + stoppedState.value = true } func resumeAutoCapture() { - stopResumeLock.lock() - _isStopped = false - stopResumeLock.unlock() + stoppedState.value = false } } diff --git a/Sources/Userpilot/AutoCapture/InstanceResolver.swift b/Sources/Userpilot/AutoCapture/InstanceResolver.swift index 67c4e83b..f968f52e 100644 --- a/Sources/Userpilot/AutoCapture/InstanceResolver.swift +++ b/Sources/Userpilot/AutoCapture/InstanceResolver.swift @@ -36,13 +36,11 @@ internal final class InstanceResolver { // MARK: - One-shot warning state - /// Lock guarding `didLogNoOwnerDrop`. Plain `NSLock` is enough — this is only - /// touched on the rare warning path. - private let warningLock = NSLock() - /// `true` once the "no owner and no default fallback" warning has been emitted. /// Logging once per process keeps the system log clean while still surfacing the issue. - private var didLogNoOwnerDrop = false + /// + /// Claimed with `getAndSet(true)`, so exactly one caller ever sees `false`. + private let didLogNoOwnerDrop = AtomicReference(false) // MARK: - Target Resolution (public to module) @@ -275,10 +273,7 @@ private extension InstanceResolver { func defaultOrNil() -> Userpilot? { if let target = registry.default { return target } - warningLock.lock() - let alreadyLogged = didLogNoOwnerDrop - didLogNoOwnerDrop = true - warningLock.unlock() + let alreadyLogged = didLogNoOwnerDrop.getAndSet(true) if !alreadyLogged { // No registered default means no logger to call. Use the system log directly. diff --git a/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift b/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift index 93b98674..fe6efd5e 100644 --- a/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift +++ b/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift @@ -50,9 +50,9 @@ internal enum InteractionEventCache { guard Userpilot.isInitialized else { return } // swiftlint:disable:next multiple_closures_with_trailing_closure superfluous_disable_command if let length = envelope.textLengthForDedupe { - lastDeliveredLock.lock() - lastDeliveredTextLengthByDebounceKey[envelope.debounceKey] = length - lastDeliveredLock.unlock() + lastDeliveredLock.withLock { + lastDeliveredTextLengthByDebounceKey[envelope.debounceKey] = length + } } // Re-resolve the owning instance at delivery time so it always reflects the // current Registry state. If the source view has been deallocated, fall back @@ -73,9 +73,7 @@ internal enum InteractionEventCache { let key = debounceKey(for: view) if let length = textLengthForDedupe { - lastDeliveredLock.lock() - let last = lastDeliveredTextLengthByDebounceKey[key] - lastDeliveredLock.unlock() + let last = lastDeliveredLock.withLock { lastDeliveredTextLengthByDebounceKey[key] } guard last != length else { return } } @@ -90,9 +88,7 @@ internal enum InteractionEventCache { static func flushAll() { debouncer.cancelAll() - lastDeliveredLock.lock() - lastDeliveredTextLengthByDebounceKey.removeAll() - lastDeliveredLock.unlock() + lastDeliveredLock.withLock { lastDeliveredTextLengthByDebounceKey.removeAll() } } /// Publishes any debounced interaction that is still waiting for its quiet period. From 02d9e8ac6c86efb379a9d0e9b7b1956ecae2461a Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 13:15:24 +0300 Subject: [PATCH 55/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Utilities):=20Always?= =?UTF-8?q?=20cancel=20a=20pending=20delay=20through=20its=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelDelay short-circuited on hasPendingAction(), which read currentWorkItem from the caller's thread while delayAction assigns it inside the serial queue. A cancel arriving just after a schedule read "nothing pending", skipped the cancel, and the action fired anyway - an experience shown after the reset that was meant to stop it. Drop the short-circuit so the queue that owns the property orders the cancel behind the assignment, and read the flag on that queue. The new test fails 3/3 against the old version. Co-Authored-By: Claude Opus 5 (1M context) --- .../Utilities/Threading/DelayUtils.swift | 18 +++++++++++++++--- .../Utilities/Threading/DelayUtilsTests.swift | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Sources/Userpilot/Utilities/Threading/DelayUtils.swift b/Sources/Userpilot/Utilities/Threading/DelayUtils.swift index 1e9fc83a..63ecab77 100644 --- a/Sources/Userpilot/Utilities/Threading/DelayUtils.swift +++ b/Sources/Userpilot/Utilities/Threading/DelayUtils.swift @@ -63,9 +63,17 @@ internal class DelayUtils { /** Cancels any currently scheduled delayed action. + + Always hops to `queue` — it must not short-circuit on a pending-action check made from the + caller's thread. `delayAction` assigns `currentWorkItem` *inside* `queue`, so between it + returning and its block running there is a window where a timer is on its way but the + property is still unset. A check there reads "nothing pending", skips the cancel, and the + action fires anyway — an experience shown after the reset that was meant to stop it. + Going through the serial queue orders this cancel behind that assignment instead. + + A no-op when nothing is scheduled, so no guard is needed. */ func cancelDelay() { - if !hasPendingAction() { return } queue.async { [weak self] in self?.currentWorkItem?.cancel() self?.currentWorkItem = nil @@ -74,11 +82,15 @@ internal class DelayUtils { /** Checks if there's a currently scheduled action that hasn't been executed yet. - + + Reads on `queue`, which owns `currentWorkItem`; never call this from within `queue`. + - Returns: `true` if there's a pending action, `false` otherwise */ func hasPendingAction() -> Bool { - return currentWorkItem != nil && currentWorkItem?.isCancelled == false + return queue.sync { + currentWorkItem != nil && currentWorkItem?.isCancelled == false + } } /** diff --git a/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift b/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift index abc17b2b..335ef801 100644 --- a/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift +++ b/Tests/UserpilotTests/Utilities/Threading/DelayUtilsTests.swift @@ -70,6 +70,25 @@ final class DelayUtilsTests: XCTestCase { wait(for: [executionExpectation], timeout: 0.3) } + /// Cancelling in the window right after `delayAction` returns must still stop the action. + /// + /// `delayAction` assigns `currentWorkItem` inside its own queue, so at this point the property + /// is almost certainly still unset. A `cancelDelay()` that short-circuits on a check made from + /// this thread reads "nothing pending", skips the cancel, and the action fires anyway — the + /// experience-after-reset case. + func testCancelDelayImmediatelyAfterSchedulingPreventsExecution() { + let executionExpectation = expectation(description: "Action should not execute") + executionExpectation.isInverted = true + + delayUtils.delayAction(delayTime: 0.1) { + executionExpectation.fulfill() + } + // Deliberately no wait: this is the window the bug lived in. + delayUtils.cancelDelay() + + wait(for: [executionExpectation], timeout: 0.3) + } + /// Verifies that `hasPendingAction()` returns true when a task is scheduled. func testHasPendingActionReturnsTrueWhenTaskIsScheduled() { let expectation = self.expectation(description: "Delay scheduled") From 6c998635ad48e54ff7ea1c0fe343aa7358054f22 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 15 Sep 2026 13:45:44 +0300 Subject: [PATCH 56/95] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(Utilities):?= =?UTF-8?q?=20Keep=20the=20multicast=20lock=20out=20of=20invoke's=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invoke held the snapshot and the callback loop in one body, so the lock sat two lines from the invocation - one edit away from wrapping it. NSLock is not recursive, so that shape deadlocks the socket thread. Move the only read of the table into currentDelegates(), leaving invoke with no lock in scope at all. The AnyObject bridging casts also move out of the critical sections. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- .../Utilities/Helper/MulticastDelegate.swift | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift b/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift index 7bcb44f5..b4b8a4cc 100644 --- a/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift +++ b/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift @@ -50,61 +50,52 @@ internal struct Multicast { } /// A class that manages multiple delegates using weak references to prevent retain cycles. +/// +/// Thread-safe. `NSLock` is not recursive, so callbacks must run outside it: `currentDelegates()` +/// is the only reader of the storage and `invoke` takes no lock. A subscriber registering from +/// inside its own callback — which happens on the socket thread — would otherwise deadlock. internal final class MulticastDelegate { // MARK: - Properties - /// Guards every access to `delegates`. Mutations and snapshot reads happen under this lock; - /// delegate callbacks run OUTSIDE it (see `invoke`). - /// - /// `NSHashTable` is not thread-safe, and this table is reached from several queues: - /// `invoke` runs on the socket transport and push-resolution threads, while `add` can run on - /// the socket thread too — resolving a lazily-built subscriber from inside a socket callback - /// registers it right there. + /// Guards `delegates`. `NSHashTable` is not thread-safe and the table is reached from the + /// socket transport and push-resolution threads as well as the caller's. private let lock = NSLock() - /// A set of weak references to the delegates, preventing retain cycles. - /// `NSHashTable.weakObjects()` ensures delegates are automatically removed when deallocated. + /// Weak references to the delegates, so registering never keeps a subscriber alive. private let delegates: NSHashTable = NSHashTable.weakObjects() // MARK: - Methods - /// Adds a new delegate to the multicast list. - /// - Parameter delegate: The delegate to be added. + /// Adds a delegate, ignoring duplicates. func add(_ delegate: T) { + let object = delegate as AnyObject lock.withLock { - // Add the delegate only if it's not already in the list. - if !delegates.contains(delegate as AnyObject) { - delegates.add(delegate as AnyObject) - } + guard !delegates.contains(object) else { return } + delegates.add(object) } } - /// Removes a specific delegate from the multicast list. - /// - Parameter delegateToRemove: The delegate to be removed. + /// Removes a delegate. func remove(_ delegateToRemove: T) { + let object = delegateToRemove as AnyObject lock.withLock { - // `allObjects` is already a snapshot, so removing while walking it is safe. - for delegate in delegates.allObjects.reversed() where delegate === delegateToRemove as AnyObject { + for delegate in delegates.allObjects where delegate === object { delegates.remove(delegate) } } } - /// Invokes a closure on all the delegates in the multicast list. - /// - /// Iterates a snapshot taken under `lock`, so a delegate whose callback registers or removes - /// another delegate cannot mutate the table mid-iteration. Callbacks run outside the lock: - /// `NSLock` is not recursive, so a subscriber reaching back into `registerCallback` from its - /// own callback would otherwise deadlock. - /// - /// - Parameter invocation: A closure that takes a delegate and performs an action. + /// Invokes `invocation` on every live delegate. Holds no lock — see the note on the type. func invoke(_ invocation: (T) -> Void) { - let snapshot = lock.withLock { delegates.allObjects.reversed() } - // Ensure that force-casting is safe by iterating through the delegates. - for delegate in snapshot { - guard let castedDelegate = delegate as? T else { continue } - invocation(castedDelegate) + currentDelegates().forEach(invocation) + } + + /// The only read of `delegates`. The snapshot holds each delegate strongly while the caller + /// iterates it, so a subscriber cannot deallocate mid-callback. + private func currentDelegates() -> [T] { + lock.withLock { + delegates.allObjects.reversed().compactMap { $0 as? T } } } } From 6e7350eb49a8a0ca02ec0bcc6cc7d95693c63951 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 16 Sep 2026 11:09:51 +0300 Subject: [PATCH 57/95] chore: ignore local plan/ folder Co-authored-by: Cursor --- .gitignore | 1 + .../UserpilotSample.xcodeproj/project.pbxproj | 146 +++++++++--------- 2 files changed, 74 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 4ff360aa..0bfe9ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ CLAUDE.md # Locally generated agent specs/plans — not project documentation docs/superpowers/ +plan/ diff --git a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj index 16d446f4..3d634658 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj +++ b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj @@ -21,12 +21,6 @@ AB46B0A42D07134B0054D249 /* Userpilot in Frameworks */ = {isa = PBXBuildFile; productRef = AB46B0A32D07134B0054D249 /* Userpilot */; }; AB4721352DBA1E8800B92EE2 /* AppDelegate+PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4721342DBA1E7900B92EE2 /* AppDelegate+PushNotification.swift */; }; AB585F662CD79D8C00E2A66D /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB585F652CD79D8C00E2A66D /* StorageManager.swift */; }; - FACF000000000000000000B1 /* ConfigViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A1 /* ConfigViewController.swift */; }; - FACF000000000000000000B2 /* ConfigFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A2 /* ConfigFlag.swift */; }; - FACF000000000000000000B3 /* ConfigFlagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */; }; - UP9B74448B7CF24C7EBF611D /* DebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UP05E7237BA6114ABE8008E3 /* DebugViewController.swift */; }; - UPDE5091EFDF0F402DB75A79 /* OnlineQueueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UP8E6144D021E7477EBB9582 /* OnlineQueueViewController.swift */; }; - UP2547E6FA14E34C4C8FE2F8 /* OfflineEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UP782B5FAFE6CF4694B8A995 /* OfflineEventsViewController.swift */; }; AB5ED5932D0E07D5002C815A /* PaddingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5ED5922D0E07CD002C815A /* PaddingLabel.swift */; }; AB6ABAA62CE88B9300FB72A8 /* SDKEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6ABAA52CE88B9300FB72A8 /* SDKEventsViewController.swift */; }; AB6ABAAC2CE88FFD00FB72A8 /* SDKEventTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6ABAAA2CE88FFD00FB72A8 /* SDKEventTableViewCell.swift */; }; @@ -60,7 +54,6 @@ ABEB31762F73F58400E1789D /* TableViewTestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEB31722F73F58400E1789D /* TableViewTestViewController.swift */; }; ABEB31772F73F58400E1789D /* CollectionViewTestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEB31712F73F58400E1789D /* CollectionViewTestViewController.swift */; }; ABEB31782F73F58400E1789D /* AutoCaptureTestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEB31702F73F58400E1789D /* AutoCaptureTestViewController.swift */; }; - UP29C06D0153AF4F2ABE3C29 /* AutoCaptureHubViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UPDFBEEB3C1F6B418793B6DE /* AutoCaptureHubViewController.swift */; }; ABEB31792F73F58400E1789D /* PickerViewTestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEB317B2F73F58400E1789D /* PickerViewTestViewController.swift */; }; ABED3F5E2C9803D000004D1C /* IdentifyViewController+UserProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABED3F5D2C9803D000004D1C /* IdentifyViewController+UserProperties.swift */; }; ABED3F602C9803DE00004D1C /* IdentifyViewController+CompanyProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABED3F5F2C9803DE00004D1C /* IdentifyViewController+CompanyProperties.swift */; }; @@ -75,13 +68,20 @@ ABFB5FDE2C9171600054E142 /* EventTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABFB5FDC2C9171600054E142 /* EventTableViewCell.swift */; }; ABFB5FE42C9173D60054E142 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABFB5FE32C9173D60054E142 /* CardView.swift */; }; ABFB5FE62C91743B0054E142 /* OutlineButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABFB5FE52C91743B0054E142 /* OutlineButton.swift */; }; - UPA91C2E4B7D6E8F0A1B2C01 /* SampleAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = UPA91C2E4B7D6E8F0A1B2C02 /* SampleAppearance.swift */; }; ABFB5FE82C9175030054E142 /* UITableReusableView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABFB5FE72C9175030054E142 /* UITableReusableView+Extension.swift */; }; ABFD95802D0E096500F8F757 /* Userpilot in Frameworks */ = {isa = PBXBuildFile; productRef = ABFD957F2D0E096500F8F757 /* Userpilot */; }; C5C897B92C5CFFFA0083928E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C897B82C5CFFFA0083928E /* AppDelegate.swift */; }; C5C897BB2C5CFFFA0083928E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C897BA2C5CFFFA0083928E /* SceneDelegate.swift */; }; C5C897C22C5CFFFB0083928E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C5C897C12C5CFFFB0083928E /* Assets.xcassets */; }; C5C897C52C5CFFFB0083928E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5C897C32C5CFFFB0083928E /* LaunchScreen.storyboard */; }; + FACF000000000000000000B1 /* ConfigViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A1 /* ConfigViewController.swift */; }; + FACF000000000000000000B2 /* ConfigFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A2 /* ConfigFlag.swift */; }; + FACF000000000000000000B3 /* ConfigFlagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */; }; + UP2547E6FA14E34C4C8FE2F8 /* OfflineEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UP782B5FAFE6CF4694B8A995 /* OfflineEventsViewController.swift */; }; + UP29C06D0153AF4F2ABE3C29 /* AutoCaptureHubViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UPDFBEEB3C1F6B418793B6DE /* AutoCaptureHubViewController.swift */; }; + UP9B74448B7CF24C7EBF611D /* DebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UP05E7237BA6114ABE8008E3 /* DebugViewController.swift */; }; + UPA91C2E4B7D6E8F0A1B2C01 /* SampleAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = UPA91C2E4B7D6E8F0A1B2C02 /* SampleAppearance.swift */; }; + UPDE5091EFDF0F402DB75A79 /* OnlineQueueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = UP8E6144D021E7477EBB9582 /* OnlineQueueViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -105,12 +105,6 @@ AB2564922DB4F55000393126 /* UserpilotSample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = UserpilotSample.entitlements; sourceTree = ""; }; AB4721342DBA1E7900B92EE2 /* AppDelegate+PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+PushNotification.swift"; sourceTree = ""; }; AB585F652CD79D8C00E2A66D /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; - FACF000000000000000000A1 /* ConfigViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigViewController.swift; sourceTree = ""; }; - FACF000000000000000000A2 /* ConfigFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFlag.swift; sourceTree = ""; }; - FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFlagTableViewCell.swift; sourceTree = ""; }; - UP05E7237BA6114ABE8008E3 /* DebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewController.swift; sourceTree = ""; }; - UP8E6144D021E7477EBB9582 /* OnlineQueueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineQueueViewController.swift; sourceTree = ""; }; - UP782B5FAFE6CF4694B8A995 /* OfflineEventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineEventsViewController.swift; sourceTree = ""; }; AB585F672CD79EF400E2A66D /* Dev.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Dev.xcconfig; sourceTree = ""; }; AB5ED5922D0E07CD002C815A /* PaddingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingLabel.swift; sourceTree = ""; }; AB6ABAA52CE88B9300FB72A8 /* SDKEventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKEventsViewController.swift; sourceTree = ""; }; @@ -157,7 +151,6 @@ ABCDBF452CAEACED0083DF1D /* NibLoadable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NibLoadable.swift; sourceTree = ""; }; ABD07B272D6C4ED000619C47 /* Prod.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Prod.xcconfig; sourceTree = ""; }; ABEB31702F73F58400E1789D /* AutoCaptureTestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCaptureTestViewController.swift; sourceTree = ""; }; - UPDFBEEB3C1F6B418793B6DE /* AutoCaptureHubViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCaptureHubViewController.swift; sourceTree = ""; }; ABEB31712F73F58400E1789D /* CollectionViewTestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewTestViewController.swift; sourceTree = ""; }; ABEB31722F73F58400E1789D /* TableViewTestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewTestViewController.swift; sourceTree = ""; }; ABEB31732F73F58400E1789D /* TextConfigDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextConfigDemoViewController.swift; sourceTree = ""; }; @@ -175,7 +168,6 @@ ABFB5FDC2C9171600054E142 /* EventTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTableViewCell.swift; sourceTree = ""; }; ABFB5FE32C9173D60054E142 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; ABFB5FE52C91743B0054E142 /* OutlineButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineButton.swift; sourceTree = ""; }; - UPA91C2E4B7D6E8F0A1B2C02 /* SampleAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleAppearance.swift; sourceTree = ""; }; ABFB5FE72C9175030054E142 /* UITableReusableView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableReusableView+Extension.swift"; sourceTree = ""; }; C5C897B52C5CFFFA0083928E /* UserpilotSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UserpilotSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; C5C897B82C5CFFFA0083928E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -183,6 +175,14 @@ C5C897C12C5CFFFB0083928E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C5C897C42C5CFFFB0083928E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; C5C897C62C5CFFFB0083928E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FACF000000000000000000A1 /* ConfigViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigViewController.swift; sourceTree = ""; }; + FACF000000000000000000A2 /* ConfigFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFlag.swift; sourceTree = ""; }; + FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFlagTableViewCell.swift; sourceTree = ""; }; + UP05E7237BA6114ABE8008E3 /* DebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewController.swift; sourceTree = ""; }; + UP782B5FAFE6CF4694B8A995 /* OfflineEventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineEventsViewController.swift; sourceTree = ""; }; + UP8E6144D021E7477EBB9582 /* OnlineQueueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineQueueViewController.swift; sourceTree = ""; }; + UPA91C2E4B7D6E8F0A1B2C02 /* SampleAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleAppearance.swift; sourceTree = ""; }; + UPDFBEEB3C1F6B418793B6DE /* AutoCaptureHubViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCaptureHubViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -360,58 +360,6 @@ path = Dialogs; sourceTree = ""; }; - FACF000000000000000000C1 /* Config */ = { - isa = PBXGroup; - children = ( - FACF000000000000000000C2 /* Models */, - FACF000000000000000000C3 /* Cells */, - FACF000000000000000000A1 /* ConfigViewController.swift */, - ); - path = Config; - sourceTree = ""; - }; - UP08E667051DEB4096A385C2 /* Debug */ = { - isa = PBXGroup; - children = ( - UP05E7237BA6114ABE8008E3 /* DebugViewController.swift */, - UP1D9D3FCE847D46FDB03B8A /* Offline */, - UPAE8C6135D07D4AA9914D9D /* OnlineQueue */, - ); - path = Debug; - sourceTree = ""; - }; - UPAE8C6135D07D4AA9914D9D /* OnlineQueue */ = { - isa = PBXGroup; - children = ( - UP8E6144D021E7477EBB9582 /* OnlineQueueViewController.swift */, - ); - path = OnlineQueue; - sourceTree = ""; - }; - UP1D9D3FCE847D46FDB03B8A /* Offline */ = { - isa = PBXGroup; - children = ( - UP782B5FAFE6CF4694B8A995 /* OfflineEventsViewController.swift */, - ); - path = Offline; - sourceTree = ""; - }; - FACF000000000000000000C2 /* Models */ = { - isa = PBXGroup; - children = ( - FACF000000000000000000A2 /* ConfigFlag.swift */, - ); - path = Models; - sourceTree = ""; - }; - FACF000000000000000000C3 /* Cells */ = { - isa = PBXGroup; - children = ( - FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; ABACA8632C96E3E9006C9B77 /* UI */ = { isa = PBXGroup; children = ( @@ -610,6 +558,58 @@ path = UserpilotSample; sourceTree = ""; }; + FACF000000000000000000C1 /* Config */ = { + isa = PBXGroup; + children = ( + FACF000000000000000000C2 /* Models */, + FACF000000000000000000C3 /* Cells */, + FACF000000000000000000A1 /* ConfigViewController.swift */, + ); + path = Config; + sourceTree = ""; + }; + FACF000000000000000000C2 /* Models */ = { + isa = PBXGroup; + children = ( + FACF000000000000000000A2 /* ConfigFlag.swift */, + ); + path = Models; + sourceTree = ""; + }; + FACF000000000000000000C3 /* Cells */ = { + isa = PBXGroup; + children = ( + FACF000000000000000000A3 /* ConfigFlagTableViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; + UP08E667051DEB4096A385C2 /* Debug */ = { + isa = PBXGroup; + children = ( + UP05E7237BA6114ABE8008E3 /* DebugViewController.swift */, + UP1D9D3FCE847D46FDB03B8A /* Offline */, + UPAE8C6135D07D4AA9914D9D /* OnlineQueue */, + ); + path = Debug; + sourceTree = ""; + }; + UP1D9D3FCE847D46FDB03B8A /* Offline */ = { + isa = PBXGroup; + children = ( + UP782B5FAFE6CF4694B8A995 /* OfflineEventsViewController.swift */, + ); + path = Offline; + sourceTree = ""; + }; + UPAE8C6135D07D4AA9914D9D /* OnlineQueue */ = { + isa = PBXGroup; + children = ( + UP8E6144D021E7477EBB9582 /* OnlineQueueViewController.swift */, + ); + path = OnlineQueue; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -667,7 +667,7 @@ ); mainGroup = C5C897AC2C5CFFFA0083928E; packageReferences = ( - AB25648F2DB4F4AB00393126 /* XCLocalSwiftPackageReference "../../../ios-sdk" */, + AB68595F30594DA300EAEF1A /* XCLocalSwiftPackageReference "../../../ios-sdk copy" */, ); productRefGroup = C5C897B62C5CFFFA0083928E /* Products */; projectDirPath = ""; @@ -686,8 +686,8 @@ ABAC8E6E2CE9E9D0005EEC9D /* SDKEventTableViewCell.xib in Resources */, AB1942CB2CD2626C00924844 /* EventTableViewCell.xib in Resources */, AB1942AC2CD25DFE00924844 /* Localizable.xcstrings in Resources */, - AB1942BB2CD260F000924844 /* IdentifyViewController.xib in Resources */, - AB1942B72CD260CA00924844 /* DeepLinkViewController.xib in Resources */, + AB1942BB2CD260F000924844 /* IdentifyViewController.xib in Resources */, + AB1942B72CD260CA00924844 /* DeepLinkViewController.xib in Resources */, AB1942AF2CD25FD200924844 /* ScreenTwoViewController.xib in Resources */, AB1942C32CD261F100924844 /* CustomEventViewController.xib in Resources */, C5C897C52C5CFFFB0083928E /* LaunchScreen.storyboard in Resources */, @@ -1108,9 +1108,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - AB25648F2DB4F4AB00393126 /* XCLocalSwiftPackageReference "../../../ios-sdk" */ = { + AB68595F30594DA300EAEF1A /* XCLocalSwiftPackageReference "../../../ios-sdk copy" */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../../../ios-sdk"; + relativePath = "../../../ios-sdk copy"; }; /* End XCLocalSwiftPackageReference section */ From 75470b9c6958cc80f768b19e6be21b0e2131f048 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 17 Sep 2026 12:58:45 +0300 Subject: [PATCH 58/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Experiences):=20Keep?= =?UTF-8?q?=20suppressed=20NPS=20from=20blocking=20track=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advance pending processing when a repeated NPS is suppressed. Match Android's per-screen-visit flag and reset it on screen changes. Cover repeat suppression, subsequent track content, and NPS eligibility when returning to a screen. --- .../Experiences/ExperiencesPublisher.swift | 24 +++-- .../ExperiencesPublisherTests.swift | 91 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 12786b2e..5057d2a9 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -117,8 +117,10 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// The current screen title being tracked private lazy var currentScreen: String = "" - /// The last screen on which NPS was shown, preventing repeated NPS on same screen. - private lazy var npsTrackedScreen: String = "" + /// Set when NPS is presented so it shows at most once per screen visit, matching Android. + /// Cleared by `resetState()` when the screen changes or the user logs out. + /// Atomic because presentation writes on main while socket and reset paths use other queues. + private let npsShownOnCurrentScreen = AtomicReference(false) /// Queue to track pending experience content waiting to be displayed private var pendingExperiences: [ExperienceContent] = [] @@ -804,10 +806,14 @@ extension ExperiencesPublisher { ) } - /** Opens an NPS experience as a bottom sheet fragment */ + /** Opens an NPS experience as a bottom sheet, at most once per screen visit. */ private func openNPSBottomSheetExperience(_ experienceContent: ExperienceContent) { - if currentScreen == npsTrackedScreen { - resetProcessingExperienceStatus() + if npsShownOnCurrentScreen.value { + logger.info("🎯 NPS suppressed - already shown on the current screen") + // Drain the queue rather than only resetting the state: every launch path reads + // `pendingExperiences.first`, so an NPS left at the head after being refused blocks + // each experience that arrives behind it until the screen changes. + processNextPendingExperiences() return } showExperience( @@ -932,7 +938,7 @@ extension ExperiencesPublisher { self.experienceStateMachine.setActiveComponent(upExperience) } if viewController.isKind(of: NPSBottomSheetViewController.self) { - self.npsTrackedScreen = self.currentScreen + self.npsShownOnCurrentScreen.value = true } switch presentation { case .fullScreen, .normal: @@ -1067,7 +1073,7 @@ extension ExperiencesPublisher { tryCatch { delayUtils.cancelDelay() clearPendingExperiences() - npsTrackedScreen = "" + npsShownOnCurrentScreen.value = false if hasActiveExperience || experienceStateMachine.getActiveComponent() != nil { endExperience(manualClose: true, completion: completion) @@ -1306,6 +1312,10 @@ extension ExperiencesPublisher { return currentScreen } + func mockSetNPSShownOnCurrentScreen(_ shown: Bool) { + npsShownOnCurrentScreen.value = shown + } + func mockActiveExperience(experience: UIViewController) { self.activeExperience = experience } diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index c2af8ea4..fb3d8f5e 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -356,6 +356,97 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + /// Regression: an NPS refused because one already ran on this screen used to stay at the head of + /// the pending queue. `openExperienceFlow()` always reads that head, so every experience arriving + /// afterwards was decoded and queued but never rendered — content returned by a track event + /// stopped appearing for as long as the user stayed on the screen. + func testOnNewMessage_shouldShowContent_WhenARefusedNPSIsQueuedOnTheSameScreen() { + // Arrange — an NPS has already been shown on "Home", so a repeat one must be refused + let expectation = XCTestExpectation(description: "Track event content becomes the active content") + experiencesPublisher.mockSetCurrentScreen(title: "Home") + experiencesPublisher.mockSetNPSShownOnCurrentScreen(true) + + // Act — the repeat NPS arrives, then a track event returns flow content + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, + nil, + Message(payload: MockContentFactory.makeNPSContentPayload()), + true + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.experiencesPublisher.onNewMessage( + Message(payload: ["payload": MockContentFactory.makeFlowContentPayload()]) + ) + + // Assert — the refused NPS must not be holding the queue head + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard case .flow(let content) = self.experiencesPublisher.getActiveMobileContent() else { + XCTFail("Expected the track event's flow content, not the refused NPS") + expectation.fulfill() + return + } + XCTAssertEqual(content.id, 77) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 2.0) + } + + /// The queue drain must not weaken the dedup itself: a repeat NPS on the screen it already ran + /// on is still dropped rather than shown again. + func testOnSocketEventSent_shouldDropRepeatNPS_OnTheScreenItAlreadyRanOn() { + // Arrange + let expectation = XCTestExpectation(description: "Repeat NPS is dropped") + experiencesPublisher.mockSetCurrentScreen(title: "Home") + experiencesPublisher.mockSetNPSShownOnCurrentScreen(true) + + // Act — reporting the same screen must not make NPS eligible again + experiencesPublisher.updateScreen("Home") + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, + nil, + Message(payload: MockContentFactory.makeNPSContentPayload()), + true + ) + + // Assert + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertNil(self.experiencesPublisher.getActiveMobileContent()) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testUpdateScreen_shouldMakeNPSEligibleAgain_WhenReturningToThePreviousScreen() { + // Arrange — NPS already ran during the previous visit to Home + let expectation = XCTestExpectation(description: "NPS is eligible on the next screen visit") + experiencesPublisher.mockSetCurrentScreen(title: "Home") + experiencesPublisher.mockSetNPSShownOnCurrentScreen(true) + + // Act — leave Home and return, then receive NPS again + experiencesPublisher.updateScreen("Details") + experiencesPublisher.updateScreen("Home") + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, + nil, + Message(payload: MockContentFactory.makeNPSContentPayload()), + true + ) + + // Assert — the NPS is accepted instead of being suppressed for the previous visit + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self else { return } + guard case .nps = self.experiencesPublisher.getActiveMobileContent() else { + XCTFail("Expected NPS to be eligible on the next visit to Home") + expectation.fulfill() + return + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + func testOnSocketEventSent_shouldSelectSurvey_WhenHigherPriorityFlowWasSeen() { // Arrange let expectation = XCTestExpectation(description: "Unseen survey should be selected") From 36ffe42eb9a2eea3d7e1e3e7cfe48f68676b9c93 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Thu, 17 Sep 2026 13:01:33 +0300 Subject: [PATCH 59/95] =?UTF-8?q?=F0=9F=92=A1=20docs(Experiences):=20Simpl?= =?UTF-8?q?ify=20overlay=20window=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift b/Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift index ce54769f..99033c46 100644 --- a/Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift +++ b/Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift @@ -68,8 +68,7 @@ internal final class ExperienceOverlayWindow: UIWindow { owningInstance.config.attach(windows: [self]) // Surface the window immediately so its rootViewController is part of - // the scene's window hierarchy from this point onward. Mirrors the - // pattern used by Appcues' overlay (`AppcuesUIWindow`) — without this, + // the scene's window hierarchy from this point onward. Without this, // a later `rootViewController.present(...)` from `ExperiencesPublisher` // fires before the window is attached and UIKit drops the present // with "whose view is not in the window hierarchy". @@ -201,8 +200,7 @@ internal final class ExperienceOverlayWindow: UIWindow { /// app's main window scene is still reachable. /// /// Returns `nil` only when neither path resolves a scene; callers fall - /// through to the legacy `UIScreen.main.bounds` init path. Mirrors - /// Appcues' `mainWindowScene` resolver. + /// through to the legacy `UIScreen.main.bounds` init path. private static func resolveScene() -> UIWindowScene? { if let active = UIApplication.shared.activeWindowScenes.first { return active From a2bdb38497ac4540a4fc3e44c01321403de6877d Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 20 Sep 2026 11:30:36 +0300 Subject: [PATCH 60/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Clear=20?= =?UTF-8?q?seen=20content=20when=20the=20user=20navigates=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screen tracked while offline is persisted and returns early from publish, so it never reaches setupScreenEvent - the only place a new screen session starts with an empty seen set. Restored offline events go to the backend as a raw batch and never re-enter screen(_:), so those navigations stayed invisible to the session for good. The user saw it as content going missing: an experience shown on a screen before the connection dropped was still recorded as seen, so returning to that screen once the network was back showed the next unseen content instead - even with the dashboard frequency set to show every time. The stale ids also went out as seen_contents on the next screen push, so the backend was told the same thing. Screen state is local bookkeeping, so it no longer waits for the network: the offline branch advances the screen session before persisting the event, matching the experiencesPublisher.updateScreen call already made above it. Only the transmission stays gated on connectivity. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 9 ++++ .../Analytics/AnalyticsPublisherTests.swift | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 3b9213eb..0f42654d 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -394,6 +394,15 @@ extension AnalyticsPublisher: AnalyticsPublishing { // Network monitor is ready and reports no network: persist locally if offlineEventsHandler.shouldSaveOffline { + // Screen state is local bookkeeping, so it must not wait for the network. + // setupScreenEvent is the only place a new screen session starts with an empty + // seen set, and restored offline events go to the backend as a raw batch that + // never re-enters screen(_:) - so navigation performed offline would otherwise + // stay invisible to the session, and content already seen on a screen would + // remain suppressed when the user came back to it online. + if event.isScreenEvent { + setupScreenEvent(event) + } handleOfflineEvent(event) return } diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 6f428545..5b47f203 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -614,6 +614,53 @@ class AnalyticsPublisherTests: XCTestCase { ) } + /// A screen tracked while offline is only persisted, never published, so the seen sets that + /// suppress already-shown content have to be reset here too. Without it, content shown before + /// the connection dropped stays suppressed when the user navigates away and comes back. + func testPublish_offlineScreenChange_shouldClearSeenContent() throws { + // Arrange — a flow already shown on the current screen + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Screen X"))) + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + analyticsPublisher.experiencePublished(.flow, flow.id) + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.flow(content: flow))) + + // Act — the user navigates to another screen with no network + userpilot.offlineEventsHandler.shouldSaveOffline = true + analyticsPublisher.publish(Event(type: .screen("Screen Y"))) + + // Assert — the flow is eligible again, and the screen event is still only stored + XCTAssertFalse(analyticsPublisher.isExperienceSeen(.flow(content: flow))) + XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "Screen Y") + XCTAssertEqual(userpilot.offlineEventsHandler.savedEvents.count, 1) + } + + /// Re-entering the same screen is not navigation, so what was shown there stays suppressed. + func testPublish_offlineSameScreen_shouldKeepSeenContent() throws { + // Arrange — a flow already shown on the current screen + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Screen X"))) + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + analyticsPublisher.experiencePublished(.flow, flow.id) + + // Act — the same screen is tracked again with no network + userpilot.offlineEventsHandler.shouldSaveOffline = true + analyticsPublisher.publish(Event(type: .screen("Screen X"))) + + // Assert — it is still recorded as seen + XCTAssertTrue(analyticsPublisher.isExperienceSeen(.flow(content: flow))) + } + func testIsExperienceSeen_shouldNotCrossTypesForTheSameNumericId() throws { // Arrange — the screen session (which owns the seen sets) is only created once the // screen event actually goes out, which requires an open socket. From eeb620d63e7e5934f8d273b75250bc31dfc31f8c Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 20 Sep 2026 12:17:32 +0300 Subject: [PATCH 61/95] =?UTF-8?q?=F0=9F=94=A7=20chore:=20Ignore=20local=20?= =?UTF-8?q?GitHub=20Actions=20cleanup=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the per-repository cleanup helper and its audit records local. Prevent these operational files from entering source commits. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 0bfe9ed7..659e524d 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,7 @@ CLAUDE.md # Locally generated agent specs/plans — not project documentation docs/superpowers/ plan/ + +# Local GitHub Actions cleanup helper and audit records +/scripts/cleanup-github-actions.py +/.local/github-actions-cleanup/ From 623fd82b3d892b4f240eb49608c8f5be8d5eb9ec Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 20 Sep 2026 14:30:59 +0300 Subject: [PATCH 62/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Experiences):=20Keep?= =?UTF-8?q?=20the=20NPS=20close=20button=20clear=20of=20the=20progress=20b?= =?UTF-8?q?ar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steps progress bar sits at the top edge of the NPS sheet while the close button hangs from the bottom of its container, and the container was 4pt too short, so the chip's top edge ran through the bar. Derive the container height from the bar's position plus a top margin, and move the values behind it into ThemeHandler.DefaultValues, where the rest of the NPS layout constants live. The bar height was duplicated in the NPS and survey containers, so it becomes a single shared constant. Co-Authored-By: Claude Opus 5 (1M context) --- .../NPS/ContainerView/NPSContainerView.swift | 22 ++++++++++++++----- .../StepView/Base/SurveyContainerView.swift | 3 ++- .../Experiences/Theme/ThemeHandler.swift | 9 ++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift b/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift index bbddf19f..2f4c6934 100644 --- a/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift +++ b/Sources/Userpilot/Experiences/Content/NPS/ContainerView/NPSContainerView.swift @@ -9,15 +9,22 @@ import UIKit // swiftlint:disable all internal class NPSContainerView: UIView { - + // MARK: - UI Components /// A container for the dismiss button, with a fixed height. + /// The button is pinned to the container's bottom, so the height leaves room for the steps progress + /// bar above it and the two never overlap. private lazy var buttonDismissContainerView: UIView = { let view = UIView() view.backgroundColor = .clear view.translatesAutoresizingMaskIntoConstraints = false - view.heightAnchor.constraint(equalToConstant: 45).isActive = true + let height = ThemeHandler.DefaultValues.npsProgressBarTopInset + + ThemeHandler.DefaultValues.stepsProgressBarHeight + + ThemeHandler.DefaultValues.npsDismissButtonTopMargin + + ThemeHandler.DefaultValues.npsDismissButtonHeight + - ThemeHandler.DefaultValues.npsDismissButtonBottomOverhang + view.heightAnchor.constraint(equalToConstant: height).isActive = true return view }() @@ -59,7 +66,8 @@ internal class NPSContainerView: UIView { private lazy var barStepsProgressView: UPStepsBarProgressView = { let progressView = UPStepsBarProgressView() progressView.translatesAutoresizingMaskIntoConstraints = false - progressView.heightAnchor.constraint(equalToConstant: 5).isActive = true + progressView.heightAnchor.constraint( + equalToConstant: ThemeHandler.DefaultValues.stepsProgressBarHeight).isActive = true return progressView }() @@ -222,7 +230,9 @@ internal class NPSContainerView: UIView { // Define constraints storedConstraints.append(contentsOf: [ // Constraints for the content stack view (full-screen with padding) - barStepsProgressView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor,constant: 20), + barStepsProgressView.topAnchor.constraint( + equalTo: safeAreaLayoutGuide.topAnchor, + constant: ThemeHandler.DefaultValues.npsProgressBarTopInset), barStepsProgressView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: -20), barStepsProgressView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: 20), @@ -532,7 +542,9 @@ extension NPSContainerView { NSLayoutConstraint.activate([ buttonDismiss.heightAnchor.constraint( equalToConstant: ThemeHandler.DefaultValues.npsDismissButtonHeight), - buttonDismiss.bottomAnchor.constraint(equalTo: buttonDismissContainerView.bottomAnchor, constant: 10), + buttonDismiss.bottomAnchor.constraint( + equalTo: buttonDismissContainerView.bottomAnchor, + constant: ThemeHandler.DefaultValues.npsDismissButtonBottomOverhang), // Keep the chip inside the content margins, otherwise its trailing edge sits flush with the screen buttonDismiss.trailingAnchor.constraint(equalTo: buttonDismissContainerView.trailingAnchor) ]) diff --git a/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift b/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift index 16b397dc..714125d5 100644 --- a/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift +++ b/Sources/Userpilot/Experiences/Content/Survey/StepView/Base/SurveyContainerView.swift @@ -42,7 +42,8 @@ internal class SurveyContainerView: UIView { private lazy var barStepsProgressView: UPStepsBarProgressView = { let progressView = UPStepsBarProgressView() progressView.translatesAutoresizingMaskIntoConstraints = false - progressView.heightAnchor.constraint(equalToConstant: 5).isActive = true + progressView.heightAnchor.constraint( + equalToConstant: ThemeHandler.DefaultValues.stepsProgressBarHeight).isActive = true return progressView }() diff --git a/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift b/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift index fd06a74b..f7ab7008 100644 --- a/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift +++ b/Sources/Userpilot/Experiences/Theme/ThemeHandler.swift @@ -92,6 +92,9 @@ internal class ThemeHandler: ThemeHandling { static let imageSize = CGFloat(300) static let closeButtonAlpha = 0.8 static let dismissButtonMargin = CGFloat(10) + /// The height of `UPStepsBarProgressView`. Its layers are fully rounded, + /// so this also drives their corner radius. + static let stepsProgressBarHeight = CGFloat(5) static let iconImageDimensions = 38 static let npsImageDimensions = 100 @@ -108,6 +111,12 @@ internal class ThemeHandler: ThemeHandling { static let npsDismissButtonHeight = CGFloat(34) static let npsDismissButtonTextSize = CGFloat(14) + /// NPS header layout: the steps progress bar sits at the very top of the sheet and the dismiss + /// button hangs below its container, so the button needs a margin to clear the bar. + static let npsProgressBarTopInset = CGFloat(20) + static let npsDismissButtonTopMargin = CGFloat(8) + static let npsDismissButtonBottomOverhang = CGFloat(10) + /// Survey static let surveyItemRatingMinWidth: Int = 80 static let surveyContentTopMargin: Int = 16 From 6742ba2c17aecb86e2dc5764c0d905b5d401208b Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 11:34:35 +0300 Subject: [PATCH 63/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Drop=20t?= =?UTF-8?q?he=20post-dismissal=20screen=20event=20while=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a Userpilot experience makes the host surface re-emit its screen event. Online that repeat is dropped, because the dismissal already sent a fake reload for the same screen. Offline it was written to local storage and later replayed to the backend as a genuine screen view, making it re-evaluate content for a screen the user never re-entered. The post-close cooldown is now armed whether or not the socket is open, and the offline path applies the same verdict as screen(_:): a move to a different screen is always stored, a repeat of the current one only when canRequestScreenEvent() allows it. setupScreenEvent still runs either way, so screen state does not wait for the network. Android parity: the same two fixes landed there. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 36 ++++++++++++----- .../Experiences/ExperiencesPublisher.swift | 14 +++---- .../Analytics/AnalyticsPublisherTests.swift | 36 +++++++++++++++++ .../ExperiencesPublisherTests.swift | 39 +++++++++++++++++++ 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 0f42654d..bd03ec35 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -394,15 +394,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { // Network monitor is ready and reports no network: persist locally if offlineEventsHandler.shouldSaveOffline { - // Screen state is local bookkeeping, so it must not wait for the network. - // setupScreenEvent is the only place a new screen session starts with an empty - // seen set, and restored offline events go to the backend as a raw batch that - // never re-enters screen(_:) - so navigation performed offline would otherwise - // stay invisible to the session, and content already seen on a screen would - // remain suppressed when the user came back to it online. - if event.isScreenEvent { - setupScreenEvent(event) - } + if event.isScreenEvent, !shouldStoreScreenEventOffline(event) { return } handleOfflineEvent(event) return } @@ -426,6 +418,32 @@ extension AnalyticsPublisher: AnalyticsPublishing { } } + /** + * Records an offline screen event in the screen session and reports whether it is worth + * persisting. + * + * The state update always runs: screen state is local bookkeeping, so it must not wait for + * the network. `setupScreenEvent(_:)` is the only place a new screen session starts with an + * empty seen set, and restored offline events go to the backend as a raw batch that never + * re-enters `screen(_:)` - so navigation performed offline would otherwise stay invisible to + * the session, and content already seen on a screen would remain suppressed when the user + * came back to it online. + * + * The verdict mirrors `screen(_:)`: a move to a different screen is always kept, while a + * repeat of the screen the user is already on is kept only when + * `ExperiencesPublishing.canRequestScreenEvent()` allows it. Without that second check the + * screen event the host surface re-emits when a Userpilot experience is dismissed - dropped + * outright while online - would be persisted and later replayed as a genuine screen view, + * making the backend re-evaluate content for a screen the user never actually re-entered. + * + * - Parameter event: The offline screen event to record + * - Returns: true when the event should be persisted + */ + private func shouldStoreScreenEventOffline(_ event: Event) -> Bool { + let isNewScreen = setupScreenEvent(event) + return isNewScreen || experiencesPublisher?.canRequestScreenEvent() == true + } + /** * Persists an event to local storage while offline. * On an offline user switch the old user's stored events are cleared first, diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 5057d2a9..467b3bf6 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -137,7 +137,11 @@ internal class ExperiencesPublisher: ExperiencesPublishing { qos: .userInteractive ) - /// Date when a fake screen reload event was last requested + /// Date when a fake screen reload event was last requested. + /// Armed on every experience close, online or offline. Closing an experience makes the host + /// surface re-emit its screen event, and that repeat is an artifact of the dismissal rather + /// than real navigation whether or not a socket is there to carry the fake reload — offline it + /// would otherwise be persisted and replayed to the backend as a genuine screen view. private var requestFakeScreenReloadEventDate: Date? /// Track last active experience @@ -404,9 +408,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { if isPreviewExperienceMode() { if sdkEvent.isEventForCloseExperience() || sdkEvent.isEventForCloseNPSExperience() { activeExperience = nil - if socketManager.isSocketOpened { - requestFakeScreenReloadEventDate = Date() - } + requestFakeScreenReloadEventDate = Date() analyticsPublisher.publishFakeReloadScreenEvent( sdkEvent.getContentType(), sdkEvent.getContentId() @@ -430,9 +432,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { // closing the experience directly won't trigger screen content if sdkEvent.isEventForCloseExperience() || sdkEvent.isEventForCloseNPSExperience() { activeExperience = nil - if socketManager.isSocketOpened { - requestFakeScreenReloadEventDate = Date() - } + requestFakeScreenReloadEventDate = Date() } // Don't trigger fake reload for NPS experiences or experiences with deep links diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 5b47f203..3cc7134a 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -661,6 +661,42 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(analyticsPublisher.isExperienceSeen(.flow(content: flow))) } + /// Dismissing a Userpilot experience makes the host surface re-emit its screen event. Online + /// that repeat is dropped, because the dismissal already sent a fake reload for the same + /// screen. Persisting it while offline replayed it to the backend as a genuine screen view, + /// making it re-evaluate content for a screen the user never actually re-entered. + func testPublish_offlineSameScreenAfterAnExperienceClosed_shouldNotStoreTheRepeat() { + // Arrange — the user is on "Screen X" and an experience has just been dismissed there, + // which is what leaves `canRequestScreenEvent()` reporting false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Screen X"))) + userpilot.experiencesPublisher.onCanRequestScreenEvent = { return false } + + // Act — the host surface re-emits the same screen with no network + userpilot.offlineEventsHandler.shouldSaveOffline = true + analyticsPublisher.publish(Event(type: .screen("Screen X"))) + + // Assert — nothing is persisted, and the screen session still tracks the screen + XCTAssertTrue(userpilot.offlineEventsHandler.savedEvents.isEmpty) + XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "Screen X") + } + + /// The cooldown suppresses repeats, never real navigation — that would lose the screen. + func testPublish_offlineNavigationAfterAnExperienceClosed_shouldStillBeStored() { + // Arrange — the user is on "Screen X" and an experience has just been dismissed there + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Screen X"))) + userpilot.experiencesPublisher.onCanRequestScreenEvent = { return false } + + // Act — the user navigates to another screen with no network + userpilot.offlineEventsHandler.shouldSaveOffline = true + analyticsPublisher.publish(Event(type: .screen("Screen Y"))) + + // Assert — the navigation is persisted + XCTAssertEqual(userpilot.offlineEventsHandler.savedEvents.count, 1) + XCTAssertEqual(userpilot.offlineEventsHandler.savedEvents.first?.event.screenTitle, "Screen Y") + } + func testIsExperienceSeen_shouldNotCrossTypesForTheSameNumericId() throws { // Arrange — the screen session (which owns the seen sets) is only created once the // screen event actually goes out, which requires an open socket. diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index fb3d8f5e..8720ee57 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -715,6 +715,45 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertFalse(canRequest) } + /// Closing an experience makes the host surface re-emit its screen event whether or not a + /// socket is there to carry the fake reload, so the suppression window has to be armed while + /// offline too — otherwise the repeat is persisted and later replayed to the backend as a + /// genuine screen view. + func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForCloseEventWhileOffline() { + // Arrange + userpilot.socketManager.isSocketOpened = false + let mockEvent = MockSDKEvent(eventName: "dismissed_mobile_content") + mockEvent.isCloseEvent = true + + // Act + experiencesPublisher.publishInternalSDKEvent(mockEvent) + let canRequest = experiencesPublisher.canRequestScreenEvent() + + // Assert + XCTAssertFalse(canRequest) + } + + /// The preview close path re-emits the host screen event for the same reason, so it arms the + /// window while offline too. + func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForPreviewCloseWhileOffline() { + // Arrange + userpilot.socketManager.isSocketOpened = false + userpilot.experienceStateMachine.markPreviewMode() + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in return true } + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 77] + ) + closeEvent.isCloseEvent = true + + // Act + experiencesPublisher.publishInternalSDKEvent(closeEvent) + let canRequest = experiencesPublisher.canRequestScreenEvent() + + // Assert + XCTAssertFalse(canRequest) + } + func testPublishInternalSDKEvent_shouldHandleCloseEvent_WithDeepLink() { // Arrange let mockEvent = MockSDKEvent(eventName: "close-event", hasDeepLink: true) From 1f4561f2785d310d5318fd69063457f2a2c82cbd Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 11:34:48 +0300 Subject: [PATCH 64/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Flush=20?= =?UTF-8?q?cached=20SDK=20events=20before=20the=20screen=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screen message makes the backend re-evaluate content for the surface, so a cached step-seen/completed/dismissed event has to be on the wire first - otherwise the fake reload a dismissal triggers gets the dismissed content served straight back. processEvent() already drains ahead of the live queue, but that drain is skipped whenever a cycle already holds isProcessingEvent - which the post-identify screen push does deliberately, and which the async offline restore window does too. The drain now runs at the single place a screen message is published, ahead of the pre-screen identify. Android parity: the same fix landed there, where the asynchronous drain let the screen overtake the dismissal by one main-looper turn. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 16 ++++++++++- .../Analytics/AnalyticsPublisherTests.swift | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index bd03ec35..927ec44d 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -1305,7 +1305,8 @@ extension AnalyticsPublisher { * session-start state, fake-reload flag, and seen experiences/surveys. * * This is the single place a `screen` message is published, so it is also the single place the - * preceding identify is sent when `Config.enableRequestIdentifyBeforeScreen(_:)` is on. + * preceding identify is sent when `Config.enableRequestIdentifyBeforeScreen(_:)` is on and the + * single place the cached internal SDK events are flushed ahead of it. * * - Parameter precedeWithIdentify: false only for the screen event that already follows an * acknowledged identify, which would otherwise repeat it. @@ -1319,6 +1320,19 @@ extension AnalyticsPublisher { ensureScreenSessionStateMachine() guard let screenSessionStateMachine else { return false } + // Cached internal SDK events go out first. A `screen` message makes the backend + // re-evaluate content for this surface, so a step-seen/completed/dismissed event still + // sitting in the cache would be judged against a screen the backend already answered — a + // dismissal arriving after the fake reload it triggered gets the dismissed content served + // straight back. `processEvent` drains ahead of the event queue, but its single-flight gate + // makes that drain a no-op while another cycle holds it — an event awaiting its ack (the + // post-identify screen push in `onSocketEventSent` runs inside exactly that claimed cycle) + // or an asynchronous offline restore — and the screen still goes out. A persisted offline + // batch keeps its head start: it is published from + // `restoreOfflineEventsIfNeeded()` on the processing cycle that a socket open always runs, + // before any screen message can be pushed from here. + processSDKEvent() + if precedeWithIdentify { publishIdentifyBeforeScreen() } let screenEvent = screenSessionStateMachine.event diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 3cc7134a..b249b607 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -1262,6 +1262,33 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(resynced) } + + // MARK: - Internal SDK Events Ordering Around A Screen Message + + /// A `screen` message makes the backend re-evaluate content for the surface, so a cached + /// dismissal has to be on the wire first — otherwise the fake reload the dismissal itself + /// triggered gets the dismissed content served straight back. + /// + /// The cause reproduced here is the one that does not depend on dispatch: the event is cached + /// while the socket is down, so nothing drains it, and the reload then pushes the screen from + /// `publishScreenEvent`. On device the same gap opens whenever `processEvent`'s single-flight + /// gate is already claimed (e.g. an asynchronous offline restore) and the drain it schedules + /// never runs. + func testFakeReload_shouldPushCachedSDKEventsBeforeTheScreenEvent() { + // Arrange — a live screen session, and a content dismissal cached while the socket was down + arrangeReloadableScreen() + userpilot.socketManager.isSocketOpened = false + analyticsPublisher.publishInternalSDKEvent( + MockSDKEvent(eventName: "dismissed_mobile_content")) + userpilot.socketManager.isSocketOpened = true + let published = recordPublishedEvents() + + // Act — the dismissal drives a fake reload for the current screen + XCTAssertTrue(analyticsPublisher.publishFakeReloadScreenEvent(.flow, 2, isFakeReload: true)) + + // Assert — the dismissal reaches the socket before the screen message + XCTAssertEqual(published(), ["dismissed_mobile_content", Constants.Event.screenEvent]) + } } // swiftlint:enable all From fc07fd343eb9f0e88c55137933990757c60db506 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 11:34:57 +0300 Subject: [PATCH 65/95] =?UTF-8?q?=E2=9C=85=20test(Analytics):=20Pin=20cach?= =?UTF-8?q?ed=20SDK=20event=20retention=20across=20socket=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android had to be fixed here: its onSocketClosed treated a transport error like a teardown and cleared cachedSDKEvents, which are never persisted, so a dismissal cached while offline was lost and the backend served the dismissed content again. iOS is already correct - the clear is tied to logout, not to a socket callback - so this adds no production change, only the two guards that pin the distinction: an error close keeps the cached events for the reconnect, a logout drops them. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisherTests.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index b249b607..b3bd823f 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -587,6 +587,57 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(publishedEvents, ["sdk-event-1", "sdk-event-2"]) } + /// Losing the network with an experience on screen produces an error close, and Phoenix keeps + /// retrying, so the close repeats. Internal SDK events are held only in memory — unlike + /// analytics events they are never written to the offline database — so dropping them on an + /// error close would lose the dismissal for good and the backend would serve the dismissed + /// content again. A transport error is not a teardown: the user has not changed and the socket + /// is expected back. + func testOnSocketClosed_afterASocketError_shouldKeepCachedSDKEventsForTheReconnect() { + // Arrange — the network died and a content dismissal was cached + userpilot.socketManager.isSocketOpened = false + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "content-dismissed")) + XCTAssertTrue(publishedEvents.isEmpty) + + // Act — a reconnect attempt fails and the channel reports the error close + userpilot.socketManager.didCloseFromError = true + analyticsPublisher.onSocketClosed() + + // Act — the connection comes back + userpilot.socketManager.didCloseFromError = false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + // Assert — the dismissal still reaches the backend + XCTAssertEqual(publishedEvents, ["content-dismissed"]) + } + + /// A logout *is* a teardown, so the opposite has to hold too: the previous session's cached + /// SDK events must not leak into the next one. + func testLogout_shouldDropCachedSDKEvents() { + // Arrange — a cached SDK event and a closed socket + userpilot.socketManager.isSocketOpened = false + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "content-dismissed")) + + // Act — the SDK is torn down + analyticsPublisher.logout(clearCachedIdentifyEvent: true) + + // Act — a socket opens for the next session + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + // Assert — the previous session's event does not leak into it + XCTAssertTrue(publishedEvents.isEmpty) + } + func testIsExperienceSeen_shouldUseSeenSetForContentType() throws { // Arrange — the screen session (which owns the seen sets) is only created once the // screen event actually goes out, which requires an open socket. From 939f606d8b6520602fa8873d60ff226bacdf642f Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 12:07:28 +0300 Subject: [PATCH 66/95] =?UTF-8?q?=F0=9F=94=A7=20ci:=20Rename=20the=20CI=20?= =?UTF-8?q?workflow=20to=20Quality=20Gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "CI" was the only acronym among this repo's workflow names - the others are descriptive phrases (Bump iOS SDK Version, Release and Publish to CocoaPods). "Quality Gate" says what the workflow is and stays accurate for both triggers, pull_request and push to main. Job names are untouched, so the required status checks branch protection matches are unchanged. The concurrency group also drops its redundant ci- prefix, which already duplicated github.workflow. Renamed across the iOS and Android SDKs and the React Native wrapper together, so the gate carries one name. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b1f64ab..49e4ca3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ # # Only the SDK is built here — the Sample/ apps are covered by publish-sample.yml. -name: CI +name: Quality Gate on: pull_request: @@ -25,7 +25,7 @@ on: # 🛑 A new push to the same PR/branch supersedes the previous run concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: From b2d8f8d02a2d028cdc9c302bc006a46cf3a7bc01 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 12:40:57 +0300 Subject: [PATCH 67/95] =?UTF-8?q?=F0=9F=94=A7=20ci:=20Rename=20the=20CI=20?= =?UTF-8?q?workflow=20file=20to=20quality-gate.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow is called "Quality Gate"; the file kept its old scaffold name. Renamed with git mv so the run history follows the path, and updated the reference in scripts/release-preflight.sh. Job names are untouched, so the required status checks branch protection matches (Environment, Build & test, SwiftLint, XCFramework, Green gate) are unchanged. GitHub keys run history by file path, so the Actions tab will show the previous runs under a separate, now-inactive entry. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/{ci.yml => quality-gate.yml} | 0 scripts/release-preflight.sh | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{ci.yml => quality-gate.yml} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/quality-gate.yml similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/quality-gate.yml diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 5e962f44..5e4637eb 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -2,7 +2,7 @@ # # Shared green gate for the Userpilot iOS SDK. # -# Local (release-sdk / pre-release-check) and GitHub Actions (ci.yml) run the +# Local (release-sdk / pre-release-check) and GitHub Actions (quality-gate.yml) run the # same steps via this script so a PR cannot skip a check that a release needs. # # scripts/release-preflight.sh From 97fcec68fb10209b9fc9dbc53f67ddd12e4b81fb Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 16:10:40 +0300 Subject: [PATCH 68/95] =?UTF-8?q?=E2=9C=A8=20feat:=20Persist=20and=20repla?= =?UTF-8?q?y=20internal=20SDK=20events=20while=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Experience, survey and NPS lifecycle events plus opened_push_notification are now persisted while offline and replayed in the same chronological batch_events message as the analytics events around them, matching the Android SDK's batch item shape. Adds a StoredOfflineEvent envelope around every stored row, carrying a kind discriminator so an internal row and an analytics row share one store. Excluded on purpose: get_mobile_content and fetch_theme are request/response, user_token already self-heals via resyncPushToken, and user_logout clears the store it would be written into. Co-Authored-By: Claude Opus 5 (1M context) --- .../Analytics/AnalyticsPublisher.swift | 10 + .../Analytics/OfflineEventsHandler.swift | 151 +++++++-- Sources/Userpilot/Database/EventStorage.swift | 13 +- .../Database/StoredOfflineEvent.swift | 95 ++++++ .../Experiences/Events/SDKEvent.swift | 57 +++- .../Analytics/AnalyticsPublisherTests.swift | 60 ++++ .../Analytics/OfflineEventsHandlerTests.swift | 312 ++++++++++++++++++ .../Database/StoredOfflineEventTests.swift | 69 ++++ .../Experiences/SDKEventTests.swift | 63 ++++ Tests/UserpilotTests/MockUserpilot.swift | 15 + 10 files changed, 819 insertions(+), 26 deletions(-) create mode 100644 Sources/Userpilot/Database/StoredOfflineEvent.swift create mode 100644 Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift create mode 100644 Tests/UserpilotTests/Database/StoredOfflineEventTests.swift diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 927ec44d..3f9ec6fc 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -1201,6 +1201,16 @@ extension AnalyticsPublisher { */ func publishInternalSDKEvent(_ sdkEvent: SDKEvent) { tryCatch { + // No network: persist the eligible ones so they replay in the offline batch with the + // analytics events around them, in createdAt order within that batch. The early + // return also skips the openSocket() below, which cannot succeed while offline. An + // ineligible event falls through and keeps the in-memory path — it is neither + // persisted nor dropped. + if offlineEventsHandler.shouldSaveOffline, sdkEvent.isOfflineEligible { + offlineEventsHandler.saveSDKEventToLocalStorage(sdkEvent) + return + } + // Every internal SDK event takes the cached route, never a direct send. The // cache is drained from `processEvent` *after* `restoreOfflineEventsIfNeeded()`, // so a syncing offline batch always reaches the backend first - no gate and no diff --git a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift index 39c95a1f..b4a20cc5 100644 --- a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift +++ b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift @@ -28,6 +28,12 @@ internal protocol OfflineEventsHandling: AnyObject { /// under the new user. func saveEventToLocalStorage(event: Event, clearStoredEventsFirst: Bool) + /// Saves an internal SDK event to local storage when network is unavailable. + /// + /// Unlike `saveEventToLocalStorage` there is no `clearStoredEventsFirst`: an internal + /// event is never an identify event, so an offline user switch cannot arrive here. + func saveSDKEventToLocalStorage(_ sdkEvent: SDKEvent) + /// Restores events from local storage and publishes them as a batch /// - Parameter completion: Optional callback invoked when restoration is complete func restoreEventsFromLocalStorage(completion: (() -> Void)?) @@ -126,16 +132,44 @@ internal class OfflineEventsHandler: OfflineEventsHandling { } // Save this item to local storage so we can retry later if needed - eventDatabaseStorage.saveEvent( + persistOfflineEvent( eventStorage, - completion: { [weak self] saved in - if saved { - self?.logger.info( - "🗃️ Event saved to local storage: %{public}@", event.eventName) - } else { - self?.logger.error("⚠️ Event not saved - storage limit exceeded") - } - }) + eventName: event.eventName, + successMessage: "🗃️ Event saved to local storage: %{public}@", + limitMessage: "⚠️ Event not saved - storage limit exceeded") + } + } + + /** + * Saves an internal SDK event to local storage when network is unavailable. + * + * The event is wrapped in a `StoredOfflineEvent` envelope marked `.internalEvent` so the + * replay path can tell it apart from an analytics row. + * + * There is deliberately no `clearStoredEventsFirst` counterpart to + * `saveEventToLocalStorage`: an internal event is never an identify event, so an offline + * user switch cannot arrive through this path. An `SDKEvent` also carries no user id of + * its own, so an empty `storage.userId` drops the event rather than falling back. + * + * - Parameter sdkEvent: The internal SDK event to save to local storage + */ + func saveSDKEventToLocalStorage(_ sdkEvent: SDKEvent) { + tryCatch { + let userId = storage.userId + guard !userId.isEmpty else { return } + + guard let eventStorage = EventStorage( + StoredOfflineEvent(sdkEvent: sdkEvent), config.token, userId + ) else { + logger.error("⚠️ Failed to encode internal event to JSON") + return + } + + persistOfflineEvent( + eventStorage, + eventName: sdkEvent.eventName, + successMessage: "🗃️ Internal event saved to local storage: %{public}@", + limitMessage: "⚠️ Internal event not saved - storage limit exceeded") } } @@ -184,23 +218,36 @@ internal class OfflineEventsHandler: OfflineEventsHandling { var eventsList: [[String: Any]] = [] for eventStorage in localEvents { - guard let event = eventStorage.toEvent() else { + guard let stored = eventStorage.toStoredEvent(), stored.isSupportedSchema else { self.logger.error("⚠️ Failed to decode event from local storage") continue } var eventData: [String: Any]? - switch event.type { - case .identify: - eventData = self.buildIdentifyEventData( - event: event, eventStorage: eventStorage) - case .screen: - eventData = self.buildScreenEventData( - event: event, eventStorage: eventStorage) - case .event, .autoCaptureEvent: - eventData = self.buildTrackEventData( - event: event, eventStorage: eventStorage) + // The internal check comes first: an internal row's `eventType` + // carries the SDK event name, not an analytics event name, so it + // must never reach a `switch event.type` branch. + if stored.isInternalEvent { + eventData = self.buildInternalEventData( + stored: stored, eventStorage: eventStorage) + } else if let event = stored.event { + switch event.type { + case .identify: + eventData = self.buildIdentifyEventData( + event: event, eventStorage: eventStorage) + case .screen: + eventData = self.buildScreenEventData( + event: event, eventStorage: eventStorage) + case .event, .autoCaptureEvent: + eventData = self.buildTrackEventData( + event: event, eventStorage: eventStorage) + } + } else { + // Decoded cleanly but carries neither an internal payload nor an + // analytics event — nothing this version knows how to replay. + self.logger.error( + "⚠️ Dropping unsupported offline event: %{public}@", stored.eventType) } if let eventData = eventData { @@ -246,6 +293,42 @@ internal class OfflineEventsHandler: OfflineEventsHandling { // MARK: - Private Methods + /** + * Persists an already-encoded row and reports the outcome. + * + * Shared tail of `saveEventToLocalStorage` and `saveSDKEventToLocalStorage`. The two differ + * in how they resolve the user id and build the row; they do not differ in how the row is + * persisted or in the shape of the outcome logging, so a future change to the + * persistence-failure path (a retry, a metric, a dropped-event counter) only has to be made + * here. + * + * `Logging` declares its messages as `StaticString`, so the two log lines cannot be built at + * runtime and are passed in as literals instead — the same way `UPLogger` threads its own + * `StaticString` format down into `os_log`. + * + * - Parameters: + * - eventStorage: The encoded row to persist + * - eventName: Event name interpolated into `successMessage` + * - successMessage: Logged at `info` level when the row was stored + * - limitMessage: Logged at `error` level when the store refused the row + */ + private func persistOfflineEvent( + _ eventStorage: EventStorage, + eventName: String, + successMessage: StaticString, + limitMessage: StaticString + ) { + eventDatabaseStorage.saveEvent( + eventStorage, + completion: { [weak self] saved in + if saved { + self?.logger.info(successMessage, eventName) + } else { + self?.logger.error(limitMessage) + } + }) + } + /** * Builds event data map for identify events. * @@ -320,6 +403,34 @@ internal class OfflineEventsHandler: OfflineEventsHandling { return eventData } + /** + * Builds event data map for an internal SDK event. + * + * The event's own payload is spread at the top level rather than nested under + * `metaDataProperty`: internal payloads are flat maps of JSON primitives and none of them + * carry a key that could collide with `eventTypeProperty` or `createdAtProperty`. The + * Android SDK's `buildInternalEventData` produces the same shape, so this is a + * cross-platform wire contract — do not rename keys or nest the payload. + * + * - Parameters: + * - stored: The stored internal event + * - eventStorage: The stored event metadata + * - Returns: Map containing event data for batch sending + */ + private func buildInternalEventData( + stored: StoredOfflineEvent, + eventStorage: EventStorage + ) -> [String: Any] { + var eventData: [String: Any] = [:] + eventData[Constants.OfflineEvents.eventTypeProperty] = stored.eventType + eventData[Constants.OfflineEvents.createdAtProperty] = formatTimestampWithTimezone( + eventStorage.createdAt) + for (key, value) in stored.payload ?? [:] { + eventData[key] = value + } + return eventData + } + /** * Formats a timestamp (in milliseconds) to ISO-8601 format with timezone information. * diff --git a/Sources/Userpilot/Database/EventStorage.swift b/Sources/Userpilot/Database/EventStorage.swift index 60b5c17d..6944956b 100644 --- a/Sources/Userpilot/Database/EventStorage.swift +++ b/Sources/Userpilot/Database/EventStorage.swift @@ -22,7 +22,11 @@ internal struct EventStorage: Codable { let sizeBytes: Int init?(_ event: Event, _ token: String, _ userId: String) { - guard let data = try? UserpilotEncoder.shared.encode(event) else { + self.init(StoredOfflineEvent(event: event), token, userId) + } + + init?(_ stored: StoredOfflineEvent, _ token: String, _ userId: String) { + guard let data = try? UserpilotEncoder.shared.encode(stored) else { return nil } self.requestId = UUID() @@ -33,12 +37,11 @@ internal struct EventStorage: Codable { self.sizeBytes = data.count } - /// Decodes the stored event data back to an Event object - func toEvent() -> Event? { + /// Decodes the stored envelope back to a `StoredOfflineEvent`. + func toStoredEvent() -> StoredOfflineEvent? { do { - return try UserpilotDecoder.shared.decode(Event.self, from: data) + return try UserpilotDecoder.shared.decode(StoredOfflineEvent.self, from: data) } catch { - print("❌ Failed to decode Event:", error) return nil } } diff --git a/Sources/Userpilot/Database/StoredOfflineEvent.swift b/Sources/Userpilot/Database/StoredOfflineEvent.swift new file mode 100644 index 00000000..7810ad03 --- /dev/null +++ b/Sources/Userpilot/Database/StoredOfflineEvent.swift @@ -0,0 +1,95 @@ +// +// StoredOfflineEvent.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// +// Stable JSON payload stored inside the offline events database. +// Mirrors the Android `StoredOfflineEvent` so both platforms replay the same batch shape. +// + +import Foundation + +/// Which pipeline a stored offline row came from. +internal enum StoredOfflineEventKind: String, Codable { + case analytics + /// `internal` is a Swift keyword, so the case is spelled differently from its raw value. + case internalEvent = "internal" +} + +/// The envelope persisted inside `EventStorage.data`. +/// +/// An analytics row nests the existing `Codable` `Event`; an internal row carries the SDK +/// event's flat payload. The discriminator lives here rather than in a stored column so no +/// database schema change is needed. +internal struct StoredOfflineEvent { + static let schemaVersionValue = 1 + + let schemaVersion: Int + let kind: StoredOfflineEventKind + let eventType: String + let event: Event? + let payload: [String: Any]? + + var isInternalEvent: Bool { kind == .internalEvent } + + var isSupportedSchema: Bool { schemaVersion == Self.schemaVersionValue } + + init(event: Event) { + self.schemaVersion = Self.schemaVersionValue + self.kind = .analytics + self.eventType = event.eventName + self.event = event + self.payload = nil + } + + init(sdkEvent: SDKEvent) { + self.schemaVersion = Self.schemaVersionValue + self.kind = .internalEvent + self.eventType = sdkEvent.eventName + self.event = nil + self.payload = sdkEvent.eventPayload + } +} + +// MARK: - Codable Conformance + +extension StoredOfflineEvent: Codable { + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case kind + case eventType = "event_type" + case event + case payload + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(kind, forKey: .kind) + try container.encode(eventType, forKey: .eventType) + try container.encodeIfPresent(event, forKey: .event) + + // Encoded directly rather than through a JSONSerialization round-trip: that round-trip + // turns Bool into __NSCFBoolean, which AnyCodable matches as Int and writes out as 0/1. + if let payload = payload { + try container.encode(payload.mapValues { AnyCodable($0) }, forKey: .payload) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) + ?? Self.schemaVersionValue + kind = try container.decodeIfPresent(StoredOfflineEventKind.self, forKey: .kind) ?? .analytics + eventType = try container.decode(String.self, forKey: .eventType) + event = try container.decodeIfPresent(Event.self, forKey: .event) + + if let payloadDict = try container.decodeIfPresent([String: AnyCodable].self, forKey: .payload) { + payload = payloadDict.mapValues { $0.value } + } else { + payload = nil + } + } +} diff --git a/Sources/Userpilot/Experiences/Events/SDKEvent.swift b/Sources/Userpilot/Experiences/Events/SDKEvent.swift index f838afa6..11e12b59 100644 --- a/Sources/Userpilot/Experiences/Events/SDKEvent.swift +++ b/Sources/Userpilot/Experiences/Events/SDKEvent.swift @@ -76,7 +76,20 @@ extension SDKEvent { } -internal enum SDKEventsName: String { +/// Used to decide which internal events survive an offline period +extension SDKEvent { + + /// Whether this event is persisted while offline and replayed in the `batch_events` batch. + /// + /// Resolves to false for any name the enum does not know, so an event coming from outside + /// `SDKEventsName` is excluded until someone deliberately opts it in. + var isOfflineEligible: Bool { + return SDKEventsName(rawValue: self.eventName)?.isOfflineEligible ?? false + } + +} + +internal enum SDKEventsName: String, CaseIterable { case fetchExperienceContent = "get_mobile_content" case fetchExperienceTheme = "fetch_theme" @@ -102,3 +115,45 @@ internal enum SDKEventsName: String { case pushNotificationOpened = "opened_push_notification" case userLogout = "user_logout" } + +extension SDKEventsName { + + /// Internal events worth persisting while offline. Must stay identical to the Android SDK's set. + /// + /// Excluded on purpose: + /// - `fetchExperienceContent` / `fetchExperienceTheme`: request/response, a stale replay asks the + /// backend to re-answer a question with no consumer left. + /// - `pushNotificationToken`: already self-heals through `resyncPushToken()` on socket open; + /// replaying would send a stale token instead of the current one. + /// - `userLogout`: logout also clears the offline store it would be written into. + /// + /// Every case is listed explicitly with no `default:`, so a newly added event fails to compile + /// until someone decides which side it belongs on. + var isOfflineEligible: Bool { + switch self { + case .flowExperienceSeen, + .flowExperienceDismissed, + .flowExperienceCompleted, + .flowExperienceStepSeen, + .flowExperienceStepCompleted, + .surveyExperienceSeen, + .surveyExperienceDismissed, + .surveyExperienceCompleted, + .surveyExperienceSubmitted, + .surveyExperienceStepSeen, + .surveyExperienceStepSkipped, + .surveyExperienceStepSubmitted, + .npsExperienceSeen, + .npsExperienceDismissed, + .npsExperienceSubmitted, + .pushNotificationOpened: + return true + case .fetchExperienceContent, + .fetchExperienceTheme, + .pushNotificationToken, + .userLogout: + return false + } + } + +} diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index b3bd823f..984ba9b7 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -1340,6 +1340,66 @@ class AnalyticsPublisherTests: XCTestCase { // Assert — the dismissal reaches the socket before the screen message XCTAssertEqual(published(), ["dismissed_mobile_content", Constants.Event.screenEvent]) } + + // MARK: - Internal SDK Events Offline Gate + + // The three tests below assert synchronously on purpose. `publishInternalSDKEvent` + // (`Sources/Userpilot/Analytics/AnalyticsPublisher.swift:1202`) runs its whole body on the + // calling thread: `tryCatch` (`Sources/Userpilot/Utilities/Helper/Utils.swift:149`) is a plain + // non-escaping `try?` wrapper, and the gate sits ahead of every dispatch in the method, so the + // mock has already been touched by the time the call returns. + // + // Each one also pins the *other* branch — whether `openSocket()` ran — so the `isEmpty` + // assertions cannot pass by the event having been dropped on the floor. + + func testPublishInternalSDKEvent_persistsAnEligibleEventWhileOffline() throws { + let offlineHandler = try XCTUnwrap( + userpilot.container.resolve(OfflineEventsHandling.self) as? MockOfflineEventsHandler) + offlineHandler.shouldSaveOffline = true + var didOpenSocket = false + userpilot.socketManager.onConnect = { didOpenSocket = true } + let sdkEvent = MockSDKEvent( + eventName: "seen_mobile_content_step", + eventPayload: ["mobile_content_id": 42] + ) + + analyticsPublisher.publishInternalSDKEvent(sdkEvent) + + XCTAssertEqual(offlineHandler.savedSDKEvents.count, 1) + XCTAssertEqual(offlineHandler.savedSDKEvents.first?.eventName, "seen_mobile_content_step") + // The early return is load-bearing beyond the persist: reopening the socket cannot + // succeed with no network. + XCTAssertFalse(didOpenSocket) + } + + func testPublishInternalSDKEvent_keepsTheInMemoryPathForAnIneligibleEventWhileOffline() throws { + let offlineHandler = try XCTUnwrap( + userpilot.container.resolve(OfflineEventsHandling.self) as? MockOfflineEventsHandler) + offlineHandler.shouldSaveOffline = true + var didOpenSocket = false + userpilot.socketManager.onConnect = { didOpenSocket = true } + + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_content")) + + XCTAssertTrue(offlineHandler.savedSDKEvents.isEmpty) + // Neither persisted nor dropped: a request/response event still takes the cached route + // and still asks for a socket, so it retries live on reconnect. + XCTAssertTrue(didOpenSocket) + } + + func testPublishInternalSDKEvent_doesNotPersistWhenTheNetworkIsAvailable() throws { + let offlineHandler = try XCTUnwrap( + userpilot.container.resolve(OfflineEventsHandling.self) as? MockOfflineEventsHandler) + offlineHandler.shouldSaveOffline = false + var didOpenSocket = false + userpilot.socketManager.onConnect = { didOpenSocket = true } + + analyticsPublisher.publishInternalSDKEvent( + MockSDKEvent(eventName: "seen_mobile_content_step", eventPayload: ["mobile_content_id": 42])) + + XCTAssertTrue(offlineHandler.savedSDKEvents.isEmpty) + XCTAssertTrue(didOpenSocket) + } } // swiftlint:enable all diff --git a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift new file mode 100644 index 00000000..89ba589f --- /dev/null +++ b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift @@ -0,0 +1,312 @@ +// +// OfflineEventsHandlerTests.swift +// Userpilot SDK +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all + +class OfflineEventsHandlerTests: XCTestCase { + + var handler: OfflineEventsHandler! + var userpilot: MockUserpilot! + var eventStore: MockEventStorage! + var storage: MockStorage! + var socketManager: MockSocketManager! + var logger: MockLogger! + + override func setUpWithError() throws { + super.setUp() + let config = Userpilot.Config(token: "NX-00000") + logger = MockLogger() + config.logger = logger + userpilot = MockUserpilot(config: config) + eventStore = try XCTUnwrap(userpilot.container.resolve(EventStoring.self) as? MockEventStorage) + storage = try XCTUnwrap(userpilot.container.resolve(DataStoring.self) as? MockStorage) + socketManager = try XCTUnwrap( + userpilot.container.resolve(SocketManaging.self) as? MockSocketManager) + storage.userId = "user-1" + handler = OfflineEventsHandler(container: userpilot.container) + } + + override func tearDown() { + socketManager?.onPublish = nil + handler = nil + eventStore = nil + storage = nil + socketManager = nil + logger = nil + userpilot = nil + super.tearDown() + } + + func testSaveSDKEvent_persistsAnInternalRow() throws { + let sdkEvent = MockSDKEvent( + eventName: "seen_mobile_content_step", + eventPayload: ["mobile_content_id": 42, "step_id": 7] + ) + + handler.saveSDKEventToLocalStorage(sdkEvent) + + XCTAssertEqual(eventStore.events.count, 1) + let stored = try XCTUnwrap(eventStore.events.first?.toStoredEvent()) + XCTAssertTrue(stored.isInternalEvent) + XCTAssertEqual(stored.kind, .internalEvent) + XCTAssertEqual(stored.eventType, "seen_mobile_content_step") + XCTAssertNil(stored.event) + XCTAssertEqual(stored.payload?["mobile_content_id"] as? Int, 42) + XCTAssertEqual(stored.payload?["step_id"] as? Int, 7) + XCTAssertEqual(eventStore.events.first?.userId, "user-1") + XCTAssertEqual(eventStore.events.first?.token, "NX-00000") + } + + func testSaveSDKEvent_dropsTheEventWhenThereIsNoUserId() { + // Prove the identical call DOES write while `storage.userId` is set, so the + // empty assertion below is attributable to the guard rather than to a call + // that would never have written anything. + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + XCTAssertEqual(eventStore.events.count, 1) + eventStore.events.removeAll() + + storage.userId = "" + + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + + XCTAssertTrue(eventStore.events.isEmpty) + // The guard returns before touching the store at all — not by deleting rows. + XCTAssertFalse(eventStore.didDeleteAllEvents) + } + + func testSaveSDKEvent_persistsAlongsideAnalyticsRows() throws { + handler.saveEventToLocalStorage(event: Event(type: .event("Purchase"))) + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + + XCTAssertEqual(eventStore.events.count, 2) + let analytics = try XCTUnwrap(eventStore.events.first?.toStoredEvent()) + let internalRow = try XCTUnwrap(eventStore.events.last?.toStoredEvent()) + XCTAssertFalse(analytics.isInternalEvent) + XCTAssertTrue(internalRow.isInternalEvent) + } + + func testClearLocalEvents_removesInternalRowsToo() { + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + XCTAssertEqual(eventStore.events.count, 1) + + handler.clearLocalEvents() + + XCTAssertTrue(eventStore.events.isEmpty) + XCTAssertTrue(eventStore.didDeleteAllEvents) + } + + // MARK: - Shared Persist Tail + + func testSave_logsADistinctSuccessMessagePerPath() { + handler.saveEventToLocalStorage(event: Event(type: .event("Purchase"))) + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + + XCTAssertEqual(eventStore.events.count, 2) + // Both paths share one persist-and-log helper; the analytics and internal wording must + // stay distinguishable through it rather than collapsing into one generic line. + XCTAssertTrue(logger.loggedInfos.contains { $0.hasPrefix("🗃️ Event saved to local storage:") }) + XCTAssertTrue( + logger.loggedInfos.contains { $0.hasPrefix("🗃️ Internal event saved to local storage:") }) + } + + func testSave_logsTheLimitExceededMessagePerPathWhenTheStoreRefusesTheRow() { + eventStore.saveEventResult = false + + handler.saveEventToLocalStorage(event: Event(type: .event("Purchase"))) + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + + XCTAssertTrue(eventStore.events.isEmpty) + XCTAssertTrue(logger.loggedErrors.contains("⚠️ Event not saved - storage limit exceeded")) + XCTAssertTrue( + logger.loggedErrors.contains("⚠️ Internal event not saved - storage limit exceeded")) + // Neither path may claim success while the store is refusing rows. + XCTAssertFalse(logger.loggedInfos.contains { $0.contains("saved to local storage") }) + } + + // MARK: - Restore Helpers + + /// Runs a restore and returns the items of the `batch_events` payload it publishes. + /// + /// `MockSocketManager` records nothing of its own — it only forwards to its `onPublish` + /// closure — so the batch is captured through that closure, which doubles as the fulfilment + /// signal and lets the test proceed the instant the work is done. The timeout is only a + /// failure bound. The handler's own completion is unusable here: once a batch is published + /// the completion is parked in `offlineRestoreCompletion` until the socket ACKs, and the + /// mock never ACKs. + private func captureRestoredBatch( + file: StaticString = #filePath, + line: UInt = #line + ) throws -> [[String: Any]] { + let published = expectation(description: "batch_events published") + var captured: [String: Any]? + socketManager.onPublish = { eventName, payload in + guard eventName == Constants.Event.batchEventsEvent else { return } + captured = payload + published.fulfill() + } + + handler.restoreEventsFromLocalStorage { } + wait(for: [published], timeout: 2.0) + + let batch = try XCTUnwrap(captured, file: file, line: line) + return try XCTUnwrap( + batch[Constants.OfflineEvents.eventsProperty] as? [[String: Any]], file: file, line: line) + } + + /// Runs a restore and returns every socket event name published while it ran. + /// + /// Synchronises on the restore completion rather than on `onPublish`, because a test that + /// expects NO publish gets no `onPublish` callback to wait on. Reaching the completion is + /// itself evidence: the handler only calls it inline when it decided not to publish, so if a + /// batch had gone out this would time out rather than pass. + private func publishedEventNamesDuringRestore() -> [String] { + var publishedEventNames: [String] = [] + socketManager.onPublish = { eventName, _ in publishedEventNames.append(eventName) } + + let finished = expectation(description: "restore finished") + handler.restoreEventsFromLocalStorage { finished.fulfill() } + wait(for: [finished], timeout: 2.0) + + return publishedEventNames + } + + /// Builds a stored row straight from a raw envelope body, so a test can inject JSON the + /// production encoder can never emit — an unknown `kind`, or an analytics row carrying no + /// nested `event`. `EventStorage` has no memberwise initializer (both of its initializers + /// encode a valid envelope), so the row is built through its own `Codable` conformance rather + /// than by widening the production API for tests. + private func makeStoredRow(rawEnvelope: String) throws -> EventStorage { + let body = Data(rawEnvelope.utf8) + let row: [String: Any] = [ + "requestId": UUID().uuidString, + "token": "NX-00000", + "userId": "user-1", + "data": body.base64EncodedString(), + "createdAt": 1_000, + "sizeBytes": body.count + ] + return try UserpilotDecoder.shared.decode( + EventStorage.self, from: JSONSerialization.data(withJSONObject: row)) + } + + /// An analytics-kind row with no nested `event`: it decodes cleanly, so it is not a decode + /// failure — it is a row this SDK version has no way to replay. + private static let unsupportedEnvelope = + #"{"schema_version":1,"kind":"analytics","event_type":"whatever"}"# + + // MARK: - Internal Event Replay + + func testRestore_spreadsTheInternalPayloadAtTheTopLevelOfTheBatchItem() throws { + handler.saveSDKEventToLocalStorage( + MockSDKEvent( + eventName: "seen_mobile_content_step", + eventPayload: ["mobile_content_id": 42, "step_id": 7] + ) + ) + + let events = try captureRestoredBatch() + + XCTAssertEqual(events.count, 1) + let item = try XCTUnwrap(events.first) + XCTAssertEqual( + item[Constants.OfflineEvents.eventTypeProperty] as? String, + "seen_mobile_content_step") + + // Same ISO-8601-with-timezone format the analytics items use. + let createdAt = try XCTUnwrap(item[Constants.OfflineEvents.createdAtProperty] as? String) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + XCTAssertNotNil(formatter.date(from: createdAt)) + + // Spread flat, NOT nested under `metadata` the way an analytics item is. This is the + // cross-platform wire contract — Android's `buildInternalEventData` emits the same keys. + XCTAssertEqual(item["mobile_content_id"] as? Int, 42) + XCTAssertEqual(item["step_id"] as? Int, 7) + XCTAssertNil(item[Constants.Analytics.metaDataProperty]) + XCTAssertEqual( + Set(item.keys), + Set(["event_type", "created_at", "mobile_content_id", "step_id"])) + } + + func testRestore_preservesTheOrderTheStoreReturnsForMixedRows() throws { + handler.saveEventToLocalStorage( + event: Event(type: .screen("Home")), clearStoredEventsFirst: false) + handler.saveSDKEventToLocalStorage( + MockSDKEvent( + eventName: "dismissed_mobile_content", eventPayload: ["mobile_content_id": 42])) + handler.saveEventToLocalStorage( + event: Event(type: .event("purchase")), clearStoredEventsFirst: false) + + let events = try captureRestoredBatch() + + // The store hands rows back in insertion (createdAt) order; the handler must preserve + // that order rather than sort or group by kind. + XCTAssertEqual( + events.compactMap { $0[Constants.OfflineEvents.eventTypeProperty] as? String }, + ["screen", "dismissed_mobile_content", "track"] + ) + // The internal item stays flat while the analytics items keep their nested metadata. + let eventType = Constants.OfflineEvents.eventTypeProperty + let internalItem = events.first { $0[eventType] as? String == "dismissed_mobile_content" } + XCTAssertEqual(internalItem?["mobile_content_id"] as? Int, 42) + XCTAssertNil(internalItem?[Constants.Analytics.metaDataProperty]) + let analyticsItems = events.filter { $0[eventType] as? String != "dismissed_mobile_content" } + XCTAssertEqual(analyticsItems.count, 2) + XCTAssertTrue(analyticsItems.allSatisfy { $0[Constants.Analytics.metaDataProperty] != nil }) + } + + func testRestore_dropsAnUnsupportedRowAndStillPublishesTheRest() throws { + handler.saveSDKEventToLocalStorage(MockSDKEvent(eventName: "seen_survey")) + eventStore.events.append( + try makeStoredRow(rawEnvelope: Self.unsupportedEnvelope)) + XCTAssertEqual(eventStore.events.count, 2) + + let events = try captureRestoredBatch() + + XCTAssertEqual(events.count, 1) + XCTAssertEqual( + events.first?[Constants.OfflineEvents.eventTypeProperty] as? String, "seen_survey") + // An unsupported row must not be reported as a decode failure. + XCTAssertTrue(logger.loggedErrors.contains { $0.contains("Dropping unsupported offline event") }) + XCTAssertFalse( + logger.loggedErrors.contains { $0.contains("Failed to decode event from local storage") }) + } + + func testRestore_publishesNothingWhenEveryRowIsUnsupported() throws { + eventStore.events = [try makeStoredRow(rawEnvelope: Self.unsupportedEnvelope)] + + let publishedEventNames = publishedEventNamesDuringRestore() + + XCTAssertTrue(publishedEventNames.isEmpty) + // Non-vacuity: the row genuinely went through the loop rather than the restore + // short-circuiting on an empty store. It was read and deleted, the handler logged that it + // was restoring one row, and it took the unsupported branch. + XCTAssertTrue(eventStore.events.isEmpty) + XCTAssertTrue(logger.loggedInfos.contains { $0.contains("Restoring") }) + XCTAssertTrue(logger.loggedErrors.contains { $0.contains("Dropping unsupported offline event") }) + } + + func testRestore_logsADecodeFailureRatherThanUnsupportedForUndecodableRowData() throws { + // `martian` is not a `StoredOfflineEventKind`, so the envelope itself fails to decode. + eventStore.events = [ + try makeStoredRow( + rawEnvelope: #"{"schema_version":1,"kind":"martian","event_type":"whatever"}"#) + ] + + let publishedEventNames = publishedEventNamesDuringRestore() + + XCTAssertTrue(publishedEventNames.isEmpty) + XCTAssertTrue(eventStore.events.isEmpty) + XCTAssertTrue(logger.loggedInfos.contains { $0.contains("Restoring") }) + XCTAssertTrue( + logger.loggedErrors.contains { $0.contains("Failed to decode event from local storage") }) + XCTAssertFalse(logger.loggedErrors.contains { $0.contains("Dropping unsupported offline event") }) + } +} + +// swiftlint:enable all diff --git a/Tests/UserpilotTests/Database/StoredOfflineEventTests.swift b/Tests/UserpilotTests/Database/StoredOfflineEventTests.swift new file mode 100644 index 00000000..36407a17 --- /dev/null +++ b/Tests/UserpilotTests/Database/StoredOfflineEventTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import Userpilot + +// swiftlint:disable all + +class StoredOfflineEventTests: XCTestCase { + + private func encodeDecode(_ stored: StoredOfflineEvent) throws -> StoredOfflineEvent { + let data = try UserpilotEncoder.shared.encode(stored) + return try UserpilotDecoder.shared.decode(StoredOfflineEvent.self, from: data) + } + + func testInternalEnvelope_roundTrips_preservingIntegerPayloadValues() throws { + let sdkEvent = MockSDKEvent( + eventName: "seen_mobile_content_step", + eventPayload: ["mobile_content_id": 42, "step_id": 7] + ) + + let restored = try encodeDecode(StoredOfflineEvent(sdkEvent: sdkEvent)) + + XCTAssertTrue(restored.isInternalEvent) + XCTAssertEqual(restored.eventType, "seen_mobile_content_step") + // AnyCodable decodes Int before Double, so ids do not become 42.0 on the wire. + XCTAssertEqual(restored.payload?["mobile_content_id"] as? Int, 42) + XCTAssertEqual(restored.payload?["step_id"] as? Int, 7) + } + + func testInternalEnvelope_preservesMixedPrimitiveTypes() throws { + let sdkEvent = MockSDKEvent( + eventName: "NPS_feedback", + eventPayload: [ + "score": 9, + "survey_question_key": "q1", + "feedback": "great", + "rating": 4.5, + "skipped": false + ] + ) + + let restored = try encodeDecode(StoredOfflineEvent(sdkEvent: sdkEvent)) + + XCTAssertEqual(restored.payload?["score"] as? Int, 9) + XCTAssertEqual(restored.payload?["survey_question_key"] as? String, "q1") + XCTAssertEqual(restored.payload?["rating"] as? Double, 4.5) + XCTAssertEqual(restored.payload?["skipped"] as? Bool, false) + } + + func testAnalyticsEnvelope_roundTripsTheNestedEvent() throws { + let event = Event(type: .screen("Home")) + + let restored = try encodeDecode(StoredOfflineEvent(event: event)) + + XCTAssertFalse(restored.isInternalEvent) + XCTAssertEqual(restored.event?.screenTitle, "Home") + } + + func testEventStorage_roundTripsAnAnalyticsEventThroughTheEnvelope() throws { + let event = Event(type: .event("purchase")) + let storage = try XCTUnwrap(EventStorage(event, "NX-00000", "user-1")) + + let stored = try XCTUnwrap(storage.toStoredEvent()) + + XCTAssertFalse(stored.isInternalEvent) + XCTAssertEqual(stored.event?.eventTitle, "purchase") + XCTAssertGreaterThan(storage.sizeBytes, 0) + } +} + +// swiftlint:enable all diff --git a/Tests/UserpilotTests/Experiences/SDKEventTests.swift b/Tests/UserpilotTests/Experiences/SDKEventTests.swift index b65ca377..cea61c78 100644 --- a/Tests/UserpilotTests/Experiences/SDKEventTests.swift +++ b/Tests/UserpilotTests/Experiences/SDKEventTests.swift @@ -134,4 +134,67 @@ final class SDKEventTests: XCTestCase { XCTAssertEqual(logout.eventName, SDKEventsName.userLogout.rawValue) XCTAssertEqual(logout.eventPayload["user_id"] as? String, "user-1") } + + /// Mirrors the Android SDK's offline-eligible set; the two must stay identical. + private static let offlineEligibleEventNames: Set = [ + "seen_mobile_content", + "dismissed_mobile_content", + "complete_mobile_content", + "seen_mobile_content_step", + "completed_mobile_content_step", + "seen_survey", + "dismissed_survey", + "completed_survey", + "completed_survey_module_batch", + "seen_survey_module", + "skipped_survey_module", + "completed_survey_module", + "seen_NPS", + "dismiss_NPS", + "NPS_feedback", + "opened_push_notification" + ] + + private static let offlineIneligibleEventNames: Set = [ + "get_mobile_content", + "fetch_theme", + "user_token", + "user_logout" + ] + + func testOfflineEligibleEventNamesAreExactlyTheCrossPlatformSet() { + let eligible = Set(SDKEventsName.allCases.filter { $0.isOfflineEligible }.map { $0.rawValue }) + + XCTAssertEqual(eligible, Self.offlineEligibleEventNames) + XCTAssertEqual(Self.offlineEligibleEventNames.count, 16) + XCTAssertEqual( + Set(SDKEventsName.allCases.map { $0.rawValue }), + Self.offlineEligibleEventNames.union(Self.offlineIneligibleEventNames) + ) + } + + func testEachOfflineEligibleEventResolvesTrueThroughSDKEvent() { + for name in Self.offlineEligibleEventNames.sorted() { + XCTAssertTrue( + MockSDKEvent(eventName: name).isOfflineEligible, + "\(name) should be offline eligible" + ) + } + } + + func testEachIneligibleEventResolvesFalseThroughSDKEvent() { + for name in Self.offlineIneligibleEventNames.sorted() { + XCTAssertFalse( + MockSDKEvent(eventName: name).isOfflineEligible, + "\(name) should not be offline eligible" + ) + } + } + + func testUnknownEventNameIsNotOfflineEligible() { + XCTAssertFalse( + MockSDKEvent(eventName: "something_new").isOfflineEligible, + "something_new is not a known SDK event and must not be offline eligible" + ) + } } diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 482ecd6f..ae79dbd3 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -446,6 +446,7 @@ class MockOfflineEventsHandler: OfflineEventsHandling { var shouldSaveOffline: Bool = false var hasCachedEvents: Bool = false var savedEvents: [(event: Event, clearStoredEventsFirst: Bool)] = [] + var savedSDKEvents: [SDKEvent] = [] var didRestoreEvents = false var didClearLocalEvents = false @@ -459,6 +460,10 @@ class MockOfflineEventsHandler: OfflineEventsHandling { savedEvents.append((event, clearStoredEventsFirst)) } + func saveSDKEventToLocalStorage(_ sdkEvent: SDKEvent) { + savedSDKEvents.append(sdkEvent) + } + func restoreEventsFromLocalStorage(completion: (() -> Void)?) { didRestoreEvents = true // The real handler deletes the rows as it reads them @@ -479,7 +484,9 @@ class MockOfflineEventsHandler: OfflineEventsHandling { func clearLocalEvents() { didClearLocalEvents = true + // The real handler deletes every row, internal ones included. savedEvents.removeAll() + savedSDKEvents.removeAll() } } @@ -489,7 +496,15 @@ class MockEventStorage: EventStoring { var events: [EventStorage] = [] var didDeleteAllEvents = false + /// Set to false to stand in for a full store: `saveEvent` then refuses the row the way the + /// real storage does once a limit is reached. Defaults to true, so no existing test changes. + var saveEventResult = true + func saveEvent(_ activity: EventStorage, completion: @escaping (Bool) -> Void) { + guard saveEventResult else { + completion(false) + return + } events.append(activity) completion(true) } From 72264a0751a8d3f7506a9105cb88fec4dec71432 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 16:11:01 +0300 Subject: [PATCH 69/95] =?UTF-8?q?=F0=9F=90=9B=20fix(PushNotification):=20D?= =?UTF-8?q?o=20not=20gate=20notification=20taps=20on=20the=20socket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processNotification deferred both the open event and the deeplink behind canRequestEvent. That gate existed for the analytics event alone - on Android its field was literally named pushNotificationOpenedEvent - and swept the deeplink along by accident, so a tap in airplane mode navigated nowhere until the socket came back. Both steps now run immediately: publishInternalSDKEvent caches or persists the event itself, and the deeplink is client-side navigation through LinkOpener. Removes the now-dead deferredNotification state and attemptDeferredNotificationResponse() from the internal PushNotificationMonitoring protocol; PushNotificationAutoConfig's own cache-and-replay is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../PushNotificationMonitor.swift | 73 ++++++------------ Tests/UserpilotTests/MockUserpilot.swift | 6 -- .../PushNotificationAutoConfigTests.swift | 16 +++- .../PushNotificationDeferredTests.swift | 75 ------------------- .../PushNotificationHandlingTests.swift | 16 +++- 5 files changed, 48 insertions(+), 138 deletions(-) delete mode 100644 Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift diff --git a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift index f1307121..832e33b7 100644 --- a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift +++ b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift @@ -46,12 +46,6 @@ internal protocol PushNotificationMonitoring: AnyObject { /// /// - Returns: A boolean indicating whether the notification was successfully handled. func didReceiveNotification(response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool - - /// Attempts to handle a deferred notification response. - /// - /// - Returns: A boolean indicating whether the deferred response was successfully processed. - @discardableResult - func attemptDeferredNotificationResponse() -> Bool } /// `PushNotificationMonitor` is responsible for managing push notifications, @@ -79,7 +73,6 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr pushAuthorizationStatus == .authorized && storage.pushToken.isNotEmpty } - private var deferredNotification: UserpilotNotification? // Later, we could make this as configuration option, // to request the permission many times till we get it. private var didRequestPermissions = false @@ -176,7 +169,6 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr if let cachedToken { setPushToken(cachedToken) } - attemptDeferredNotificationResponse() } // MARK: - Refresh Push Status @@ -284,12 +276,11 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr return false } - // Handle deferred notification if analytics event is not yet allowed - guard analyticsPublisher.canRequestEvent else { - deferredNotification = parsedNotification - completionHandler?() - return true - } + // Neither step below needs an open socket: publishInternalSDKEvent caches the event while + // the socket is closed and persists it to the offline store while the network is down, + // and the deeplink is pure client-side navigation through LinkOpener. Reporting here also + // keeps `created_at` at tap time rather than reconnect time. + reportNotificationOpened(parsedNotification) // Process the notification and respond accordingly executeNotificationResponse( @@ -301,37 +292,6 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr return true } - /// Attempts to process a deferred notification response if it was previously deferred. - /// - /// - Returns: A boolean indicating whether the deferred response was successfully processed. - @discardableResult - func attemptDeferredNotificationResponse() -> Bool { - guard - let parsedNotification = deferredNotification, - let userpilot = userpilot - else { return false } - - defer { deferredNotification = nil } - - if let appToken = parsedNotification.appToken, !appToken.isEmpty, appToken != config.token { - config.logger.info("Deferred notification response skipped") - return false - } - - guard parsedNotification.userId == storage.userId else { - config.logger.info("Deferred notification response skipped") - return false - } - - executeNotificationResponse( - userpilot: userpilot, - parsedNotification: parsedNotification, - completionHandler: nil - ) - - return true - } - /// Executes the appropriate response to a received notification. /// /// - Parameters: @@ -343,13 +303,6 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr parsedNotification: UserpilotNotification, completionHandler: (() -> Void)? = nil ) { - if parsedNotification.isTest != "true" { - let properties: [String: Any] = ["notification_id": Int(parsedNotification.notificationId) ?? 0] - analyticsPublisher.publishInternalSDKEvent( - PushNotificationOpenedEvent(payload: properties) - ) - } - if let url = parsedNotification.deeplink { linkOpener.handleURL(url) } @@ -357,6 +310,22 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr completionHandler?() } + /// Publishes the `opened_push_notification` event for a tap. + /// + /// Called from `processNotification(_:completionHandler:)` the moment the tap arrives, + /// whatever the connection state — `publishInternalSDKEvent` caches it while the socket is + /// closed and persists it to the offline store while the network is down. + /// + /// - Parameter parsedNotification: The validated notification that was opened. + private func reportNotificationOpened(_ parsedNotification: UserpilotNotification) { + guard parsedNotification.isTest != "true" else { return } + + let properties: [String: Any] = ["notification_id": Int(parsedNotification.notificationId) ?? 0] + analyticsPublisher.publishInternalSDKEvent( + PushNotificationOpenedEvent(payload: properties) + ) + } + } #if DEBUG diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index ae79dbd3..e7f2d502 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -411,12 +411,6 @@ class MockPushNotificationMonitor: PushNotificationMonitoring { } return result } - - var onAttemptDeferredNotificationResponse: (() -> Void)? - func attemptDeferredNotificationResponse() -> Bool { - onAttemptDeferredNotificationResponse?() - return false - } } // MARK: - Mock Network Monitor diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift index 2291bb11..273bef4a 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift @@ -33,8 +33,8 @@ final class PushNotificationAutoConfigTests: PushNotificationMonitorTestCase { config: Userpilot.Config(token: lateToken).defaultInstance(false) ) lateUserpilot.storage.userId = "default-00000" + // Socket still closed when the replay lands — the response must be acted on anyway. lateUserpilot.analyticsPublisher.canRequestEvent = false - let lateMonitor = PushNotificationMonitor(container: lateUserpilot.container) let linkOpened = expectation(description: "deep link opened") lateUserpilot.linkOpener.onHandleURL = { url in @@ -42,8 +42,9 @@ final class PushNotificationAutoConfigTests: PushNotificationMonitorTestCase { linkOpened.fulfill() } - // Assert: the response was held for that instance, not dropped. - XCTAssertTrue(lateMonitor.attemptDeferredNotificationResponse()) + // Assert: registering replays the held response straight through, not dropped and not + // parked waiting for a socket. + _ = PushNotificationMonitor(container: lateUserpilot.container) wait(for: [linkOpened], timeout: 1.0) } @@ -54,10 +55,17 @@ final class PushNotificationAutoConfigTests: PushNotificationMonitorTestCase { UNNotificationResponse.mock(userInfo: userpilotPushNotification()) ) userpilot.storage.userId = "default-00000" + // Socket closed: the response is still handled, it no longer waits for a connection. userpilot.analyticsPublisher.canRequestEvent = false + let linkOpened = expectation(description: "deep link opened") + userpilot.linkOpener.onHandleURL = { url in + XCTAssertEqual(url.absoluteString, "app://some-link") + linkOpened.fulfill() + } + PushNotificationAutoConfig.didReceive(response) {} - XCTAssertTrue(pushNotificationMonitor.attemptDeferredNotificationResponse()) + wait(for: [linkOpened], timeout: 1.0) } } diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift deleted file mode 100644 index a136f5a1..00000000 --- a/Tests/UserpilotTests/PushNotification/PushNotificationDeferredTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// PushNotificationDeferredTests.swift -// Userpilot SDK -// - -import XCTest -@testable import Userpilot - -final class PushNotificationDeferredTests: PushNotificationMonitorTestCase { - - func testAttemptDeferredNotificationResponse_handlesDeferred_whenUserMatches() throws { - // Arrange - let userInfo = userpilotPushNotification() - - let analyticsExpectation = expectation(description: "push open event") - let linkCompletionExpectation = expectation(description: "link opened") - let completionExpectation = expectation(description: "completion called") - - let completion = { completionExpectation.fulfill() } - - userpilot.storage.userId = "default-00000" - userpilot.analyticsPublisher.canRequestEvent = false - - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { sdkEvent in - guard let event = sdkEvent as? PushNotificationOpenedEvent else { - XCTFail("sdkEvent is not PushNotificationOpenedEvent") - return - } - - guard let notificationId = event.eventPayload["notification_id"] as? Int else { - XCTFail("notification_id is missing or not an Int") - return - } - - XCTAssertEqual(notificationId, 5) - - analyticsExpectation.fulfill() - } - - let result = pushNotificationMonitor.processNotificationForTesting( - userInfo: userInfo, - completionHandler: completion - ) - - userpilot.linkOpener.onHandleURL = { url in - XCTAssertEqual(url.absoluteString, "app://some-link") - linkCompletionExpectation.fulfill() - } - - // Act - let didHandleDeferred = pushNotificationMonitor.attemptDeferredNotificationResponse() - - // Assert - waitForExpectations(timeout: 1.0) - XCTAssertTrue(result) - XCTAssertTrue(didHandleDeferred) - } - - func testAttemptDeferredNotificationResponse_doesNotHandle_whenUserMismatch() throws { - // Arrange - let userInfo = userpilotPushNotification() - - userpilot.storage.userId = "default-11111" - userpilot.analyticsPublisher.canRequestEvent = false - - let result = pushNotificationMonitor.processNotificationForTesting(userInfo: userInfo) - - // Act - let didHandleDeferred = pushNotificationMonitor.attemptDeferredNotificationResponse() - - // Assert - XCTAssertFalse(result) - XCTAssertFalse(didHandleDeferred) - } -} diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift index deec49cd..ce6d632d 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationHandlingTests.swift @@ -51,15 +51,29 @@ final class PushNotificationHandlingTests: PushNotificationMonitorTestCase { XCTAssertTrue(result) } - func testDidReceiveNotification_returnsTrue_whenUserpilotPushButEventRequestsNotAllowed() throws { + /// A closed socket must not hold anything back: `publishInternalSDKEvent` persists the event + /// to the offline store when there is no network, and the deeplink is client-side navigation. + func testDidReceiveNotification_reportsAndNavigates_whenSocketIsClosed() throws { // Arrange let userInfo = userpilotPushNotification() let completionExpectation = expectation(description: "completion called") + let openReported = expectation(description: "push open event") + let linkOpened = expectation(description: "deep link opened") let completion = { completionExpectation.fulfill() } userpilot.storage.userId = "default-00000" userpilot.analyticsPublisher.canRequestEvent = false + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { sdkEvent in + guard let event = sdkEvent as? PushNotificationOpenedEvent else { return } + XCTAssertEqual(event.eventPayload["notification_id"] as? Int, 5) + openReported.fulfill() + } + userpilot.linkOpener.onHandleURL = { url in + XCTAssertEqual(url.absoluteString, "app://some-link") + linkOpened.fulfill() + } + // Act let result = pushNotificationMonitor.processNotificationForTesting( userInfo: userInfo, From b8f306a22e1bda263e11351c8aa2894b171547f8 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 20:20:09 +0300 Subject: [PATCH 70/95] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(Database):?= =?UTF-8?q?=20Mirror=20the=20Android=20package=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits Database/ into Entities/, Models/ and Manager/ to match the Android SDK's database/ package, and lifts StoredOfflineEventKind into its own file alongside the envelope. Tests move to match. kind is now decoded as required rather than defaulting to .analytics, so a row missing it is rejected instead of being silently filed as analytics - the same strictness as Android. The raw values are unchanged, so stored rows and the batch shape are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .../{ => Entities}/EventStorage.swift | 0 .../{ => Manager}/EventDatabaseStorage.swift | 0 .../Database/{ => Models}/DatabaseStats.swift | 0 .../{ => Models}/StoredOfflineEvent.swift | 11 +++------- .../Models/StoredOfflineEventKind.swift | 20 +++++++++++++++++++ .../StoredOfflineEventTests.swift | 0 6 files changed, 23 insertions(+), 8 deletions(-) rename Sources/Userpilot/Database/{ => Entities}/EventStorage.swift (100%) rename Sources/Userpilot/Database/{ => Manager}/EventDatabaseStorage.swift (100%) rename Sources/Userpilot/Database/{ => Models}/DatabaseStats.swift (100%) rename Sources/Userpilot/Database/{ => Models}/StoredOfflineEvent.swift (89%) create mode 100644 Sources/Userpilot/Database/Models/StoredOfflineEventKind.swift rename Tests/UserpilotTests/Database/{ => Models}/StoredOfflineEventTests.swift (100%) diff --git a/Sources/Userpilot/Database/EventStorage.swift b/Sources/Userpilot/Database/Entities/EventStorage.swift similarity index 100% rename from Sources/Userpilot/Database/EventStorage.swift rename to Sources/Userpilot/Database/Entities/EventStorage.swift diff --git a/Sources/Userpilot/Database/EventDatabaseStorage.swift b/Sources/Userpilot/Database/Manager/EventDatabaseStorage.swift similarity index 100% rename from Sources/Userpilot/Database/EventDatabaseStorage.swift rename to Sources/Userpilot/Database/Manager/EventDatabaseStorage.swift diff --git a/Sources/Userpilot/Database/DatabaseStats.swift b/Sources/Userpilot/Database/Models/DatabaseStats.swift similarity index 100% rename from Sources/Userpilot/Database/DatabaseStats.swift rename to Sources/Userpilot/Database/Models/DatabaseStats.swift diff --git a/Sources/Userpilot/Database/StoredOfflineEvent.swift b/Sources/Userpilot/Database/Models/StoredOfflineEvent.swift similarity index 89% rename from Sources/Userpilot/Database/StoredOfflineEvent.swift rename to Sources/Userpilot/Database/Models/StoredOfflineEvent.swift index 7810ad03..ad15f0a1 100644 --- a/Sources/Userpilot/Database/StoredOfflineEvent.swift +++ b/Sources/Userpilot/Database/Models/StoredOfflineEvent.swift @@ -10,13 +10,6 @@ import Foundation -/// Which pipeline a stored offline row came from. -internal enum StoredOfflineEventKind: String, Codable { - case analytics - /// `internal` is a Swift keyword, so the case is spelled differently from its raw value. - case internalEvent = "internal" -} - /// The envelope persisted inside `EventStorage.data`. /// /// An analytics row nests the existing `Codable` `Event`; an internal row carries the SDK @@ -82,7 +75,9 @@ extension StoredOfflineEvent: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? Self.schemaVersionValue - kind = try container.decodeIfPresent(StoredOfflineEventKind.self, forKey: .kind) ?? .analytics + // Required, not defaulted: every writer sets it, so a row without one is corrupt and + // must not be silently filed as analytics. Matches the Android envelope. + kind = try container.decode(StoredOfflineEventKind.self, forKey: .kind) eventType = try container.decode(String.self, forKey: .eventType) event = try container.decodeIfPresent(Event.self, forKey: .event) diff --git a/Sources/Userpilot/Database/Models/StoredOfflineEventKind.swift b/Sources/Userpilot/Database/Models/StoredOfflineEventKind.swift new file mode 100644 index 00000000..81dc73f5 --- /dev/null +++ b/Sources/Userpilot/Database/Models/StoredOfflineEventKind.swift @@ -0,0 +1,20 @@ +// +// StoredOfflineEventKind.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// +// Which pipeline a stored offline row came from. +// + +import Foundation + +/// Which pipeline a stored offline row came from. +/// +/// The raw values are a cross-platform contract with the Android SDK's +/// `StoredOfflineEventKind` — do not rename them without changing both. +internal enum StoredOfflineEventKind: String, Codable { + case analytics + /// `internal` is a Swift keyword, so the case is spelled differently from its raw value. + case internalEvent = "internal" +} diff --git a/Tests/UserpilotTests/Database/StoredOfflineEventTests.swift b/Tests/UserpilotTests/Database/Models/StoredOfflineEventTests.swift similarity index 100% rename from Tests/UserpilotTests/Database/StoredOfflineEventTests.swift rename to Tests/UserpilotTests/Database/Models/StoredOfflineEventTests.swift From 64ea7d0cd47cacd259d3c727995a80524f440f58 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Mon, 21 Sep 2026 23:13:05 +0300 Subject: [PATCH 71/95] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Hold=20deep=20links?= =?UTF-8?q?=20until=20the=20host=20can=20route=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold-start push tap is replayed from PushNotificationAutoConfig inside Userpilot.init, before the host can assign navigationDelegate - hosts assign it on the lines after the constructor returns. LinkOpener fell through to the URL-opening fallback, so on React Native the tap bounced out to Linking with no JS listener mounted yet and the screen never opened. LinkOpener now holds the latest link until routing opens: from navigationDelegate's didSet, and one runloop turn after init for hosts that never set a delegate. Delivery moved into route(), on the main queue, since both the delegate and the UIKit fallback touch host UI. Also drops redundant doc comments above the injected services. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Userpilot.swift | 43 +++--- .../Userpilot/Utilities/Link/LinkOpener.swift | 74 ++++++++-- Tests/UserpilotTests/MockUserpilot.swift | 5 + .../Utilities/Link/LinkOpenerTests.swift | 130 ++++++++++++++++-- 4 files changed, 211 insertions(+), 41 deletions(-) diff --git a/Sources/Userpilot/Userpilot.swift b/Sources/Userpilot/Userpilot.swift index f5df189d..35611278 100644 --- a/Sources/Userpilot/Userpilot.swift +++ b/Sources/Userpilot/Userpilot.swift @@ -60,40 +60,22 @@ public class Userpilot: NSObject { // MARK: - Properties - /// A dependency injection container that stores and provides necessary services like analytics, - /// storage, and networking. - /// /// `var` rather than `let` so the idempotent initializer can adopt an already-registered - /// instance's container when `Userpilot(config:)` is called twice with the same token — - /// see the explanatory comment in `init(config:)`. External callers never see this - /// property; it remains module-internal. + /// instance's container when `Userpilot(config:)` is called twice with the same token. var container = DIContainer() - /// Configuration object that holds initialization parameters for the SDK. let config: Config - /// Lazy loading of the `AnalyticsPublishing` instance responsible for publishing user tracking events. + // Resolved lazily, never in `init`: the idempotent initializer may swap `container` for an + // already-registered instance's, and these must bind to that one. private lazy var analyticsPublisher = container.resolve(AnalyticsPublishing.self) - - /// Lazy loading of the `DataStoring` instance that manages persistent storage (e.g., user data, preferences). private lazy var storage = container.resolve(DataStoring.self) - - /// Lazy loading of the `SocketManaging` instance that manages WebSocket connections and event-driven communication. private lazy var socketManager = container.resolve(SocketManaging.self) - - /// Lazy loading of the `SessionMonitoring` instance that manages app lifecycle. private lazy var sessionMonitor = container.resolve(SessionMonitoring.self) - - /// Lazy loading of the `ExperiencesPublishing` instance that manages app lifecycle. private lazy var experiencesPublisher = container.resolve(ExperiencesPublishing.self) - - /// Lazy loading AutoPropertyDecoratoring private lazy var autoPropertyDecorator = container.resolve(AutoPropertyDecoratoring.self) - - /// Lazy loading pushNotificationMonitoring private lazy var pushNotificationMonitor = container.resolve(PushNotificationMonitoring.self) - - /// Lazy loading SDK logger + private lazy var linkOpener = container.resolve(LinkOpening.self) private lazy var logger = container.resolve(Userpilot.Config.self).logger /// Lazy-instantiated overlay window used to present experiences for this instance. @@ -135,7 +117,14 @@ public class Userpilot: NSObject { // MARK: - Delegates /// The delegate object that handles application screen navigation during experience presentation. - @objc public weak var navigationDelegate: UserpilotNavigationDelegate? + @objc public weak var navigationDelegate: UserpilotNavigationDelegate? { + didSet { + // A cold-start push deep link may be held waiting on exactly this assignment: the + // notification is replayed from inside `init`, before a host could set a delegate. + guard navigationDelegate != nil else { return } + linkOpener.processPendingDeepLink() + } + } /// The delegate object that broadcast analytics events. @objc public weak var analyticsDelegate: UserpilotAnalyticsDelegate? @@ -215,6 +204,14 @@ public class Userpilot: NSObject { // Log the initialization of the SDK with the current version. config.logger.info("🌏 Userpilot SDK initialized, version: %{public}@", version()) + + // Hosts assign `navigationDelegate` on the lines *after* this constructor returns, so a + // cold-start push replayed by `register(observer:)` above has nowhere to route to yet. + // Opening routing one runloop turn later lets those assignments land first, and still + // delivers the link for hosts that never set a delegate at all. + performOn(.main) { [weak self] in + self?.linkOpener.processPendingDeepLink() + } } deinit { diff --git a/Sources/Userpilot/Utilities/Link/LinkOpener.swift b/Sources/Userpilot/Utilities/Link/LinkOpener.swift index 1597bda1..92388a75 100644 --- a/Sources/Userpilot/Utilities/Link/LinkOpener.swift +++ b/Sources/Userpilot/Utilities/Link/LinkOpener.swift @@ -14,6 +14,7 @@ import UIKit internal protocol LinkOpening: AnyObject { func handleURL(_ url: URL) + func processPendingDeepLink() } internal class LinkOpener: LinkOpening { @@ -24,6 +25,19 @@ internal class LinkOpener: LinkOpening { private let config: Userpilot.Config private let logger: Logging + /// Guards `canRoute` and `pendingURL`: a deep link can arrive on the socket thread, while + /// the gate is opened from the main thread and from wherever a host assigns its delegate. + private let routingLock = NSLock() + + /// False until the host has had its chance to wire up — see `processPendingDeepLink()`. + private var canRoute = false + + /// The most recent deep link received before routing opened, if any. + /// + /// Newest-wins is safe because only a cold-start push can land here: an experience CTA + /// cannot fire before the host is wired, since no experience has been rendered yet. + private var pendingURL: URL? + /// Dependency for getting the top view controller and opening URLs. Can be mocked for testing. var urlOpener: TopControllerGetting & URLOpening = UIApplication.shared @@ -39,15 +53,59 @@ internal class LinkOpener: LinkOpening { func handleURL(_ url: URL) { tryCatch { - guard let userpilot = userpilot else { - logger.error("❌ Cannot open URL - Userpilot instance is nil") + routingLock.lock() + let canRoute = self.canRoute + if !canRoute { pendingURL = url } + routingLock.unlock() + + guard canRoute else { + logger.info("🔗 Holding deep link until the host can route it") + return + } + + route(url) + } + } + + /// Opens routing and delivers a deep link held from before the host was ready. + /// + /// A cold-start push tap is replayed from `PushNotificationAutoConfig.register(observer:)`, + /// which runs *inside* `Userpilot.init` — before the caller has had any chance to assign + /// `navigationDelegate`. Routing there sends the link nowhere, so `handleURL` holds it until + /// this is called: from `navigationDelegate`'s `didSet`, and one runloop turn after `init` + /// returns so hosts that never set a delegate still get the URL-opening fallback. + /// + /// Idempotent — later calls with nothing held do nothing. + func processPendingDeepLink() { + routingLock.lock() + canRoute = true + let held = pendingURL + pendingURL = nil + routingLock.unlock() + + guard let held = held else { return } + route(held) + } + + // MARK: - Private Methods + + /// Delivers `url` to the host on the main thread. + /// + /// Both branches reach host UI — the navigation delegate drives the app's navigation stack, + /// and the fallback presents a view controller — so neither is safe off main. + private func route(_ url: URL) { + performOn(.main) { [weak self] in + guard let self = self else { return } + guard let userpilot = self.userpilot else { + self.logger.error("❌ Cannot open URL - Userpilot instance is nil") return } // If a delegate is provided from the host application, preference is to use it for // handling navigation and invoking the completion handler. if let delegate = userpilot.navigationDelegate { - logger.info("🔗 UserpilotNavigationDelegate opening %{private}@", url.absoluteString) + self.logger.info( + "🔗 UserpilotNavigationDelegate opening %{private}@", url.absoluteString) delegate.navigate(to: url) return } @@ -59,19 +117,17 @@ internal class LinkOpener: LinkOpening { // SFSafariViewController only supports HTTP and HTTPS URLs and crashes otherwise, // and scheme links crash the universal link opener, so check here to be sure we route safely. if url.isWebLink { - if config.useInAppBrowser { - openInAppBrowser(url) + if self.config.useInAppBrowser { + self.openInAppBrowser(url) } else { - openExternalBrowser(url) + self.openExternalBrowser(url) } } else { - openSchemeLink(url) + self.openSchemeLink(url) } } } - // MARK: - Private Methods - /// Opens a URL in the in-app Safari view controller. private func openInAppBrowser(_ url: URL) { logger.info("🌐 In-app browser opening %{private}@", url.absoluteString) diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index e7f2d502..75ee2e08 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -248,6 +248,11 @@ class MockLinkOpening: LinkOpening { func handleURL(_ url: URL) { onHandleURL?(url) } + + var didProcessPendingDeepLink = false + func processPendingDeepLink() { + didProcessPendingDeepLink = true + } } // MARK: - Mock Socket Manager diff --git a/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift b/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift index d8ed262d..55862485 100644 --- a/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift +++ b/Tests/UserpilotTests/Utilities/Link/LinkOpenerTests.swift @@ -25,6 +25,21 @@ final class LinkOpenerTests: XCTestCase { urlOpener = MockURLOpener() linkOpener.urlOpener = urlOpener navigationDelegate = MockNavigationDelegate() + // In production `Userpilot.init` opens routing; these tests build the opener directly, so + // they must do it themselves or every link would sit in the pending slot. The holding + // behaviour itself is covered by the routing-gate tests at the bottom of this file. + linkOpener.processPendingDeepLink() + } + + /// Runs `act`, then waits out the main-queue hop `LinkOpener.route` delivers through. + /// + /// The barrier is enqueued after that hop, and the main queue is FIFO, so when it fires the + /// delivery has already happened — deterministic, with no sleeps. + private func routing(_ act: () -> Void) { + act() + let delivered = expectation(description: "deep link routed") + DispatchQueue.main.async { delivered.fulfill() } + wait(for: [delivered], timeout: 1.0) } override func tearDown() { @@ -47,7 +62,7 @@ final class LinkOpenerTests: XCTestCase { } // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertEqual(capturedURL, url) @@ -61,7 +76,7 @@ final class LinkOpenerTests: XCTestCase { let url = URL(string: "myapp://deeplink")! // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertFalse(urlOpener.openCalled) @@ -76,7 +91,7 @@ final class LinkOpenerTests: XCTestCase { urlOpener.topViewControllerToReturn = UIViewController() // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertTrue(urlOpener.topViewControllerCalled) @@ -90,7 +105,7 @@ final class LinkOpenerTests: XCTestCase { urlOpener.topViewControllerToReturn = UIViewController() // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertTrue(urlOpener.topViewControllerCalled) @@ -103,7 +118,7 @@ final class LinkOpenerTests: XCTestCase { let url = URL(string: "http://example.com")! // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertTrue(urlOpener.openCalled) @@ -116,7 +131,7 @@ final class LinkOpenerTests: XCTestCase { let url = URL(string: "https://example.com")! // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertTrue(urlOpener.openCalled) @@ -130,7 +145,7 @@ final class LinkOpenerTests: XCTestCase { urlOpener.topViewControllerToReturn = nil // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertTrue(urlOpener.topViewControllerCalled) @@ -165,7 +180,7 @@ final class LinkOpenerTests: XCTestCase { let url = URL(string: "HTTP://EXAMPLE.COM")! // Act - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } // Assert XCTAssertTrue(urlOpener.topViewControllerCalled) @@ -184,11 +199,108 @@ final class LinkOpenerTests: XCTestCase { let url = URL(string: urlString)! mockUserpilot.config.useInAppBrowser = false - linkOpener.handleURL(url) + routing { linkOpener.handleURL(url) } XCTAssertTrue(urlOpener.openCalled) XCTAssertEqual(urlOpener.lastOpenedURL, url) } + + // MARK: - Routing Gate Tests + + /// A fresh opener with routing still closed, as it is while the SDK is starting up. + private func unopenedLinkOpener() -> LinkOpener { + let opener = LinkOpener(container: mockUserpilot.container) + opener.urlOpener = urlOpener + return opener + } + + func testHandleURL_beforeRoutingOpens_holdsTheLink() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + var navigated = false + navigationDelegate.onNavigate = { _ in navigated = true } + let url = URL(string: "myapp://deeplink")! + + // Act + routing { unopenedLinkOpener().handleURL(url) } + + // Assert — nothing is delivered by either route while the host may not be ready. + XCTAssertFalse(navigated) + XCTAssertFalse(urlOpener.openCalled) + } + + func testProcessPendingDeepLink_deliversTheHeldLink() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + var capturedURL: URL? + navigationDelegate.onNavigate = { capturedURL = $0 } + let url = URL(string: "myapp://deeplink")! + let opener = unopenedLinkOpener() + opener.handleURL(url) + + // Act + routing { opener.processPendingDeepLink() } + + // Assert + XCTAssertEqual(capturedURL, url) + } + + func testProcessPendingDeepLink_deliversOnlyTheNewestHeldLink() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + var captured: [URL] = [] + navigationDelegate.onNavigate = { captured.append($0) } + let older = URL(string: "myapp://older")! + let newer = URL(string: "myapp://newer")! + let opener = unopenedLinkOpener() + opener.handleURL(older) + opener.handleURL(newer) + + // Act + routing { opener.processPendingDeepLink() } + + // Assert — one slot: a newer tap supersedes an older, still-undelivered one. + XCTAssertEqual(captured, [newer]) + } + + func testProcessPendingDeepLink_isIdempotent() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + var captured: [URL] = [] + navigationDelegate.onNavigate = { captured.append($0) } + let url = URL(string: "myapp://deeplink")! + let opener = unopenedLinkOpener() + opener.handleURL(url) + + // Act — the init backstop opens routing, then the host assigns a delegate and opens again. + routing { + opener.processPendingDeepLink() + opener.processPendingDeepLink() + } + + // Assert — the tap is delivered once, not once per trigger. + XCTAssertEqual(captured, [url]) + } + + func testHandleURL_afterRoutingOpens_deliversImmediately() { + // Arrange + mockUserpilot.navigationDelegate = navigationDelegate + var captured: [URL] = [] + navigationDelegate.onNavigate = { captured.append($0) } + let first = URL(string: "myapp://first")! + let second = URL(string: "myapp://second")! + let opener = unopenedLinkOpener() + opener.processPendingDeepLink() + + // Act — experience CTAs, which can only ever fire after the host is up. + routing { + opener.handleURL(first) + opener.handleURL(second) + } + + // Assert — no holding, and no single slot that could drop the second tap. + XCTAssertEqual(captured, [first, second]) + } } private final class MockURLOpener: TopControllerGetting, URLOpening { From 61a524fdc8b8a944672b44cb0a7507fe3b1a05b7 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 22 Sep 2026 11:59:42 +0300 Subject: [PATCH 72/95] docs: sync mobile integration guidance --- README.md | 39 ++++++-- .../Userpilot/Userpilot.docc/OfflineMode.md | 45 +++++++++ .../Userpilot.docc/PushNotifications.md | 10 ++ .../PushNotificationsManually.md | 10 ++ .../Userpilot.docc/ScanQRCodeForReview.md | 97 +++++++++++++++++++ .../Userpilot.docc/URLSchemeConfiguring.md | 6 ++ .../UserpilotNanvigationHandler.md | 8 ++ 7 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 Sources/Userpilot/Userpilot.docc/OfflineMode.md create mode 100644 Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md diff --git a/README.md b/README.md index 690907c7..1ed4f231 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Userpilot iOS SDK ![version](https://img.shields.io/github/v/tag/Userpilot/ios-sdk?label=version) -[![Documentation](https://img.shields.io/badge/Documentation-blue.svg)](https://docs.userpilot.com/article/313-install-userpilot-on-your-ios-app) +[![Documentation](https://img.shields.io/badge/Documentation-blue.svg)](https://docs.userpilot.com/developer/installation/mobile/ios/installation) [![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-brightgreen.svg)](https://github.com/apple/swift-package-manager) [![CocoaPods compatible](https://img.shields.io/badge/CocoaPods-compatible-red.svg)](https://cocoapods.org/pods/Userpilot) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/Userpilot/ios-sdk/blob/main/LICENSE) @@ -25,6 +25,8 @@ This document provides a step-by-step walkthrough of the installation and initia - [Initializing](#initializing) - [Using the SDK](#using-the-SDK) - [Configurations](#configurations-optional) + - [Scan QR Code for Review](#scan-qr-code-for-review) + - [Offline Mode](#offline-mode) - [Auto Capture](#auto-capture) - [Push Notification](#push-notification) - [Multi-Instance Support](#multi-instance-support) @@ -66,6 +68,8 @@ Before you begin, ensure your iOS project meets the following requirements: Once integrated, the Userpilot SDK is available throughout your application. +See the official [iOS SDK release notes](https://docs.userpilot.com/developer/installation/mobile/ios/mobile-ios-release-notes) for version history and upgrade details. + ### Initializing To use Userpilot, initialize it once in your App Delegate or Scene Delegate during app launch. This ensures the SDK is ready as soon as your app starts. Replace `` with your Application Token, which can be fetched from your [Environments Page](https://run.userpilot.io/environment). @@ -190,8 +194,8 @@ If you have additional configuration needs, you can pass a custom configuration ```swift userpilot = Userpilot( config: Userpilot.Config(token: "APP_TOKEN") - .logging(true) // Enable or disable logging. - .enableUseInAppBrowser(enabled: true) // Enable Open external link In-app browser using SFSafariViewController. + .logging(enabled: true) // Enable or disable logging. + .enableUseInAppBrowser(true) // Enable Open external link In-app browser using SFSafariViewController. .disableRequestPushNotificationsPermission() // Disable SDK push permission request flow (default false). .enableRequestIdentifyBeforeScreen() // Re-state the identified user before every screen event (default false). ) @@ -211,7 +215,18 @@ public protocol UserpilotNavigationDelegate: AnyObject { } ``` -The Userpilot SDK automatically handles navigation if you haven't implemented the `UserpilotNavigationHandler`. When a deep link is external, the SDK will handle it appropriately. For complete control over link handling, you can override the `UserpilotNavigationHandler` protocol. This allows you to customize the behavior for all types of links as per your requirements. +Implement `UserpilotNavigationDelegate` to control link handling. Without a delegate, the SDK opens custom schemes through the system and web links through the system browser or the configured in-app browser. + +Assign `userpilot.navigationDelegate` immediately after initializing the SDK, +and keep a strong reference to the delegate because the SDK holds it weakly. +Navigation callbacks run on the main thread. In SDK 1.4.0 and later, the latest +link received during initialization is held until a delegate is assigned or the +next main-queue turn after initialization. If no delegate is assigned, the SDK +uses its normal URL-opening behavior. This startup delay does not wait for a +router that your app initializes asynchronously; buffer the URL in your delegate +if your navigation UI is not ready. + +See the [SDK Callbacks guide](https://docs.userpilot.com/developer/installation/mobile/ios/callbacks#navigation-handler). #### Analytics Delegate @@ -290,17 +305,29 @@ public protocol UserpilotExperienceDelegate: AnyObject { } ``` +### Scan QR Code for Review + +To review a draft Flow or Survey on a real device, configure the Userpilot custom URL scheme and scan the experience QR code from the Mobile Builder. The QR code opens a Userpilot preview link in your app, and the SDK loads the draft experience for review. + +See the official [Custom URL Scheme guide](https://docs.userpilot.com/developer/installation/mobile/ios/custom_scheme) for setup and troubleshooting. + +### Offline Mode + +SDK **1.4.0 and later** automatically stores offline analytics, flow/survey/NPS interactions, and push-notification opens for the current user. Stored events survive app restarts and are sent together when the connection returns. All event types share a **3 MB / 5,000-event** limit; events removed for sending are not retried if that send fails. + +See the official [Offline Mode guide](https://docs.userpilot.com/developer/installation/mobile/ios/features#offline-mode) for supported events, exclusions, and delivery limits. + ### Auto Capture The Userpilot SDK supports automatic capture of screen views and user interaction events. please refer to the [Userpilot Documentation](https://docs.userpilot.com/developer/installation/mobile/ios/installation). ### Push Notification -Userpilot SDK supports handling push notifications to help you deliver targeted messages and enhance user engagement. For setup instructions, and integration details, please refer to the [Push Notifications Guide](https://docs.userpilot.com/developer/installation/mobile/ios/push-notifications). +Userpilot SDK supports handling push notifications to help you deliver targeted messages and enhance user engagement. For setup instructions and integration details, see the official [Push Notifications Guide](https://docs.userpilot.com/developer/installation/mobile/ios/push-notifications). ### Multi-Instance Support -Userpilot iOS SDK supports running multiple independent instances in the same process. Each instance keeps its own analytics, autocapture scope, storage, and experience overlay window isolated from other instances. For setup instructions and routing details, please refer to the [Multi-Instance Guide](Sources/Userpilot/Userpilot.docc/MultiInstance.md). +Userpilot iOS SDK supports running multiple independent instances in the same process. Each instance keeps its own analytics, autocapture scope, storage, and experience overlay window isolated from other instances. For setup instructions and routing details, see the official [Multi-Instance Guide](https://docs.userpilot.com/developer/installation/mobile/ios/multi-instance). ## 📝 Documentation diff --git a/Sources/Userpilot/Userpilot.docc/OfflineMode.md b/Sources/Userpilot/Userpilot.docc/OfflineMode.md new file mode 100644 index 00000000..6551c2ca --- /dev/null +++ b/Sources/Userpilot/Userpilot.docc/OfflineMode.md @@ -0,0 +1,45 @@ +# Offline mode + +Offline mode is available in SDK **1.4.0 and later**. It is always enabled and +requires no additional configuration. Identify a user, or call `anonymous()`, +before collecting events for that user. + +## Supported events + +When the device is offline, the SDK stores these events locally: + +- Identify calls, screen views, custom events, and Auto Capture events. +- Flow views, dismissals, completions, and step views and completions. +- Survey views, dismissals, completions, responses, and step views, skips, and submissions. +- NPS views, dismissals, and feedback submissions. +- Push-notification opens, excluding test notifications. + +Stored events survive app restarts. When the connection returns, the SDK sends +them together in a batch ordered by their stored timestamps, before processing +pending live events. Experience interactions and push opens are associated with +the current identified or anonymous user; they are not persisted without a user ID. + +## Storage and delivery limits + +- All supported event types share a limit of **3 MB** or **5,000 events**, whichever + limit is reached first. +- New events that would exceed a limit are not stored until space becomes available. +- Switching users or logging out clears stored events to prevent them from being + replayed for another user. +- Stored events are removed before the batch is sent. If the send fails or times + out, that batch is not retried. +- Persistence applies when the SDK detects that the device is offline. A closed + socket while the device still has network connectivity uses an in-memory queue; + those queued events do not survive an app restart. + +## Experiences and push notifications + +Loading new experiences and themes requires a network connection. Interactions +with an experience that is already displayed can be recorded while offline. +Content/theme requests, push-token updates, and logout events are not persisted +in the offline batch. Push-token synchronization is handled separately when the +connection returns. + +Tapping an already-delivered push notification can navigate within your app +without waiting for the analytics connection. The destination may still need a +network connection to load its content. diff --git a/Sources/Userpilot/Userpilot.docc/PushNotifications.md b/Sources/Userpilot/Userpilot.docc/PushNotifications.md index 81583e9a..ee696614 100644 --- a/Sources/Userpilot/Userpilot.docc/PushNotifications.md +++ b/Sources/Userpilot/Userpilot.docc/PushNotifications.md @@ -6,6 +6,16 @@ There are two options for configuring push notification: automatic or manual. > Tip: Automatic configuration is the quickest and simplest way to configure push notifications and is recommended for most customers. Refer to for manual configuration instructions. +## Notification taps while offline + +In SDK 1.4.0 and later, a valid notification tap for the current user routes its +deep link without waiting for an open analytics connection. The SDK records the +open at tap time and persists it when the device is offline. Test notifications +navigate without recording a push-open analytics event. The destination may still +need a network connection to load its content. + +See for startup routing and for storage and delivery limits. + ## Prerequisites It is recommended to have [configured your iOS push settings in Userpilot settings Studio](https://run.userpilot.io/settings/mobile) before configuring push notifications in your app. diff --git a/Sources/Userpilot/Userpilot.docc/PushNotificationsManually.md b/Sources/Userpilot/Userpilot.docc/PushNotificationsManually.md index d175f62e..259b6126 100644 --- a/Sources/Userpilot/Userpilot.docc/PushNotificationsManually.md +++ b/Sources/Userpilot/Userpilot.docc/PushNotificationsManually.md @@ -7,6 +7,16 @@ There are two options for configuring push notification: automatic or manual. > Tip: Automatic configuration is the quickest and simplest way to configure push notifications and is recommended for most customers. Refer to for automatic configuration instructions. +## Notification taps while offline + +In SDK 1.4.0 and later, a valid notification tap for the current user routes its +deep link without waiting for an open analytics connection. The SDK records the +open at tap time and persists it when the device is offline. Test notifications +navigate without recording a push-open analytics event. The destination may still +need a network connection to load its content. + +See for startup routing and for storage and delivery limits. + ## Prerequisites It is recommended to have [configured your iOS push settings in Userpilot Studio](https://run.userpilot.io/settings/mobile) before configuring push notifications in your app. diff --git a/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md b/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md new file mode 100644 index 00000000..6d2c258d --- /dev/null +++ b/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md @@ -0,0 +1,97 @@ +# Scan a QR Code to Review an Experience + +Review a draft Flow or Survey on a physical device or simulator before publishing it. The Mobile Builder QR code contains a Userpilot custom URL that opens your app and asks the SDK to fetch the draft experience. + +## URL contract + +The SDK accepts preview URLs with this exact structure: + +```text +userpilot-{token}://sdk/experience_preview/{experience_id} +``` + +The URL must satisfy all of these checks: + +- The scheme is `userpilot-` followed by the token passed to ``Userpilot/Config``, lowercased. +- The host is exactly `sdk`. +- The path contains `experience_preview` followed by a nonempty experience ID. +- Any query items, such as locale or experience type, are forwarded to the preview request. + +A staging token keeps its prefix. For example, `STG-NX-12345678` maps to `userpilot-stg-nx-12345678`. + +## Register the scheme + +Add the token-based scheme to the app target's `Info.plist`: + +```xml +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + userpilot-USERPILOT_TOKEN + + + +``` + +Replace `USERPILOT_TOKEN` with the same lowercased token used to initialize the SDK. + +## Forward every URL entry point + +For scene-based apps, handle both cold-launch and already-running delivery: + +```swift +func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions +) { + let unhandled = userpilot.filterAndHandle(connectionOptions.urlContexts) + // Route any contexts left in `unhandled` through your app. +} + +func scene(_ scene: UIScene, openURLContexts contexts: Set) { + let unhandled = userpilot.filterAndHandle(contexts) + // Route any contexts left in `unhandled` through your app. +} +``` + +For an app-delegate or SwiftUI entry point, pass the individual URL to ``Userpilot/didHandleURL(_:)``. A return value of `false` means the URL belongs to the host app. + +```swift +func application( + _ application: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] +) -> Bool { + if userpilot.didHandleURL(url) { return true } + return false +} +``` + +The SDK queues a valid preview action until a window scene becomes active. This allows a QR scan to start the app without losing the preview while the UI is still connecting. + +## Test without the Builder + +Open a known preview URL in the booted simulator: + +```bash +xcrun simctl openurl booted \ + "userpilot-nx-12345678://sdk/experience_preview/12345" +``` + +Replace the scheme and experience ID with values for your environment. Enable SDK logging while testing. + +## Failure boundaries + +- **No app opens:** the scheme is absent from `Info.plist` or does not match the initialized token. +- **The app opens but `didHandleURL` returns `false`:** check the `sdk` host, path, environment token, and experience ID. +- **The app opens but no preview appears:** confirm every URL entry point forwards to Userpilot and the same environment created the QR code. +- **Your own links stop routing:** continue handling URL contexts returned by ``Userpilot/filterAndHandle(_:)`` and URLs for which ``Userpilot/didHandleURL(_:)`` returns `false`. + +See for the complete custom URL scheme integration. diff --git a/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md index 9aecbf42..d60330b7 100644 --- a/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md +++ b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md @@ -2,6 +2,8 @@ The Userpilot iOS SDK includes support for a custom URL scheme that supports previewing Userpilot experiences. +For a focused technical walkthrough of Builder preview links, see . + ## Overview Configuring the Userpilot URL scheme involves adding a `CFBundleURLTypes` value and then directing the incoming URL to the Userpilot iOS SDK. @@ -28,6 +30,10 @@ For example, if your Userpilot Token is `NX-12345678` your url scheme value woul ``` +Use the token exactly as passed to the SDK, in lowercase. Keep the `STG-` prefix +for staging: `STG-NX-12345678` uses `userpilot-stg-nx-12345678`, while +`NX-12345678` uses `userpilot-nx-12345678`. + ## Handle the Custom URL Scheme Custom URL's should be handled with a call to ``Userpilot/filterAndHandle(_:)`` or ``Userpilot/didHandleURL(_:)``. If the URL being opened is an Userpilot URL, the URL will be handled. diff --git a/Sources/Userpilot/Userpilot.docc/UserpilotNanvigationHandler.md b/Sources/Userpilot/Userpilot.docc/UserpilotNanvigationHandler.md index 07be856b..a217454c 100644 --- a/Sources/Userpilot/Userpilot.docc/UserpilotNanvigationHandler.md +++ b/Sources/Userpilot/Userpilot.docc/UserpilotNanvigationHandler.md @@ -26,6 +26,14 @@ Userpilot(config: Userpilot.Config(token: appToken) .setNavigationHandler(navigationDelegate: self)) ``` +Keep a strong reference to the navigation delegate because the SDK holds it +weakly. Navigation callbacks run on the main thread. In SDK 1.4.0 and later, the +latest link received during initialization is held until a delegate is assigned +or the next main-queue turn after initialization. If no delegate is assigned, the +SDK uses its normal URL-opening behavior. This startup delay does not wait for a +router that your app initializes asynchronously; buffer the URL in your delegate +if your navigation UI is not ready. + ### Step 2: Implement the Navigation Delegate From 4e29619177cb2c39d7391fc0841a13dd466f4c75 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 22 Sep 2026 14:31:39 +0300 Subject: [PATCH 73/95] =?UTF-8?q?=F0=9F=90=9B=20fix(monitor):=20Resume=20o?= =?UTF-8?q?n=20didBecomeActive,=20not=20willEnterForeground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit willEnterForeground never fires on the first launch, so wrapper hosts (Capacitor / Flutter / React Native / MAUI) that build Userpilot while UIKit is still .inactive never resumed — the socket stayed closed until the user backgrounded and foregrounded the app. didBecomeActive covers that first activation and every later foreground return, so one observer replaces two and the dedupe flag goes with them: startMonitoring, resume and connect each gate on their own state, so re-entry is a no-op. Co-Authored-By: Claude Opus 5 (1M context) --- .../Userpilot/Monitor/SessionMonitor.swift | 42 ++++++++++--------- .../Analytics/SessionMonitorTests.swift | 29 ++++++++++--- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/Sources/Userpilot/Monitor/SessionMonitor.swift b/Sources/Userpilot/Monitor/SessionMonitor.swift index a5620270..c2b3e51d 100644 --- a/Sources/Userpilot/Monitor/SessionMonitor.swift +++ b/Sources/Userpilot/Monitor/SessionMonitor.swift @@ -36,9 +36,6 @@ internal class SessionMonitor: SessionMonitoring { /// The storage used to store user-related data. private let storage: DataStoring - /// A flag to prevent calling didEnterForeground twice - private var hasInitializedForeground = false - /// A flag to mintor app status private var _isAppActive = true @@ -56,23 +53,27 @@ internal class SessionMonitor: SessionMonitoring { name: UIApplication.didEnterBackgroundNotification, object: nil ) - // Add observer for when the app enters the foreground. + // Add observer for every transition to the active state. + // `didBecomeActive` supersedes `willEnterForeground`: it covers the first + // activation (which `willEnterForeground` never delivers — that one only fires + // when returning from background) *and* every later foreground return, so a + // single observer handles both. Wrapper SDKs (Capacitor / Flutter / React Native + // / MAUI) build `Userpilot` while UIKit is still `.inactive`, so with + // `willEnterForeground` alone `resume()` never ran and the socket stayed closed + // until the user backgrounded and foregrounded the app. NotificationCenter.default.addObserver( self, - selector: #selector(didEnterForeground), - name: UIApplication.willEnterForegroundNotification, + selector: #selector(didBecomeActive), + name: UIApplication.didBecomeActiveNotification, object: nil ) - // Handle initial state only if app is currently active - // This is a common issue when using native iOS SDKs within Flutter or ReactNative plugins. - // The problem occurs due to the different lifecycle management between - // these plugins and native iOS apps. + // The one activation no notification can deliver: the SDK was created *after* + // the app was already `.active`, so `didBecomeActive` has already fired and will + // not fire again until the next activation. Catch up on the current state. DispatchQueue.main.async { [weak self] in - guard let self else { return } - if UIApplication.shared.applicationState == .active && !self.hasInitializedForeground { - self.onAppStart() - } + guard let self, UIApplication.shared.applicationState == .active else { return } + self.onAppStart() } } @@ -87,8 +88,6 @@ internal class SessionMonitor: SessionMonitoring { } func reset() { - hasInitializedForeground = false - // Stop listening for further lifecycle callbacks NotificationCenter.default.removeObserver( self, @@ -96,7 +95,7 @@ internal class SessionMonitor: SessionMonitoring { object: nil) NotificationCenter.default.removeObserver( self, - name: UIApplication.willEnterForegroundNotification, + name: UIApplication.didBecomeActiveNotification, object: nil) // Clean up state we previously persisted @@ -114,17 +113,20 @@ internal class SessionMonitor: SessionMonitoring { analyticsPublisher.flush() } - /// Called when the app enters the foreground. + /// Called when the app becomes active — cold start and every foreground return. /// This method resumes analytics socket connection and event publishing. /// - Parameter notification: The notification object containing information about the event. @objc - func didEnterForeground(notification: Notification) { + func didBecomeActive(notification: Notification) { onAppStart() } + /// Resumes monitoring and publishing. Safe to call repeatedly: `startMonitoring()` + /// returns early while a path monitor is alive, `resume()` no-ops without a stored + /// session date, and `connect()` gates on the socket state — so no guard flag is + /// needed to dedupe the activation paths. private func onAppStart() { _isAppActive = true - hasInitializedForeground = true networkMonitor.startMonitoring() analyticsPublisher.resume() } diff --git a/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift b/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift index 49c9aba7..43eaaaf2 100644 --- a/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift +++ b/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift @@ -42,19 +42,36 @@ final class SessionMonitorTests: XCTestCase { XCTAssertEqual(trackedFlushEvent, 1, "Flush should be called exactly once") } - func testDidEnterForeground_shouldResumeAnalytics() { + func testDidBecomeActive_shouldResumeAnalytics() { // Arrange var trackedResumeEvent = 0 userpilot.analyticsPublisher.onResume = { trackedResumeEvent += 1 } // Act - monitor.didEnterForeground(notification: Notification(name: UIApplication.willEnterForegroundNotification)) + monitor.didBecomeActive(notification: Notification(name: UIApplication.didBecomeActiveNotification)) // Assert - XCTAssertTrue(monitor.isAppActive, "App should be marked active when entering foreground") + XCTAssertTrue(monitor.isAppActive, "App should be marked active when becoming active") XCTAssertEqual(trackedResumeEvent, 1, "Resume should be called exactly once") } + func testDidBecomeActive_forEveryActivation_shouldResumeEachTime() { + // Arrange + var trackedResumeEvent = 0 + userpilot.analyticsPublisher.onResume = { trackedResumeEvent += 1 } + let activation = Notification(name: UIApplication.didBecomeActiveNotification) + let background = Notification(name: UIApplication.didEnterBackgroundNotification) + + // Act — cold start, then a background round trip + monitor.didBecomeActive(notification: activation) + monitor.didEnterBackground(notification: background) + monitor.didBecomeActive(notification: activation) + + // Assert + XCTAssertTrue(monitor.isAppActive, "App should be active again after the second activation") + XCTAssertEqual(trackedResumeEvent, 2, "Resume should run on every activation, not only the first") + } + func testInit_shouldResumeAnalyticsIfAppIsActive() { // Arrange var trackedResumeEvent = 0 @@ -91,7 +108,7 @@ final class SessionMonitorTests: XCTestCase { userpilot.analyticsPublisher.onFlush = { flushExpectation.fulfill() } NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) wait(for: [resumeExpectation, flushExpectation], timeout: 1.0) } @@ -103,8 +120,8 @@ final class SessionMonitorTests: XCTestCase { monitor.didEnterBackground(notification: Notification(name: UIApplication.didEnterBackgroundNotification)) XCTAssertFalse(monitor.isAppActive, "App should be inactive after background event") - monitor.didEnterForeground(notification: Notification(name: UIApplication.willEnterForegroundNotification)) - XCTAssertTrue(monitor.isAppActive, "App should be active again after foreground event") + monitor.didBecomeActive(notification: Notification(name: UIApplication.didBecomeActiveNotification)) + XCTAssertTrue(monitor.isAppActive, "App should be active again after becoming active") } func testSessionDate_shouldBeClearedOnReset() { From d35079b3140c5f85acd632be1ebac4ddaa692972 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Tue, 22 Sep 2026 14:31:59 +0300 Subject: [PATCH 74/95] =?UTF-8?q?=F0=9F=90=9B=20fix(monitor):=20Guard=20th?= =?UTF-8?q?e=20app-active=20flag=20across=20threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _isAppActive is written on the main thread by the lifecycle callbacks and read from the socket and analytics queues to drop events while the app is inactive. Unguarded, a reader can miss the background transition and push over a socket the system is about to suspend. Wrapped in AtomicReference, the primitive the shared-state rule mandates. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Userpilot/Monitor/SessionMonitor.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/Userpilot/Monitor/SessionMonitor.swift b/Sources/Userpilot/Monitor/SessionMonitor.swift index c2b3e51d..9be8d0a4 100644 --- a/Sources/Userpilot/Monitor/SessionMonitor.swift +++ b/Sources/Userpilot/Monitor/SessionMonitor.swift @@ -36,8 +36,12 @@ internal class SessionMonitor: SessionMonitoring { /// The storage used to store user-related data. private let storage: DataStoring - /// A flag to mintor app status - private var _isAppActive = true + /// A flag to mintor app status. + /// Written on the main thread by the lifecycle callbacks, read from the socket and + /// analytics queues (`SocketManager.publish`, `AnalyticsPublisher.publish`) to drop + /// events while the app is inactive. Unguarded, a reader can miss the background + /// transition and push events over a socket the system is about to suspend. + private let _isAppActive = AtomicReference(true) /// Initializes the `SessionMonitor` with a dependency container that resolves an `AnalyticsPublishing` instance. /// - Parameter container: The dependency injection container used to resolve the required dependencies. @@ -84,7 +88,7 @@ internal class SessionMonitor: SessionMonitoring { /// Logic to check if the socket is currently open var isAppActive: Bool { - _isAppActive + _isAppActive.value } func reset() { @@ -107,7 +111,7 @@ internal class SessionMonitor: SessionMonitoring { /// - Parameter notification: The notification object containing information about the event. @objc func didEnterBackground(notification: Notification) { - _isAppActive = false + _isAppActive.value = false storage.sessionDate = Date() networkMonitor.stopMonitoring() analyticsPublisher.flush() @@ -126,7 +130,7 @@ internal class SessionMonitor: SessionMonitoring { /// session date, and `connect()` gates on the socket state — so no guard flag is /// needed to dedupe the activation paths. private func onAppStart() { - _isAppActive = true + _isAppActive.value = true networkMonitor.startMonitoring() analyticsPublisher.resume() } From 9826fe046a6b160af7a44a26e6263194e8074c3c Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 23 Sep 2026 08:50:22 +0300 Subject: [PATCH 75/95] =?UTF-8?q?=F0=9F=90=9B=20fix(monitor):=20Guard=20th?= =?UTF-8?q?e=20session-start=20flag=20across=20threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasStartedSession` is cleared by `reset()`, which `deinit` calls on whichever thread releases the last reference, so a plain `Bool` races with the main-thread reads. Store it in an `AtomicReference` and claim the foreground period via `compareAndSet`. Also drops the `dispatchPrecondition` — it would trap a host app in a release build. Co-authored-by: Cursor --- .../Userpilot/Monitor/SessionMonitor.swift | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Sources/Userpilot/Monitor/SessionMonitor.swift b/Sources/Userpilot/Monitor/SessionMonitor.swift index 9be8d0a4..04cb9616 100644 --- a/Sources/Userpilot/Monitor/SessionMonitor.swift +++ b/Sources/Userpilot/Monitor/SessionMonitor.swift @@ -43,6 +43,12 @@ internal class SessionMonitor: SessionMonitoring { /// transition and push events over a socket the system is about to suspend. private let _isAppActive = AtomicReference(true) + /// Whether `onAppStart()` has run for the current foreground period. + /// Set on the main thread by `onAppStart()` and `didEnterBackground`, but also cleared by + /// `reset()`, which `deinit` calls on whichever thread releases the last reference — so + /// this cannot be a plain `Bool`. + private let hasStartedSession = AtomicReference(false) + /// Initializes the `SessionMonitor` with a dependency container that resolves an `AnalyticsPublishing` instance. /// - Parameter container: The dependency injection container used to resolve the required dependencies. init(container: DIContainer) { @@ -102,6 +108,8 @@ internal class SessionMonitor: SessionMonitoring { name: UIApplication.didBecomeActiveNotification, object: nil) + hasStartedSession.value = false + // Clean up state we previously persisted storage.sessionDate = nil } @@ -111,6 +119,7 @@ internal class SessionMonitor: SessionMonitoring { /// - Parameter notification: The notification object containing information about the event. @objc func didEnterBackground(notification: Notification) { + hasStartedSession.value = false _isAppActive.value = false storage.sessionDate = Date() networkMonitor.stopMonitoring() @@ -125,11 +134,14 @@ internal class SessionMonitor: SessionMonitoring { onAppStart() } - /// Resumes monitoring and publishing. Safe to call repeatedly: `startMonitoring()` - /// returns early while a path monitor is alive, `resume()` no-ops without a stored - /// session date, and `connect()` gates on the socket state — so no guard flag is - /// needed to dedupe the activation paths. + /// Resumes monitoring and publishing once per foreground period. Deduplicates the + /// init catch-up and `didBecomeActive` (which can both fire for the same activation), + /// and `.inactive` → `.active` bounces (Control Center, alerts) that never backgrounded. private func onAppStart() { + // Claim the foreground period in one atomic step - a separate read then write would + // let two activations both pass the check and resume twice. + guard hasStartedSession.compareAndSet(expected: false, new: true) else { return } + _isAppActive.value = true networkMonitor.startMonitoring() analyticsPublisher.resume() From 65363dc3c77863eecd993af3776297ede492dbbe Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Wed, 23 Sep 2026 13:03:37 +0300 Subject: [PATCH 76/95] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Restore?= =?UTF-8?q?=20APP=5FTOKEN=20placeholders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update URL-scheme guides and sample native configuration to use APP_TOKEN. Runtime behavior is unchanged. --- Sample/UserpilotSwiftSample/UserpilotSample/Info.plist | 2 +- Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md | 4 ++-- Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Info.plist b/Sample/UserpilotSwiftSample/UserpilotSample/Info.plist index a859204f..7f2bd4b0 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Info.plist +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Info.plist @@ -79,7 +79,7 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - userpilot-USERPILOT_TOKEN + userpilot-APP_TOKEN diff --git a/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md b/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md index 6d2c258d..e89fc763 100644 --- a/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md +++ b/Sources/Userpilot/Userpilot.docc/ScanQRCodeForReview.md @@ -33,13 +33,13 @@ Add the token-based scheme to the app target's `Info.plist`: $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - userpilot-USERPILOT_TOKEN + userpilot-APP_TOKEN ``` -Replace `USERPILOT_TOKEN` with the same lowercased token used to initialize the SDK. +Replace `APP_TOKEN` with the same lowercased token used to initialize the SDK. ## Forward every URL entry point diff --git a/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md index d60330b7..e9ec5937 100644 --- a/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md +++ b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md @@ -10,7 +10,7 @@ Configuring the Userpilot URL scheme involves adding a `CFBundleURLTypes` value ## Register the Custom URL Scheme -Update your `Info.plist` to register the custom URL scheme. Replace `USERPILOT_TOKEN` in the snippet below with the Userpilot Token your app is configured with. This value can be obtained from your [Environments Page](https://run.userpilot.io/environment). +Update your `Info.plist` to register the custom URL scheme. Replace `APP_TOKEN` in the snippet below with the Userpilot Token your app is configured with. This value can be obtained from your [Environments Page](https://run.userpilot.io/environment). For example, if your Userpilot Token is `NX-12345678` your url scheme value would be `userpilot-nx-12345678`. @@ -24,7 +24,7 @@ For example, if your Userpilot Token is `NX-12345678` your url scheme value woul $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - userpilot-USERPILOT_TOKEN + userpilot-APP_TOKEN From 65da305b3405d2a9d3376164aacc9dea7fccbc54 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 18:10:33 +0300 Subject: [PATCH 77/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Monitor):=20Recheck=20?= =?UTF-8?q?reachability=20when=20offline=20events=20arrive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed probe could leave a connected interface marked offline until its network path changed. Recheck reachability when events arrive, with throttling to prevent bursts of probes, and cover recovery. --- .../Analytics/AnalyticsPublisher.swift | 5 + .../Userpilot/Monitor/NetworkMonitor.swift | 60 +++++- Tests/UserpilotTests/MockUserpilot.swift | 4 + .../Monitor/NetworkMonitorTests.swift | 199 ++++++++++++++++++ 4 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 3f9ec6fc..1c593398 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -395,6 +395,11 @@ extension AnalyticsPublisher: AnalyticsPublishing { // Network monitor is ready and reports no network: persist locally if offlineEventsHandler.shouldSaveOffline { if event.isScreenEvent, !shouldStoreScreenEventOffline(event) { return } + // Something needs sending and we believe we are offline, so this is the moment + // to re-verify. `NWPathMonitor` only reports interface transitions, so a probe + // that failed while the interface stayed up never retries on its own. The call + // is throttled inside the monitor, so a burst of events is still one probe. + networkMonitor.recheckIfOffline() handleOfflineEvent(event) return } diff --git a/Sources/Userpilot/Monitor/NetworkMonitor.swift b/Sources/Userpilot/Monitor/NetworkMonitor.swift index b484f4f6..dd00d113 100644 --- a/Sources/Userpilot/Monitor/NetworkMonitor.swift +++ b/Sources/Userpilot/Monitor/NetworkMonitor.swift @@ -59,6 +59,10 @@ internal protocol NetworkMonitoring: AnyObject { /// Stops monitoring network connectivity changes. func stopMonitoring() + + /// Re-probes reachability if the SDK currently believes it is offline. Throttled internally; + /// safe to call on every event. + func recheckIfOffline() } // MARK: - NetworkMonitor @@ -86,7 +90,8 @@ internal class NetworkMonitor: NetworkMonitoring { private var pathMonitor: NWPathMonitor? private var debounceWorkItem: DispatchWorkItem? - private let debounceDelay: TimeInterval = 0.3 + /// Interface-change debounce. `var` so tests can collapse it. + internal var debounceDelay: TimeInterval = 0.3 // Reachability check properties // Active validation targets first-party Userpilot hosts only — probing public @@ -97,6 +102,14 @@ internal class NetworkMonitor: NetworkMonitoring { private let reachabilityTimeout: TimeInterval = 5.0 private var currentReachabilityIndex = 0 + /// Stands in for ``checkInternetReachability(host:completion:)`` so tests can drive a + /// failing probe to recovery without opening real sockets. `nil` in production. + internal var reachabilityProbe: ((String, @escaping (Bool) -> Void) -> Void)? + + /// Shortest gap between two recovery probes driven by ``recheckIfOffline()``. + /// `var` so tests can collapse it. + internal var reachabilityRecheckInterval: TimeInterval = 10.0 + // Backing state (accessed via concurrent queue) private var _isNetworkAvailable: Bool = false // Start pessimistic until verified private var _hasInterfaceConnection: Bool = false // Interface level connectivity @@ -104,6 +117,8 @@ internal class NetworkMonitor: NetworkMonitoring { private var _connectionType: ConnectionType = .unknown private var _isReady: Bool = false private var _isCheckingReachability: Bool = false + /// When the last probe started, so `recheckIfOffline()` can throttle. + private var _lastReachabilityCheckAt: TimeInterval = 0 /// Indicates whether the device has real internet connectivity var isNetworkAvailable: Bool { @@ -226,6 +241,7 @@ internal class NetworkMonitor: NetworkMonitoring { stateQueue.async(flags: .barrier) { [weak self] in self?._isCheckingReachability = true + self?._lastReachabilityCheckAt = Date().timeIntervalSince1970 } // Rotate through first-party hosts for redundancy @@ -245,7 +261,11 @@ internal class NetworkMonitor: NetworkMonitoring { let host = reachabilityHosts[currentReachabilityIndex] currentReachabilityIndex = (currentReachabilityIndex + 1) % reachabilityHosts.count - checkInternetReachability(host: host) { [weak self] hasAccess in + let probe = reachabilityProbe ?? { [weak self] probeHost, completion in + self?.checkInternetReachability(host: probeHost, completion: completion) + } + + probe(host) { [weak self] hasAccess in guard let self = self else { return } self.stateQueue.async(flags: .barrier) { @@ -256,6 +276,38 @@ internal class NetworkMonitor: NetworkMonitoring { } } + // MARK: - Recovery + + /// Re-probes reachability when the SDK believes it is offline. + /// + /// `NWPathMonitor` reports INTERFACE transitions only, so a probe that failed while the + /// interface stayed `.satisfied` — captive portal, backend outage, transient DNS — would + /// otherwise pin the SDK offline until the interface flapped or the app was backgrounded, + /// with every event routed to local storage in the meantime. + /// + /// Driven by event publishing rather than by a timer: while the app is idle there is nothing + /// to send, so being marked offline costs nothing and a wake-up would buy nothing. The moment + /// something does need sending, this re-checks. Throttled to one probe per + /// ``reachabilityRecheckInterval`` so a burst of events cannot turn into a burst of probes. + func recheckIfOffline() { + var shouldProbe = false + stateQueue.sync(flags: .barrier) { + let now = Date().timeIntervalSince1970 + guard !_isNetworkAvailable, + _hasInterfaceConnection, + !_isCheckingReachability, + now - _lastReachabilityCheckAt >= reachabilityRecheckInterval + else { return } + shouldProbe = true + } + guard shouldProbe else { return } + + logger.debug("🌐 Offline with a live interface - re-checking reachability") + networkQueue.async { [weak self] in + self?.performReachabilityCheck() + } + } + /// Builds the first-party probe host list: the configured socket endpoint plus /// the settings and experience API hosts. Never public hosts. static func makeReachabilityHosts(socketURL: String) -> [String] { @@ -334,7 +386,9 @@ internal class NetworkMonitor: NetworkMonitoring { // MARK: - State Management - private func updateInterfaceState(hasInterface: Bool, connectionType: ConnectionType) { + /// Entry point for an interface transition. Internal rather than private so tests can stand + /// in for `NWPathMonitor` without opening a real path monitor. + internal func updateInterfaceState(hasInterface: Bool, connectionType: ConnectionType) { debounceWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 75ee2e08..0b38e865 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -437,6 +437,10 @@ class MockNetworkMonitor: NetworkMonitoring { func stopMonitoring() { onStopMonitoring?() } + var recheckIfOfflineCount = 0 + func recheckIfOffline() { + recheckIfOfflineCount += 1 + } } // MARK: - Mock Offline Events Handler diff --git a/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift b/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift new file mode 100644 index 00000000..a60e368e --- /dev/null +++ b/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift @@ -0,0 +1,199 @@ +// +// NetworkMonitorTests.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all + +/// Collects `networkMonitorDidUpdate` calls off whichever queue the monitor notifies on. +private final class SpyNetworkDelegate: NetworkMonitoringDelegate { + private let lock = NSLock() + private var _updates: [(isReady: Bool, isNetworkAvailable: Bool)] = [] + + var updates: [(isReady: Bool, isNetworkAvailable: Bool)] { + lock.lock() + defer { lock.unlock() } + return _updates + } + + /// Fulfilled the first time the monitor reports available internet. + var onAvailable: (() -> Void)? + + func networkMonitorDidUpdate(isReady: Bool, isNetworkAvailable: Bool) { + lock.lock() + _updates.append((isReady, isNetworkAvailable)) + lock.unlock() + + if isReady && isNetworkAvailable { + onAvailable?() + } + } +} + +/// Counts probes across queues. +private final class ProbeCounter { + private let lock = NSLock() + private var count = 0 + + func increment() -> Int { + lock.lock() + defer { lock.unlock() } + count += 1 + return count + } + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } +} + +final class NetworkMonitorTests: XCTestCase { + + private var userpilot: MockUserpilot! + private var monitor: NetworkMonitor! + private var delegateSpy: SpyNetworkDelegate! + + override func setUp() { + super.setUp() + let config = Userpilot.Config(token: "NX-\(UUID().uuidString)").defaultInstance(false) + config.logger = MockLogger() + userpilot = MockUserpilot(config: config) + + monitor = NetworkMonitor(container: userpilot.container) + // Collapse the interface debounce so the tests exercise ordering, not wall-clock. + monitor.debounceDelay = 0.01 + + delegateSpy = SpyNetworkDelegate() + monitor.delegate = delegateSpy + } + + override func tearDown() { + monitor.stopMonitoring() + monitor = nil + delegateSpy = nil + userpilot = nil + super.tearDown() + } + + /// A probe that fails a fixed number of times and succeeds afterwards, counting calls. + private func installProbe(failuresBeforeSuccess: Int, counter: ProbeCounter) { + monitor.reachabilityProbe = { _, completion in + let attempt = counter.increment() + completion(attempt > failuresBeforeSuccess) + } + } + + /// Brings the interface up and settles the first (failing) probe. + 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) + + XCTAssertGreaterThanOrEqual(counter.value, 1, "precondition: the first probe ran") + XCTAssertFalse(monitor.isNetworkAvailable, "precondition: the monitor is offline") + } + + // MARK: - Recovery while the interface stays connected + + /// The regression this guards: `NWPathMonitor` reports INTERFACE changes only. When a probe + /// fails while the interface stays satisfied, nothing re-probes on its own, so the SDK stayed + /// offline — routing every event to local storage — until the interface flapped or the app was + /// backgrounded. Publishing an event is what drives recovery now. + func testRecheckIfOffline_afterAFailedProbe_recoversWhenTheEndpointComesBack() { + let counter = ProbeCounter() + installProbe(failuresBeforeSuccess: 1, counter: counter) + monitor.reachabilityRecheckInterval = 0 + arrangeOfflineOnALiveInterface(counter: counter) + + let recovered = expectation(description: "monitor reports internet access again") + recovered.assertForOverFulfill = false + delegateSpy.onAvailable = { recovered.fulfill() } + + // An event arrives while we believe we are offline. + monitor.recheckIfOffline() + + wait(for: [recovered], timeout: 5.0) + XCTAssertTrue(monitor.isNetworkAvailable) + } + + /// A burst of events must not become a burst of probes. + func testRecheckIfOffline_isThrottled_whileTheIntervalHasNotElapsed() { + let counter = ProbeCounter() + // Never recovers, so every allowed call would otherwise probe. + installProbe(failuresBeforeSuccess: .max, counter: counter) + monitor.reachabilityRecheckInterval = 60 + arrangeOfflineOnALiveInterface(counter: counter) + + let afterFirstProbe = counter.value + for _ in 0..<20 { + monitor.recheckIfOffline() + } + + let settled = expectation(description: "any probes settled") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settled.fulfill() } + wait(for: [settled], timeout: 2.0) + + XCTAssertEqual( + counter.value, afterFirstProbe, + "20 events inside the throttle window must not trigger a probe") + } + + /// While connectivity is verified there is nothing to recover, so no probe should run. + func testRecheckIfOffline_doesNothing_whenAlreadyOnline() { + let counter = ProbeCounter() + installProbe(failuresBeforeSuccess: 0, counter: counter) + monitor.reachabilityRecheckInterval = 0 + + let online = expectation(description: "monitor reports internet access") + online.assertForOverFulfill = false + delegateSpy.onAvailable = { online.fulfill() } + monitor.updateInterfaceState(hasInterface: true, connectionType: .wifi) + wait(for: [online], timeout: 5.0) + + let afterOnline = counter.value + for _ in 0..<5 { + monitor.recheckIfOffline() + } + + let settled = expectation(description: "any probes settled") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settled.fulfill() } + wait(for: [settled], timeout: 2.0) + + XCTAssertEqual(counter.value, afterOnline, "a verified connection must not be re-probed") + } + + /// Without an interface there is nothing to reach through; the interface transition itself + /// re-probes when one comes back. + func testRecheckIfOffline_doesNothing_whileTheInterfaceIsDown() { + 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 afterDrop = counter.value + for _ in 0..<5 { + monitor.recheckIfOffline() + } + + let settled = expectation(description: "any probes settled") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settled.fulfill() } + wait(for: [settled], timeout: 2.0) + + XCTAssertEqual(counter.value, afterDrop, "no probes should run while the interface is down") + } +} +// swiftlint:enable all From f38f221520397f10b1fdbe805ab60f5f34ffa6b2 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 18:10:34 +0300 Subject: [PATCH 78/95] =?UTF-8?q?=F0=9F=90=9B=20fix(AutoCapture):=20Honor?= =?UTF-8?q?=20redaction=20on=20table=20title=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve captured table titles against the label that owns the text. This respects redaction on cell and header labels while keeping unredacted titles available when only the detail label is redacted. --- .../Extensions/UITableView+AutoCapture.swift | 39 +++++++++++------- .../CellInteractionTextTests.swift | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift index f9cfc5d1..2081a536 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift @@ -81,7 +81,7 @@ internal extension UITableViewCell { /// - Returns: The resolved text content or nil func userpilotResolvedCellText(touchedView: UIView?) -> String? { if let title = userpilotCellTitle() { - return resolvedInteractionText(title) + return title.owner.resolvedInteractionText(title.text) } if let rowText = contentView.userpilotFirstTextInSubtree() { return resolvedInteractionText(rowText) @@ -103,13 +103,21 @@ internal extension UITableViewCell { return nil } - /// The cell's own title: the legacy `textLabel`, or the iOS 14+ content configuration's text. - private func userpilotCellTitle() -> String? { - if let text = textLabel?.text, !text.isEmpty { - return text + /// The cell's own title, together with the view that owns it. + /// + /// The owner matters: text-capture policy is resolved against a view's responder chain, + /// which runs UPWARD. Resolving a child label's text against the cell therefore never sees + /// that label's own `userpilotRedactText`, and published it verbatim. Resolving against the + /// label covers both — the label itself and every ancestor, the cell included. + private func userpilotCellTitle() -> (text: String, owner: UIView)? { + if let label = textLabel, let text = label.text, !text.isEmpty { + return (text, label) } - if #available(iOS 14.0, *) { - return UIKitViewResolver.listConfigurationText(contentConfiguration) + if #available(iOS 14.0, *), + let text = UIKitViewResolver.listConfigurationText(contentConfiguration) { + // A content configuration renders through views the host cannot mark + // individually, so the cell is the correct scope for the policy. + return (text, self) } return nil } @@ -128,7 +136,7 @@ internal extension UITableViewHeaderFooterView { /// - Returns: The resolved text content or nil func userpilotResolvedHeaderFooterText(touchedView: UIView?) -> String? { if let title = userpilotHeaderFooterTitle() { - return resolvedInteractionText(title) + return title.owner.resolvedInteractionText(title.text) } if let headerText = contentView.userpilotFirstTextInSubtree() { return resolvedInteractionText(headerText) @@ -138,14 +146,15 @@ internal extension UITableViewHeaderFooterView { // MARK: - Private Helpers - /// The header/footer's own title: the legacy `textLabel`, or the iOS 14+ content - /// configuration's text. - private func userpilotHeaderFooterTitle() -> String? { - if let text = textLabel?.text, !text.isEmpty { - return text + /// The header/footer's own title, together with the view that owns it. + /// Same owner rule as ``UITableViewCell/userpilotCellTitle()``. + private func userpilotHeaderFooterTitle() -> (text: String, owner: UIView)? { + if let label = textLabel, let text = label.text, !text.isEmpty { + return (text, label) } - if #available(iOS 14.0, *) { - return UIKitViewResolver.listConfigurationText(contentConfiguration) + if #available(iOS 14.0, *), + let text = UIKitViewResolver.listConfigurationText(contentConfiguration) { + return (text, self) } return nil } diff --git a/Tests/UserpilotTests/AutoCapture/CellInteractionTextTests.swift b/Tests/UserpilotTests/AutoCapture/CellInteractionTextTests.swift index 94aaa6a7..97034307 100644 --- a/Tests/UserpilotTests/AutoCapture/CellInteractionTextTests.swift +++ b/Tests/UserpilotTests/AutoCapture/CellInteractionTextTests.swift @@ -109,6 +109,34 @@ final class CellInteractionTextTests: XCTestCase { ) } + /// The row title is read off `textLabel`, a CHILD of the cell, but the policy used to be + /// evaluated on the cell - whose responder chain walks UPWARD and never sees that label. + /// Marking the title itself therefore published it verbatim. + func testCellText_forRedactedTitleLabel_usesRedactionPlaceholder() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = "j.doe@example.com" + cell.textLabel?.userpilotRedactText = true + + XCTAssertEqual( + cell.userpilotResolvedCellText(touchedView: cell.contentView), + Constants.AutoCapture.reductText, + "A redacted title label must never publish its text as target_text" + ) + } + + /// Redaction on the detail label must not suppress a title the host left readable. + func testCellText_forRedactedDetailLabelOnly_stillPublishesTheTitle() { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = "Email" + cell.detailTextLabel?.text = "j.doe@example.com" + cell.detailTextLabel?.userpilotRedactText = true + + XCTAssertEqual( + cell.userpilotResolvedCellText(touchedView: cell.detailTextLabel), + "Email" + ) + } + func testCellText_forCellWithoutAnyLabel_fallsBackToTouchedView() { let cell = UITableViewCell(style: .default, reuseIdentifier: nil) let textView = UITextView() @@ -154,6 +182,19 @@ final class CellInteractionTextTests: XCTestCase { ) } + /// Same child-vs-container mismatch as the cell title. + func testHeaderText_forRedactedTitleLabel_usesRedactionPlaceholder() { + let header = UITableViewHeaderFooterView(reuseIdentifier: nil) + header.textLabel?.text = "j.doe@example.com" + header.textLabel?.userpilotRedactText = true + + XCTAssertEqual( + header.userpilotResolvedHeaderFooterText(touchedView: header.contentView), + Constants.AutoCapture.reductText, + "A redacted header title label must never publish its text as target_text" + ) + } + // MARK: - Collection supplementary view func testSupplementaryText_forTouchedOnCountLabel_usesHeaderTitle() { From 55e8ad70279e2084af5dccb798d118ee9f4507f7 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 18:10:35 +0300 Subject: [PATCH 79/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Reject?= =?UTF-8?q?=20autocapture=20events=20without=20a=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same screen-context check before sending or storing an interaction. Treat empty screen payloads as missing so offline replay cannot submit interactions that the live path should reject. --- .../Analytics/AnalyticsPublisher.swift | 27 +++++++++++-- .../Analytics/AnalyticsPublisherTests.swift | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 1c593398..d476a55b 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -395,6 +395,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { // Network monitor is ready and reports no network: persist locally if offlineEventsHandler.shouldSaveOffline { if event.isScreenEvent, !shouldStoreScreenEventOffline(event) { return } + if rejectsAutoCaptureWithoutScreen(event) { return } // Something needs sending and we believe we are offline, so this is the moment // to re-verify. `NWPathMonitor` only reports interface transitions, so a probe // that failed while the interface stayed up never retries on its own. The call @@ -449,6 +450,27 @@ extension AnalyticsPublisher: AnalyticsPublishing { return isNewScreen || experiencesPublisher?.canRequestScreenEvent() == true } + /** + * An autocapture event without a screen must be neither sent nor stored: its payload is + * meaningless without the surface it happened on. + * + * Checked on the live path AND the offline path. The offline branch of `publish` returns + * before `trackEvent` ever runs, so a screenless autocapture event used to be persisted and + * replayed later regardless. The check is `screen` empty-or-nil rather than just nil: the + * autocapture pipeline can hand over an empty dictionary, which carries no more information + * than a missing one. + * + * Mirrors Android's `rejectsAutoCaptureWithoutScreen`. + * + * - Parameter event: The event to check + * - Returns: true when the event must not be sent or stored + */ + private func rejectsAutoCaptureWithoutScreen(_ event: Event) -> Bool { + guard event.type == .autoCaptureEvent, event.screen?.isEmpty ?? true else { return false } + logger.error("❗ Event Error, Auto capture event must have screen") + return true + } + /** * Persists an event to local storage while offline. * On an offline user switch the old user's stored events are cleared first, @@ -823,10 +845,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { if let screen = event.screen { payload[Constants.Analytics.screenProperty] = screen } - if event.type == .autoCaptureEvent && event.screen == nil { - logger.error("❗ Event Error, Auto capture event must have screen") - return false - } + if rejectsAutoCaptureWithoutScreen(event) { return false } broadcastEvent(event, event.eventTitle, properties: payload) socketManager.publish(event.eventName, payload: payload) diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 984ba9b7..3a0624ed 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -937,6 +937,45 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertFalse(connectCalled) } + // MARK: - Autocapture screen guard (Android parity) + + /// An autocapture event with no screen is meaningless, so it must be neither sent nor STORED. + /// The offline branch of `publish` returns before `trackEvent` runs, so the guard has to sit + /// on that path too or the event is persisted and replayed later. + func testPublish_autoCaptureWithoutScreen_isNotPersistedOffline() { + userpilot.storage.userId = "user-1" + userpilot.offlineEventsHandler.shouldSaveOffline = true + + analyticsPublisher.publish(makeAutoCaptureEvent(screen: nil)) + + XCTAssertTrue( + userpilot.offlineEventsHandler.savedEvents.isEmpty, + "a screenless autocapture event must not reach local storage") + } + + /// The autocapture pipeline can hand over an EMPTY screen dictionary, which carries no more + /// information than a missing one. + func testPublish_autoCaptureWithEmptyScreen_isNotPersistedOffline() { + userpilot.storage.userId = "user-1" + userpilot.offlineEventsHandler.shouldSaveOffline = true + + analyticsPublisher.publish(makeAutoCaptureEvent(screen: [:])) + + XCTAssertTrue( + userpilot.offlineEventsHandler.savedEvents.isEmpty, + "an empty screen map must be treated as no screen") + } + + /// The guard must not swallow a well-formed autocapture event. + func testPublish_autoCaptureWithScreen_isPersistedOffline() { + userpilot.storage.userId = "user-1" + userpilot.offlineEventsHandler.shouldSaveOffline = true + + analyticsPublisher.publish(makeAutoCaptureEvent()) + + XCTAssertEqual(userpilot.offlineEventsHandler.savedEvents.count, 1) + } + // MARK: - Track Event Throttle Key Tests private func makeAutoCaptureEvent( From 37bc71f48a06d20995a8245fb06e09678e252400 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 18:10:35 +0300 Subject: [PATCH 80/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Keep=20o?= =?UTF-8?q?ffline=20replay=20within=20the=20current=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline batch items carry no user ID and inherit the socket user. Drop stored rows belonging to a different user before constructing the batch, and cover mixed-user replay and missing-user fallback. --- .../Analytics/OfflineEventsHandler.swift | 17 +++++++++++ .../Analytics/OfflineEventsHandlerTests.swift | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift index b4a20cc5..c194024e 100644 --- a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift +++ b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift @@ -217,7 +217,24 @@ internal class OfflineEventsHandler: OfflineEventsHandling { var eventsList: [[String: Any]] = [] + // Who the batch will be attributed to. Empty only if storage was cleared + // under us, and then there is nobody to compare against - replay as + // before rather than drop everything. + let currentUserId = self.storage.userId + for eventStorage in localEvents { + // The batch goes out on the current user's channel and its items + // carry no user id, so anything stored under a previous user would be + // reported as this user's activity. `saveEventToLocalStorage` clears + // on an offline user switch; this is the second line of defence for a + // row that outlived that clear. + if currentUserId.isNotEmpty, eventStorage.userId != currentUserId { + self.logger.error( + "⚠️ Dropping offline event stored for another user: %{public}@", + eventStorage.requestId.uuidString) + continue + } + guard let stored = eventStorage.toStoredEvent(), stored.isSupportedSchema else { self.logger.error("⚠️ Failed to decode event from local storage") continue diff --git a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift index 89ba589f..93cf798c 100644 --- a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift +++ b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift @@ -138,6 +138,34 @@ class OfflineEventsHandlerTests: XCTestCase { /// failure bound. The handler's own completion is unusable here: once a batch is published /// the completion is parked in `offlineRestoreCompletion` until the socket ACKs, and the /// mock never ACKs. + /// Mirrors Android: the batch goes out on the CURRENT user's channel and its items carry no + /// user id, so a row that outlived an offline user switch would be reported as this user's + /// activity. + func testRestore_dropsRowsStoredForAnotherUser() throws { + let mine = try XCTUnwrap( + EventStorage(Event(type: .event("mine")), userpilot.config.token, "user-1")) + let theirs = try XCTUnwrap( + EventStorage(Event(type: .event("theirs")), userpilot.config.token, "user-2")) + eventStore.events = [theirs, mine] + + let batch = try captureRestoredBatch() + + let names = batch.compactMap { $0[Constants.Analytics.eventNameProperty] as? String } + XCTAssertEqual(names, ["mine"], "a row stored for another user must not be replayed") + } + + /// With no current user there is nobody to compare against, so replay rather than drop. + func testRestore_keepsEveryRow_whenNoUserIsStored() throws { + storage.userId = "" + let older = try XCTUnwrap( + EventStorage(Event(type: .event("older")), userpilot.config.token, "user-2")) + eventStore.events = [older] + + let batch = try captureRestoredBatch() + + XCTAssertEqual(batch.count, 1) + } + private func captureRestoredBatch( file: StaticString = #filePath, line: UInt = #line From 8731504d21da6996829b64fbda46d13c50caf9ea Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 18:10:36 +0300 Subject: [PATCH 81/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Socket):=20Keep=20mess?= =?UTF-8?q?age=20references=20unique=20across=20threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent pushes and heartbeats could reuse a reference and route an ACK to the wrong event. Guard makeRef in the vendored SwiftPhoenixClient/Socket.swift with a lock, preserving wraparound and caller ordering, with concurrent reference coverage. --- Sources/Userpilot/Socket/SocketManager.swift | 11 +- .../Vendor/SwiftPhoenixClient/Socket.swift | 16 +++ .../Socket/SocketRefTests.swift | 107 ++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 Tests/UserpilotTests/Socket/SocketRefTests.swift diff --git a/Sources/Userpilot/Socket/SocketManager.swift b/Sources/Userpilot/Socket/SocketManager.swift index 0f594e63..27dee507 100644 --- a/Sources/Userpilot/Socket/SocketManager.swift +++ b/Sources/Userpilot/Socket/SocketManager.swift @@ -545,10 +545,13 @@ extension SocketManager: SocketManaging { /// /// Runs on the caller's queue - unlike the connect/teardown paths, which are all serialized on /// main. That is safe for the transport itself (`sendBuffer` is a `SynchronizedArray` and a send - /// on a cancelled task just fails its completion), but `Socket.makeRef()` increments a plain - /// `UInt64`, so a push racing the main-queue heartbeat can duplicate a message ref and misroute - /// its ACK. Hopping to main would fix that; it would also reorder pushes relative to the caller, - /// so it is left alone deliberately rather than by omission. + /// on a cancelled task just fails its completion), and it stays on the caller's queue so pushes + /// are not reordered relative to the caller. + /// + /// Message refs used to be the catch here: `Socket.makeRef()` was an unguarded read-modify-write, + /// so a push racing the heartbeat (`com.phoenix.socket.heartbeat`, not main) could duplicate a + /// ref and misroute its ACK. That is now fixed at the source - `makeRef()` is lock-guarded - so + /// publishing off the caller's queue no longer trades ref safety for ordering. func publish( _ eventName: String, payload: Payload diff --git a/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift b/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift index 22879844..5992af96 100644 --- a/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift +++ b/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift @@ -152,6 +152,20 @@ public class Socket: PhoenixTransportDelegate { /// Ref counter for messages var ref: UInt64 = UInt64.min // 0 (max: 18,446,744,073,709,551,615) + + /// Guards `ref`. + /// + /// `makeRef()` is a read-modify-write, and it is reached from at least three queues: event + /// pushes run on the caller's queue (`Push.send()`), heartbeats on + /// `com.phoenix.socket.heartbeat`, and state-change registrations wherever the caller happens + /// to be. Unsynchronized, two callers observe the same counter and hand the same ref to two + /// different pushes, so a reply resolves whichever push claimed it first. + /// + /// That is not academic here: this SDK's analytics queue is ACK-gated, so a misrouted reply + /// either advances the queue past an event that was never sent or stalls it behind one that + /// can no longer resolve. Measured at ~1.5-3.5% duplicate refs under contention before this + /// lock (see `SocketRefTests`). + private let refLock = NSLock() /// Timer that triggers sending new Heartbeat messages var heartbeatTimer: HeartbeatTimer? @@ -680,6 +694,8 @@ public class Socket: PhoenixTransportDelegate { /// - return: the next message ref, accounting for overflows public func makeRef() -> String { + refLock.lock() + defer { refLock.unlock() } self.ref = (ref == UInt64.max) ? 0 : self.ref + 1 return String(ref) } diff --git a/Tests/UserpilotTests/Socket/SocketRefTests.swift b/Tests/UserpilotTests/Socket/SocketRefTests.swift new file mode 100644 index 00000000..f507d9d7 --- /dev/null +++ b/Tests/UserpilotTests/Socket/SocketRefTests.swift @@ -0,0 +1,107 @@ +// +// SocketRefTests.swift +// Userpilot SDK +// +// Copyright © 2026 Userpilot. All rights reserved. +// + +import XCTest +@testable import Userpilot + +// swiftlint:disable all + +/// Guards the uniqueness of Phoenix message refs. +/// +/// `Socket.makeRef()` is called from at least three queues in this SDK: event pushes run on the +/// caller's queue (`Push.send()`), heartbeats on `com.phoenix.socket.heartbeat`, and state-change +/// registrations wherever the caller happens to be. A duplicated ref means a reply can resolve the +/// wrong push — and because this SDK's analytics queue is ACK-gated, that either advances the queue +/// past an event that was never sent or stalls it behind one that will never resolve. +final class SocketRefTests: XCTestCase { + + private var socket: Socket! + + override func setUp() { + super.setUp() + // Never connected — `makeRef()` is pure counter arithmetic. + socket = Socket("ws://localhost:4000/socket") + } + + override func tearDown() { + socket = nil + super.tearDown() + } + + /// Collects refs from many queues at once. + private final class RefCollector { + private let lock = NSLock() + private var refs: [String] = [] + + func append(_ ref: String) { + lock.lock() + refs.append(ref) + lock.unlock() + } + + var all: [String] { + lock.lock() + defer { lock.unlock() } + return refs + } + } + + func testMakeRef_underConcurrentCallers_neverRepeatsARef() { + let iterations = 5_000 + let collector = RefCollector() + + DispatchQueue.concurrentPerform(iterations: iterations) { _ in + collector.append(socket.makeRef()) + } + + let all = collector.all + XCTAssertEqual(all.count, iterations) + XCTAssertEqual( + Set(all).count, iterations, + "every push must get its own message ref, or replies resolve the wrong push") + } + + /// The heartbeat is the concrete second caller: it runs on its own serial queue while pushes + /// run on the caller's, which is exactly the interleaving that duplicated refs in production. + func testMakeRef_pushesRacingTheHeartbeatQueue_stayUnique() { + let iterations = 2_000 + let collector = RefCollector() + let heartbeatQueue = Defaults.heartbeatQueue + let group = DispatchGroup() + + for _ in 0.. Date: Fri, 25 Sep 2026 18:10:37 +0300 Subject: [PATCH 82/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Serializ?= =?UTF-8?q?e=20identify=20before=20screen=20through=20the=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue internal identifies ahead of screens and wait for resolution without running host identify bookkeeping or repeating token sync. Preserve queued user-switch events and waiting reloads, match ACKs to queue heads, and flush screens without injecting new identifies. Keep regression coverage with the queue behavior it verifies. --- .../Analytics/AnalyticsPublisher.swift | 362 +++++++++++++----- .../Analytics/AnalyticsPublisherTests.swift | 268 ++++++++++++- 2 files changed, 521 insertions(+), 109 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index d476a55b..e16226c2 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -151,17 +151,29 @@ internal class AnalyticsPublisher { /// The user session state machine. private let userSessionStateMachine: UserSessionStateManaging - /// Guards `pendingIdentifyBeforeScreenAcks` — pushes and socket resolutions arrive on - /// different queues. - private let identifyBeforeScreenLock = NSLock() + /// Guards identify insertion, predecessor, and parked reload state across processing queues. + private let publishCorrelationLock = NSLock() - /// Identify pushes sent by `publishIdentifyBeforeScreen()` that have not resolved yet. + /// True while an identify inserted by `insertIdentifyBeforeScreenIfNeeded()` is still + /// working its way through the queue ahead of the screen that asked for it. /// - /// They bypass `eventsQueue`, but `onSocketEventSent` routes resolutions by event **name** - /// alone: an unclaimed `user_identify` ack there satisfies - /// `isPostIdentificationContext(_:)` and would publish a second screen event on top of the one - /// it precedes. A counter, not a flag: several screens can be in flight. - private var pendingIdentifyBeforeScreenAcks = 0 + /// A one-shot, not a counter: the identify goes through `eventsQueue` like any other event, + /// so its resolution already belongs to a queue head and needs no separate accounting. This + /// only stops the screen from inserting a second identify when it is reprocessed. + private var identifyBeforeScreenInserted = false + + /// True when the event this queue last resolved was an identify. + /// + /// A screen that already follows an acknowledged identify does not need the user restated + /// again - the identify ahead of it did exactly that one push ago. + private var lastResolvedQueuedEventWasIdentify = false + + /// A fake reload that yielded to an inserted identify, replayed once that identify resolves. + private struct PendingFakeReload { + let isFakeReload: Bool + } + + private var pendingFakeReload: PendingFakeReload? // MARK: - Queues & State @@ -259,25 +271,33 @@ extension AnalyticsPublisher: AnalyticsPublishing { func flush() { tryCatch { let events = eventsQueue.getAndClear() - // In case of a new user, clear all queue and cache the identify new user event only - let newUserEvents = events.filter { $0.isIdentifyEvent && $0.userId != storage.userId } - if let firstNewUserEvent = newUserEvents.first { - storage.temporaryUser = firstNewUserEvent.toUser().toJson() - eventsQueue.enqueue(firstNewUserEvent) + let pendingIdentifyIndex = events.firstIndex { + $0.isIdentifyEvent && $0.userId != storage.userId + } + if let pendingIdentifyIndex { + // Requeue the whole tail, not just the identify: those events are that user's + // session and `resume()` replays them once the socket is back. Keeping only the + // identify dropped everything queued behind it. + let pendingEvents = events[pendingIdentifyIndex...] + storage.temporaryUser = pendingEvents.first?.toUser().toJson() + pendingEvents.forEach { eventsQueue.enqueue($0) } } else { events.forEach { event in switch event.type { case .identify: _ = identify(event) case .screen: - _ = screen(event) + // No identify insertion here: `flush` has already drained the queue, so + // requeueing would put an identify back into a queue that is being torn + // down and drop this screen entirely. + _ = resolveScreenOutcome(event) case .event, .autoCaptureEvent: _ = trackEvent(event) } } + closeSocket() + userSessionStateMachine.markUserBackFromBackground() } - closeSocket() - userSessionStateMachine.markUserBackFromBackground() resetProcessingEventStatus() } } @@ -305,7 +325,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { startSession = true // The socket closes below, so an identify sent ahead of a screen will never resolve. Left // outstanding, its stale claim would swallow the next user's first identify ack. - clearIdentifyBeforeScreenAcks() + clearIdentifyBeforeScreenInsertion() // Reset content states experiencesPublisher?.logout() // Clear seen contents from screenSessionStateMachine @@ -336,7 +356,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { func reset() { startSession = true eventThrottle.clear() - clearIdentifyBeforeScreenAcks() + clearIdentifyBeforeScreenInsertion() } /** @@ -613,11 +633,20 @@ extension AnalyticsPublisher: AnalyticsPublishing { return } - // Nothing was pushed (throttled, invalid, or blocked): drop the head - // and continue, otherwise the gate would wait for an ACK that will - // never arrive. - if !publishQueuedEvent(event) { + switch publishQueuedEvent(event) { + case .published: + break + case .skipped: + // Nothing was pushed (throttled, invalid, or blocked): drop the head + // and continue, otherwise the gate would wait for an ACK that will + // never arrive. skipHeadEvent() + case .requeued: + // The head was pushed back behind an identify this cycle inserted ahead of + // it. The head is still valid work - restart the cycle so the identify goes + // out first and this event follows on its resolution. + resetProcessingEventStatus() + processEvent() } } } @@ -640,7 +669,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { } userSessionStateMachine.markNormal() - if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) { + if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false), !isRestatingUserBeforeScreen { releaseAfterEmptyQueue() } } @@ -659,14 +688,25 @@ extension AnalyticsPublisher: AnalyticsPublishing { } /// Publishes the queued event using the matching event-specific path. - private func publishQueuedEvent(_ event: Event) -> Bool { + /// What happened to the queue head this cycle. + private enum QueuedPublishOutcome { + /// A push went out; the head stays enqueued until its resolution. + case published + /// Nothing went out and nothing will - drop the head. + case skipped + /// Nothing went out, but the head is still wanted: an identify was inserted ahead of + /// it and must go first. Restart the cycle rather than dropping anything. + case requeued + } + + private func publishQueuedEvent(_ event: Event) -> QueuedPublishOutcome { switch event.type { case .identify: - return identify(event) + return identify(event) ? .published : .skipped case .screen: return screen(event) case .event, .autoCaptureEvent: - return trackEvent(event) + return trackEvent(event) ? .published : .skipped } } @@ -728,8 +768,12 @@ extension AnalyticsPublisher: AnalyticsPublishing { socketManager.publish(event.eventName, payload: identifyPayload(for: event)) - // Socket is connected with the same user id — request post-identify screen - userSessionStateMachine.markAwaitingInitialScreen() + // Socket is connected with the same user id — request post-identify screen. + // Not for the restatement: the screen it precedes IS that event, and arming this would + // leave the state machine awaiting a screen that is already on its way. + if !isRestatingUserBeforeScreen { + userSessionStateMachine.markAwaitingInitialScreen() + } return true } @@ -745,11 +789,11 @@ extension AnalyticsPublisher: AnalyticsPublishing { } /** - * Re-publishes the device push token alongside an acknowledged identify. + * Re-publishes the device push token for a host identify ACK or cached-user restatement. * * `PushNotificationMonitor.setPushToken` only publishes when the token value changes, so a * returning user whose token is unchanged would otherwise never re-pair token ↔ user on the - * backend. Called once the identify is on the backend so it sees the user first. + * backend. Host identifies sync after their ACK; restatements cache it at insertion. */ private func syncPushToken() { guard canRequestEvent else { return } @@ -757,57 +801,119 @@ extension AnalyticsPublisher: AnalyticsPublishing { } /** - * Claims the resolution of an identify sent by `publishIdentifyBeforeScreen()`, if one is - * outstanding. + * Re-states the cached user immediately before a screen event, when + * `Config.enableRequestIdentifyBeforeScreen(_:)` is on. + * + * A screen event makes the backend evaluate content for the current surface, so the user is + * restated first. The identify is inserted at the FRONT of `eventsQueue` and then travels + * the ordinary path: it is published as the queue head, and the screen behind it follows on + * its resolution. Nothing is pushed around the queue, so every resolution maps to exactly + * one queue head and the ack needs no disambiguation. + * + * One-shot per screen: `identifyBeforeScreenInserted` stops the screen from inserting another + * identify when it comes back round as the head after this one resolves, which would loop + * forever. * - * - Returns: true when this ack belongs to such an identify and must not drive the event queue. + * - Returns: true when an identify was inserted and the caller must yield to it. */ - private func consumeIdentifyBeforeScreenAck() -> Bool { - identifyBeforeScreenLock.lock() - defer { identifyBeforeScreenLock.unlock() } + private func insertIdentifyBeforeScreenIfNeeded() -> Bool { + guard config.requestIdentifyBeforeScreen else { return false } - guard pendingIdentifyBeforeScreenAcks > 0 else { return false } - pendingIdentifyBeforeScreenAcks -= 1 + // An identify was just acknowledged, so the user is already restated on the backend for + // the screen that follows. Repeating it here would be a duplicate. + publishCorrelationLock.lock() + let shouldInsert = !lastResolvedQueuedEventWasIdentify && !identifyBeforeScreenInserted + if shouldInsert { identifyBeforeScreenInserted = true } + publishCorrelationLock.unlock() + guard shouldInsert else { return false } + + let cachedUser = User.fromJson(storage.user) + guard cachedUser.userId.isNotEmpty else { + clearIdentifyBeforeScreenInsertion() + return false + } + + // A reload can enter outside processEvent. Hold its cycle while the token is cached, + // or resyncPushToken re-enters processing before the caller has parked that reload. + _ = isProcessingEvent.compareAndSet(expected: false, new: true) + // Ahead of the screen that triggered it. `isInternalEvent` inserts at index 0. + eventsQueue.enqueue( + Event( + type: .identify(cachedUser.userId), + properties: cachedUser.properties, + company: cachedUser.company.isEmpty ? nil : cachedUser.company + ), + isInternalEvent: true + ) + + // Re-pair token <-> user alongside the restatement. This is why the identify ack no + // longer syncs the token: doing it here keeps it deliberate and keeps the restatement's + // resolution free of the queue path's side effects. + syncPushToken() return true } - /// Drops every outstanding claim (logout / user switch / reset closes the socket). - private func clearIdentifyBeforeScreenAcks() { - identifyBeforeScreenLock.lock() - defer { identifyBeforeScreenLock.unlock() } + /// True while the restatement inserted by ``insertIdentifyBeforeScreenIfNeeded()`` is the + /// event on the wire. + /// + /// The old out-of-band push deliberately skipped the queue path's identify side effects - + /// no `markAwaitingInitialScreen()`, no `storage.temporaryUser` write, no host broadcast - + /// because nothing about the user changed. Routing it through the queue would have + /// subscribed it to all of them, so it is recognised here instead. + /// + /// While the one-shot is claimed the queue head is that identify, then the screen it + /// precedes; a host identify arriving meanwhile lands behind them, never ahead. + private var isRestatingUserBeforeScreen: Bool { + publishCorrelationLock.lock() + defer { publishCorrelationLock.unlock() } + return identifyBeforeScreenInserted + } - pendingIdentifyBeforeScreenAcks = 0 + /// Replays the fake reload that was waiting on the identify which has now resolved. + /// + /// The one-shot stays claimed across the replay so it cannot insert a second identify; if + /// nothing goes out it is released here, or it would stay claimed forever and no later screen + /// could restate the user again. + private func replayParkedFakeReload(_ parked: PendingFakeReload) { + // Admission already updated seen content and consumed the throttle allowance. Keep the + // gate held and preserve the original reload flag rather than admitting the reload again. + if canRequestEvent, eventsQueue.isEmpty() { + scheduleProcessingWatchdog() + if publishScreenEvent(isFakeReload: parked.isFakeReload) { return } + } + clearIdentifyBeforeScreenInsertion() + resetProcessingEventStatus() + processEvent() } - /** - * Re-states the cached user on the socket immediately before a screen event, when - * `Config.enableRequestIdentifyBeforeScreen(_:)` is on. - * - * A screen event makes the backend evaluate content for the current surface, so the user is - * restated first. Published straight to the socket rather than through `eventsQueue`: the - * screen push follows immediately, and enqueuing would reorder the two. - * - * Deliberately skips the queue path's side effects — no `markAwaitingInitialScreen()` (the - * screen it precedes *is* that event) and no `storage.temporaryUser` write, because nothing - * about the user changed. - */ - private func publishIdentifyBeforeScreen() { - guard config.requestIdentifyBeforeScreen else { return } - let cachedUser = User.fromJson(storage.user) - guard cachedUser.userId.isNotEmpty else { return } + /// Claims a parked fake reload, if one is waiting on an identify. + private func takePendingFakeReload() -> PendingFakeReload? { + publishCorrelationLock.lock() + defer { publishCorrelationLock.unlock() } - var payload: [String: Any] = [ - Constants.Analytics.metaDataProperty: cachedUser.properties - ] - if !cachedUser.company.isEmpty { - payload[Constants.Analytics.identifyCompanyProperty] = cachedUser.company + let parked = pendingFakeReload + pendingFakeReload = nil + return parked + } + + /// Replays an admitted reload or releases the gate for the next queued event. + private func continueAfterResolution() { + if let parked = takePendingFakeReload() { + replayParkedFakeReload(parked) + } else { + resetProcessingEventStatus() + processEvent() } - // Claim the ack before the push so a fast resolution cannot arrive unclaimed. - identifyBeforeScreenLock.lock() - pendingIdentifyBeforeScreenAcks += 1 - identifyBeforeScreenLock.unlock() + } - socketManager.publish(Constants.Event.identifyEvent, payload: payload) + /// Releases the one-shot so the next screen can restate the user again. + private func clearIdentifyBeforeScreenInsertion() { + publishCorrelationLock.lock() + defer { publishCorrelationLock.unlock() } + + identifyBeforeScreenInserted = false + lastResolvedQueuedEventWasIdentify = false + pendingFakeReload = nil } /** @@ -816,16 +922,32 @@ extension AnalyticsPublisher: AnalyticsPublishing { * - Parameter event: The screen event to process * - Returns: true when a screen push went out */ - private func screen(_ event: Event) -> Bool { + private func screen(_ event: Event) -> QueuedPublishOutcome { + // An identify must precede this screen, and it is now the queue head. This screen + // stays enqueued behind it and is republished when the identify resolves. + if insertIdentifyBeforeScreenIfNeeded() { + return .requeued + } + let outcome = resolveScreenOutcome(event) + // The screen this identify preceded is not going out (throttled, or not a screen the + // publisher will report). Release the one-shot, or no later screen could ever restate + // the user again. + if case .skipped = outcome { + clearIdentifyBeforeScreenInsertion() + } + return outcome + } + + private func resolveScreenOutcome(_ event: Event) -> QueuedPublishOutcome { // Returns true if this is a new screen, which triggers screen event if setupScreenEvent(event) { - return publishScreenEvent(isFakeReload: false) + return publishScreenEvent(isFakeReload: false) ? .published : .skipped } // Not a new screen, check if valid to trigger screen event if experiencesPublisher?.canRequestScreenEvent() == true { - return publishScreenEvent(isFakeReload: false) + return publishScreenEvent(isFakeReload: false) ? .published : .skipped } - return false + return .skipped } /** @@ -1036,8 +1158,17 @@ extension AnalyticsPublisher: SocketSubscription { func onSocketClosed() { tryCatch { resetProcessingEventStatus() - // Socket closed from error state, don't reopen, keep events for next open - if socketManager.didCloseFromError { return } + // A transport error keeps the queue, including an inserted identify. Keep its + // classification and parked reload for the retry; otherwise its next ACK would + // broadcast a host identify and erase the host's pending user update. + if socketManager.didCloseFromError { + publishCorrelationLock.lock() + lastResolvedQueuedEventWasIdentify = false + publishCorrelationLock.unlock() + return + } + // Intentional closes end the insertion's lifetime with the old session. + clearIdentifyBeforeScreenInsertion() // Background close: keep the queue as-is (a queued new-user identify is // picked up by resume() on foreground). Re-publishing here would hit the // app-inactive guard and silently drop the event. @@ -1068,28 +1199,39 @@ extension AnalyticsPublisher: SocketSubscription { // through their own direct subscriptions guard eventName.isAnalyticsEvent() else { return } - // An identify sent ahead of a screen owns no queue head. Claimed before the dequeue - // below so it cannot consume an unrelated event, and returned early so it cannot - // request a post-identify screen event on top of the screen it precedes. - if eventName == Constants.Event.identifyEvent, - consumeIdentifyBeforeScreenAck() { - return - } - - // Remove the in-flight head - it stayed enqueued until this resolution - let event = eventsQueue.dequeue() + // Remove the in-flight head - it stayed enqueued until this resolution. + // + // Only when the head is what actually resolved. A screen pushed around the queue (a + // fake reload, which is allowed out only while the queue is empty) owns no head, so an + // unconditional dequeue let its resolution consume whatever the app enqueued during + // the round trip - dropping an event that was never sent. Matching on the event name + // needs no per-push identity: the out-of-band pushes are screens, and a screen can + // only be queued behind them, never ahead. + let event = eventsQueue.getFirst()?.eventName == eventName + ? eventsQueue.dequeue() + : nil + + publishCorrelationLock.lock() + let wasRestatement = identifyBeforeScreenInserted && event?.isIdentifyEvent == true + // A failed intervening event also breaks adjacency. Only a successful queued + // identify can satisfy the next screen's requirement. + lastResolvedQueuedEventWasIdentify = eventSent && event?.isIdentifyEvent == true + publishCorrelationLock.unlock() guard eventSent else { logger.error( "⚠️ Event not acknowledged (%{public}@), dropping and continuing queue", eventName) - resetProcessingEventStatus() - processEvent() + continueAfterResolution() return } + // The restatement carries nothing new about the user, so it must not run any of + // this: no cached-user write, no `temporaryUser` clear, no host broadcast, no token + // resync. That is what the out-of-band push it replaced deliberately skipped. // Update cached user object - if let event, eventName == Constants.Event.identifyEvent && event.userId == storage.userId { + if let event, !wasRestatement, + eventName == Constants.Event.identifyEvent && event.userId == storage.userId { var newUser = User.fromJson(storage.user) storage.user = newUser.updateUser(event: event).toJson() ?? "" logger.info("👤 USER %{public}@", storage.user) @@ -1106,7 +1248,7 @@ extension AnalyticsPublisher: SocketSubscription { } // Handle request screen event after user identify event - if userSessionStateMachine.isPostIdentificationContext(eventName) + if !wasRestatement && userSessionStateMachine.isPostIdentificationContext(eventName) && userSessionStateMachine.shouldRequestInitialScreenEvent( eventsQueue.isEmpty(), experiencesPublisher?.getCurrentScreen.isNotEmpty == true) { @@ -1114,17 +1256,14 @@ extension AnalyticsPublisher: SocketSubscription { // fresh watchdog window for the new in-flight push scheduleProcessingWatchdog() let published = publishScreenEvent( - isFakeReload: userSessionStateMachine.getPostIdentificationFakeReloadConfig(), - // An identify was just acknowledged; repeating it here would be a duplicate. - precedeWithIdentify: false) + isFakeReload: userSessionStateMachine.getPostIdentificationFakeReloadConfig()) if !published { resetProcessingEventStatus() processEvent() } } else { // Continue processing the next event in the queue - resetProcessingEventStatus() - processEvent() + continueAfterResolution() } } } @@ -1306,6 +1445,19 @@ extension AnalyticsPublisher { screenTitle: screenSessionStateMachine.event.screenTitle ?? "") { return } + + // A fake reload is not a queue entry, so it cannot simply wait its turn behind the + // identify. Park it, let the identify go out as the queue head, and replay it from + // the identify's resolution so the order still holds. + if insertIdentifyBeforeScreenIfNeeded() { + publishCorrelationLock.lock() + pendingFakeReload = PendingFakeReload(isFakeReload: isFakeReload) + publishCorrelationLock.unlock() + resetProcessingEventStatus() + processEvent() + return + } + published = publishScreenEvent(isFakeReload: isFakeReload) } return published @@ -1338,18 +1490,19 @@ extension AnalyticsPublisher { * Publishes the current screen session state as a screen event, carrying * session-start state, fake-reload flag, and seen experiences/surveys. * - * This is the single place a `screen` message is published, so it is also the single place the - * preceding identify is sent when `Config.enableRequestIdentifyBeforeScreen(_:)` is on and the - * single place the cached internal SDK events are flushed ahead of it. + * This is the single place a `screen` message is published, and the single place the cached + * internal SDK events are flushed ahead of it. + * + * The preceding identify is NOT pushed from here. It is inserted at the front of + * `eventsQueue` by ``screen(_:)`` for client screens, and by + * ``publishFakeReloadScreenEvent(_:_:isFakeReload:)`` for SDK fake reloads, so it always + * travels the ordinary ack-gated path ahead of the screen it precedes. * - * - Parameter precedeWithIdentify: false only for the screen event that already follows an - * acknowledged identify, which would otherwise repeat it. * - Returns: true when a screen push went out */ @discardableResult private func publishScreenEvent( - isFakeReload: Bool = false, - precedeWithIdentify: Bool = true + isFakeReload: Bool = false ) -> Bool { ensureScreenSessionStateMachine() guard let screenSessionStateMachine else { return false } @@ -1367,8 +1520,6 @@ extension AnalyticsPublisher { // before any screen message can be pushed from here. processSDKEvent() - if precedeWithIdentify { publishIdentifyBeforeScreen() } - let screenEvent = screenSessionStateMachine.event if let screenTitle = screenEvent.screenTitle { if shouldSyncManualScreenForInteractionPayload() { @@ -1400,6 +1551,9 @@ extension AnalyticsPublisher { existingMetadata.merging(newMetadata) { _, new in new } socketManager.publish(screenEvent.eventName, payload: payload) + // The screen this identify preceded is now on the wire: let the next screen + // restate the user again. + clearIdentifyBeforeScreenInsertion() if isFakeReload { suppressScreenAutocaptureAfterFakeReload() diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 3a0624ed..3907d1d7 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -371,6 +371,32 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertNotEqual(userpilot.storage.user, "") } + /// A fake reload owns no queue entry. Its ACK must release a waiting track without + /// removing it before the track's own ACK arrives. + func testTrackEnqueuedDuringAFakeReloadRoundTrip_isNotConsumedByTheScreenAck() { + userpilot.experiencesPublisher.onCanRequestScreenEvent = { true } + arrangeReloadableScreen(title: "Home") + var published = recordPublishedEvents() + + XCTAssertTrue(analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true)) + XCTAssertEqual(published(), [Constants.Event.screenEvent]) + published = recordPublishedEvents() + + // Hold the track in the queue until the screen ACK resumes processing. + userpilot.socketManager.isJoiningSocket = true + analyticsPublisher.publish(Event(type: .event("purchase_completed"))) + XCTAssertTrue(published().isEmpty) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.eventTitle, "purchase_completed") + userpilot.socketManager.isJoiningSocket = false + + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + + XCTAssertEqual(published(), [Constants.Event.trackEvent]) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().count, 1) + analyticsPublisher.onSocketEventSent(Constants.Event.trackEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + } + // MARK: - Experience Events Tests func testCanRequestEvent_shouldReturnSocketState() { @@ -937,6 +963,223 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertFalse(connectCalled) } + // MARK: - identify-before-screen side effects + + /// The restatement carries nothing new about the user, so its ack must not run the identify + /// bookkeeping. `syncPushToken` used to fire from there - once per screen, invisibly. It is + /// now published deliberately alongside the restatement instead. + func testRestatement_resyncsTheTokenOnce_andNotAgainFromItsAck() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + + var resyncCount = 0 + userpilot.pushNotificationMonitor.onResyncPushToken = { resyncCount += 1 } + + analyticsPublisher.publish(Event(type: .screen("Another Screen"))) + XCTAssertEqual(resyncCount, 1, "the token is re-paired alongside the restatement") + + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual( + resyncCount, 1, + "the restatement's ack must not re-sync the token a second time") + } + + /// A screen that already follows an acknowledged identify does not need the user restated + /// again - the identify one push ago did exactly that. + func testScreenFollowingAnIdentify_doesNotInsertASecondIdentify() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + + let published = recordPublishedEvents() + // Use changed properties so the host identify is actually sent, then queue its screen. + analyticsPublisher.publish(identifyEventForCurrentUser()) + analyticsPublisher.publish(Event(type: .screen("Another Screen"))) + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) + XCTAssertEqual(User.fromJson(userpilot.storage.user).properties["plan"] as? String, "enterprise") + } + + /// `flush` drains the queue itself, so a screen it replays must go out rather than be pushed + /// back into a queue that is being torn down. + func testFlush_withTheFlagOn_stillPublishesAQueuedScreen() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + // Keep the screen unsent behind a track event until flush drains the queue. + analyticsPublisher.publish(Event(type: .event("before-background"))) + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + let published = recordPublishedEvents() + + analyticsPublisher.flush() + + XCTAssertEqual(published(), [Constants.Event.trackEvent, Constants.Event.screenEvent]) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + } + + private func identifyEventForCurrentUser() -> Event { + Event(type: .identify(userpilot.storage.userId), properties: ["plan": "enterprise"]) + } + + func testRestatement_preservesPendingHostIdentifyAndDoesNotBroadcast() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + let delegate = IdentifyDelegate() + userpilot.analyticsDelegate = delegate + let cachedUser = userpilot.storage.user + let state = userpilot.container.resolve(UserSessionStateManaging.self) + + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + XCTAssertTrue(state.isNormal(), "restating a user must not arm the initial-screen state") + analyticsPublisher.publish(identifyEventForCurrentUser()) + let pendingUser = userpilot.storage.temporaryUser + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + XCTAssertEqual(userpilot.storage.user, cachedUser) + XCTAssertEqual(userpilot.storage.temporaryUser, pendingUser) + XCTAssertTrue(delegate.identifies.isEmpty) + + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + let delivered = expectation(description: "analytics delegate callbacks delivered") + DispatchQueue.main.async { delivered.fulfill() } + wait(for: [delivered], timeout: 2) + XCTAssertEqual(delegate.identifies, ["reload-user"]) + XCTAssertNil(userpilot.storage.temporaryUser) + XCTAssertEqual(User.fromJson(userpilot.storage.user).properties["plan"] as? String, "enterprise") + } + + func testRestatement_cachesPushTokenUntilTheProcessingCycleContinues() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + // Use the real token monitor and route it back into this publisher. + userpilot.container.register(AnalyticsPublishing.self, value: analyticsPublisher) + let monitor = PushNotificationMonitor(container: userpilot.container) + userpilot.storage.pushToken = "test-token" + let published = recordPublishedEvents() + userpilot.pushNotificationMonitor.onResyncPushToken = { + monitor.resyncPushToken() + XCTAssertTrue(published().isEmpty, "the token must wait in the SDK cache while the gate is held") + } + + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + + XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent]) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent, Constants.Event.screenEvent]) + } + + func testScreenAfterFailedTrack_insertsIdentifyEvenIfAnEarlierIdentifySucceeded() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + analyticsPublisher.publish(identifyEventForCurrentUser()) + analyticsPublisher.publish(Event(type: .event("between-identify-and-screen"))) + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + let published = recordPublishedEvents() + + analyticsPublisher.onSocketEventSent(Constants.Event.trackEvent, nil, Message(), false) + + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + } + + func testRestatement_forBackgroundReload_preservesTheReloadFlag() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + var screenMetadata: [String: Any]? + userpilot.socketManager.onPublish = { name, payload in + if name == Constants.Event.screenEvent { + screenMetadata = payload?[Constants.Analytics.metaDataProperty] as? [String: Any] + } + } + analyticsPublisher.publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + XCTAssertEqual(screenMetadata?[Constants.Analytics.fakeReload] as? Bool, false) + } + + func testRestatement_whenIdentifyFails_stillReplaysTheParkedReload() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + let published = recordPublishedEvents() + analyticsPublisher.publishFakeReloadScreenEvent(nil, nil, isFakeReload: true) + + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), false) + + XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) + } + + func testRestatement_forReloadWithPushToken_sendsOnlyOneIdentify() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + userpilot.container.register(AnalyticsPublishing.self, value: analyticsPublisher) + let monitor = PushNotificationMonitor(container: userpilot.container) + userpilot.storage.pushToken = "test-token" + userpilot.pushNotificationMonitor.onResyncPushToken = { monitor.resyncPushToken() } + let published = recordPublishedEvents() + + analyticsPublisher.publishFakeReloadScreenEvent(nil, nil, isFakeReload: true) + XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent]) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent, Constants.Event.screenEvent]) + } + + func testUserSwitch_queuedBehindRestatement_reconnectsAsTheNewUser() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + var closes = 0 + userpilot.socketManager.onClose = { closes += 1 } + + analyticsPublisher.publish(Event(type: .screen("Old user screen"))) + analyticsPublisher.publish(Event(type: .identify("next-user"))) + analyticsPublisher.publish(Event(type: .screen("New user screen"))) + let pendingUser = userpilot.storage.temporaryUser + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(closes, 0) + XCTAssertEqual(userpilot.storage.temporaryUser, pendingUser) + + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertEqual(closes, 1) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.userId, "next-user") + userpilot.socketManager.isSocketOpened = false + analyticsPublisher.onSocketClosed() + XCTAssertEqual(userpilot.storage.userId, "next-user") + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + XCTAssertEqual(User.fromJson(userpilot.storage.user).userId, "next-user") + XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "New user screen") + } + + func testRestatement_afterReconnect_staysInternalAndPreservesPendingHostIdentify() { + userpilot.config.requestIdentifyBeforeScreen = true + arrangeReloadableScreen() + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + analyticsPublisher.publish(identifyEventForCurrentUser()) + let pendingUser = userpilot.storage.temporaryUser + userpilot.socketManager.didCloseFromError = true + userpilot.socketManager.isSocketOpened = false + analyticsPublisher.onSocketClosed() + userpilot.socketManager.didCloseFromError = false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + XCTAssertEqual(userpilot.storage.temporaryUser, pendingUser) + XCTAssertTrue(userpilot.container.resolve(UserSessionStateManaging.self).isNormal()) + XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "Checkout") + } + + private final class IdentifyDelegate: NSObject, UserpilotAnalyticsDelegate { + var identifies: [String] = [] + + func didTrack(analytic: UserpilotAnalytic, value: String, properties: [String: Any]?) { + if analytic == .identify { identifies.append(value) } + } + } + // MARK: - Autocapture screen guard (Android parity) /// An autocapture event with no screen is meaningless, so it must be neither sent nor STORED. @@ -1214,6 +1457,11 @@ class AnalyticsPublisherTests: XCTestCase { ).toJson() ?? "" analyticsPublisher.publish(Event(type: .screen(title))) + // With the flag on the identify is inserted ahead of the screen and is the first + // thing on the wire, so the screen only goes out once that identify is resolved. + if userpilot.config.requestIdentifyBeforeScreen { + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + } analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) let throttleWindow = XCTestExpectation(description: "screen throttle window elapsed") @@ -1254,6 +1502,12 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + // The identify travels the queue like any other event, so the reload it precedes does + // not go out until that identify resolves. + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) } @@ -1265,10 +1519,14 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.publish(Event(type: .screen("Another Screen"))) + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) } - func testReloadIdentifyCarriesTheCachedUser_whenTheFlagIsOn() { + func testScreenIdentifyCarriesTheCachedUser_whenTheFlagIsOn() { userpilot.config.requestIdentifyBeforeScreen = true arrangeReloadableScreen() var identifyPayload: [String: Any]? @@ -1276,7 +1534,7 @@ class AnalyticsPublisherTests: XCTestCase { if name == Constants.Event.identifyEvent { identifyPayload = payload } } - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + analyticsPublisher.publish(Event(type: .screen("Another Screen"))) let metadata = identifyPayload?[Constants.Analytics.metaDataProperty] as? [String: String] XCTAssertEqual(metadata, ["plan": "pro"]) @@ -1297,10 +1555,10 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.config.requestIdentifyBeforeScreen = true arrangeReloadableScreen() let published = recordPublishedEvents() - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + analyticsPublisher.publish(Event(type: .screen("Another Screen"))) - // That identify never entered the queue, so its ack owns no head. Unclaimed, it satisfies - // isPostIdentificationContext and would duplicate the screen event. + // The identify owns a queue head now, so its ack releases exactly one screen - it can + // no longer satisfy isPostIdentificationContext and duplicate the screen event. analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) From d08cfe681c623e375b4a21a996ad94710c0575be Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 18:10:37 +0300 Subject: [PATCH 83/95] =?UTF-8?q?=E2=9C=85=20test:=20Strengthen=20SDK=20as?= =?UTF-8?q?sertions=20and=20remove=20placeholder=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify payloads, state transitions, callbacks, and ACK results in existing cases. Remove the empty sample test and unused mock hook, and make asynchronous checks less timing-sensitive. Keep LoggingTests.swift restored as requested. --- .../UserpilotSwiftUISampleTests.swift | 17 --- .../Analytics/AnalyticsPublisherTests.swift | 88 +++++------ .../Analytics/OfflineEventsHandlerTests.swift | 4 +- .../Analytics/SessionMonitorTests.swift | 25 ++- .../ExperiencesPublisherTests.swift | 142 ++++++++++-------- Tests/UserpilotTests/MockUserpilot.swift | 7 +- .../MultiInstanceIntegrationTests.swift | 9 +- Tests/UserpilotTests/UserpilotTests.swift | 29 +--- 8 files changed, 150 insertions(+), 171 deletions(-) delete mode 100644 Sample/UserpilotSwiftUISample/UserpilotSwiftUISampleTests/UserpilotSwiftUISampleTests.swift diff --git a/Sample/UserpilotSwiftUISample/UserpilotSwiftUISampleTests/UserpilotSwiftUISampleTests.swift b/Sample/UserpilotSwiftUISample/UserpilotSwiftUISampleTests/UserpilotSwiftUISampleTests.swift deleted file mode 100644 index a05271a3..00000000 --- a/Sample/UserpilotSwiftUISample/UserpilotSwiftUISampleTests/UserpilotSwiftUISampleTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// UserpilotSwiftUISampleTests.swift -// UserpilotSwiftUISampleTests -// -// Created by Motasem Hamed on 11/01/2026. -// - -import Testing -@testable import UserpilotSwiftUISample - -struct UserpilotSwiftUISampleTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 3907d1d7..20eb6317 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -33,21 +33,27 @@ class AnalyticsPublisherTests: XCTestCase { // MARK: - Publish Method Tests func testPublish_identifyEvent_shouldCacheEventAndUpdateStorage() { - // Arrange - let userId = "test-user-123" - let properties = ["name": "John Doe", "email": "john@example.com"] - let company = ["name": "Test Company", "id": "company-123"] - let identifyEvent = Event( - type: .identify(userId), - properties: properties, - company: company + userpilot.storage.userId = "" + userpilot.storage.user = "" + userpilot.socketManager.isJoiningSocket = true + let identify = Event( + type: .identify("user-123"), + properties: ["plan": "pro"], + company: ["id": "company-123"] ) - // Act - analyticsPublisher.publish(identifyEvent) + analyticsPublisher.publish(identify) - // Assert - XCTAssertTrue(userpilot.socketManager.isShutdownState || !userpilot.socketManager.isSocketOpened) + let queued = analyticsPublisher.mockGetEventsToFlush() + XCTAssertEqual(queued.count, 1) + XCTAssertEqual(queued.first?.userId, "user-123") + XCTAssertEqual(queued.first?.properties?["plan"] as? String, "pro") + XCTAssertEqual(queued.first?.company?["id"] as? String, "company-123") + let pending = User.fromJson(userpilot.storage.temporaryUser.orEmpty()) + XCTAssertEqual(pending.userId, "user-123") + XCTAssertEqual(pending.properties["plan"] as? String, "pro") + XCTAssertEqual(pending.company["id"] as? String, "company-123") + XCTAssertEqual(userpilot.storage.user, "", "Unacknowledged data must not be committed") } func testPublish_screenEvent_shouldSetupScreenSessionStateMachine() { @@ -247,13 +253,12 @@ class AnalyticsPublisherTests: XCTestCase { // MARK: - Reset Tests func testReset_shouldResetStartSessionFlag() { - // Arrange - // Simulate that start session was previously false + userpilot.storage.sessionDate = Date().addingTimeInterval(-60) + analyticsPublisher.updateSessionState() + XCTAssertFalse(analyticsPublisher.isStartSession) - // Act analyticsPublisher.reset() - // Assert XCTAssertTrue(analyticsPublisher.isStartSession) } @@ -353,22 +358,20 @@ class AnalyticsPublisherTests: XCTestCase { } func testOnSocketEventSent_shouldUpdateUserOnIdentifyEvent() { - // Arrange - let userId = "test-user" - let identifyEvent = Event(type: .identify(userId)) - userpilot.storage.userId = userId - userpilot.storage.user = "{\"userId\":\"test-user\",\"properties\":{}}" - - // Simulate cached identify event - analyticsPublisher.publish(identifyEvent) - - let payload: [String: Any] = ["test": "data"] + userpilot.storage.userId = "test-user" + userpilot.storage.user = User(userId: "test-user", properties: ["plan": "free"]).toJson()! + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .identify("test-user"), properties: ["plan": "pro"])) + XCTAssertEqual(User.fromJson(userpilot.storage.user).properties["plan"] as? String, "free") + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().count, 1) - // Act - analyticsPublisher.onSocketEventSent("identify", payload, Message(), true) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - // Assert - XCTAssertNotEqual(userpilot.storage.user, "") + let user = User.fromJson(userpilot.storage.user) + XCTAssertEqual(user.userId, "test-user") + XCTAssertEqual(user.properties["plan"] as? String, "pro") + XCTAssertTrue(userpilot.storage.temporaryUser.orEmpty().isEmpty) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) } /// A fake reload owns no queue entry. Its ACK must release a waiting track without @@ -839,20 +842,16 @@ class AnalyticsPublisherTests: XCTestCase { } func testPublishFakeReloadScreenEvent_withSameTimeForScreenEvent_shouldNotPublishScreenEvent() { - // Arrange userpilot.socketManager.isSocketOpened = true - let screenEvent = Event(type: .screen("Test Screen")) - analyticsPublisher.publish(screenEvent) - - var publishScreenEventCalled = false - userpilot.socketManager.onPublish = { _, _ in publishScreenEventCalled = true } + analyticsPublisher.publish(Event(type: .screen("Test Screen"))) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + let published = recordPublishedEvents() - // Act - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10) + let sent = analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) - // Assert - // Would need to verify socket manager publish was called with fake reload flag - XCTAssertFalse(publishScreenEventCalled) + XCTAssertFalse(sent) + XCTAssertEqual(published(), []) } func testExperiencePublished_shouldUpdateSeenExperiences() { @@ -1568,11 +1567,14 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.config.requestIdentifyBeforeScreen = true arrangeReloadableScreen() analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) let published = recordPublishedEvents() - // A second dismissal inside the throttle window sends no identify either. - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 11, isFakeReload: true) + let sent = analyticsPublisher.publishFakeReloadScreenEvent(.flow, 11, isFakeReload: true) + XCTAssertFalse(sent) XCTAssertEqual(published(), []) } diff --git a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift index 93cf798c..c04514fb 100644 --- a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift +++ b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift @@ -179,7 +179,7 @@ class OfflineEventsHandlerTests: XCTestCase { } handler.restoreEventsFromLocalStorage { } - wait(for: [published], timeout: 2.0) + wait(for: [published], timeout: 10.0) let batch = try XCTUnwrap(captured, file: file, line: line) return try XCTUnwrap( @@ -198,7 +198,7 @@ class OfflineEventsHandlerTests: XCTestCase { let finished = expectation(description: "restore finished") handler.restoreEventsFromLocalStorage { finished.fulfill() } - wait(for: [finished], timeout: 2.0) + wait(for: [finished], timeout: 10.0) return publishedEventNames } diff --git a/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift b/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift index 43eaaaf2..d79d6e09 100644 --- a/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift +++ b/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift @@ -72,21 +72,20 @@ final class SessionMonitorTests: XCTestCase { XCTAssertEqual(trackedResumeEvent, 2, "Resume should run on every activation, not only the first") } - func testInit_shouldResumeAnalyticsIfAppIsActive() { - // Arrange - var trackedResumeEvent = 0 - userpilot.analyticsPublisher.onResume = { trackedResumeEvent += 1 } - - let expectation = self.expectation(description: "Initial active app handling") + func testInit_shouldResumeOnce_WhenActivationNotificationArrives() { + monitor.reset() + monitor = nil + var resumeCount = 0 + userpilot.analyticsPublisher.onResume = { resumeCount += 1 } + monitor = SessionMonitor(container: userpilot.container) - // Act - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - XCTAssertGreaterThanOrEqual(trackedResumeEvent, 0, "Resume may be called if app is active at init") - expectation.fulfill() - } + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + let initialized = expectation(description: "init catch-up completed") + DispatchQueue.main.async { initialized.fulfill() } + wait(for: [initialized], timeout: 1.0) - // Assert - wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(resumeCount, 1, "Init catch-up and the activation must start one session") + XCTAssertTrue(monitor.isAppActive) } func testReset_shouldRemoveNotificationObservers() { diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 8720ee57..9c5add15 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -552,51 +552,48 @@ final class ExperiencesPublisherTests: XCTestCase { } func testOnNewMessage_shouldNotProcessContent_WhenRequestIdExists() { - // Arrange - let payload: [String: Any] = [ - "mobile_contents": ["test": "data"], - "request_id": 123 - ] - let message = Message(payload: ["payload": payload]) + var rejected = MockContentFactory.makeFlowContentPayload() + rejected["request_id"] = 123 + var accepted = MockContentFactory.makeFlowContentPayload() + var acceptedFlow = accepted["mobile_contents"] as! [String: Any] + acceptedFlow["id"] = 78 + accepted["mobile_contents"] = acceptedFlow + XCTAssertNotNil(rejected.toJSONString()?.toFlowContent(), "The rejected response must otherwise be valid") + let processed = expectation(description: "accepted response reached theme lookup") + userpilot.themeHandler.onGetThemeById = { _ in + processed.fulfill() + return nil + } - // Act - experiencesPublisher.onNewMessage(message) + experiencesPublisher.onNewMessage(Message(payload: ["payload": rejected])) + experiencesPublisher.onNewMessage(Message(payload: ["payload": accepted])) + wait(for: [processed], timeout: 1.0) - // Assert - let result = experiencesPublisher.getActiveMobileContent() - XCTAssertNil(result) + XCTAssertEqual(experiencesPublisher.getActiveMobileContent()?.experienceId(), 78) } func testOnNewMessage_shouldNotProcessContent_WhenActiveExperienceExists() { - // Arrange - let expectation = XCTestExpectation(description: "First content should be processed, second should not") - let mockPayload: [String: Any?] = MockContentFactory.makeFlowContentPayload() - let firstMessage = Message(payload: ["payload": mockPayload]) + let renderer = MockUPExperience() + experiencesPublisher.mockActiveExperience(experience: renderer) + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceContent()?.experienceId() == 77 + }, + object: nil + ) - // Process first message - experiencesPublisher.onNewMessage(firstMessage) + experiencesPublisher.onNewMessage(Message(payload: ["payload": MockContentFactory.makeFlowContentPayload()])) + wait(for: [cached], timeout: 2.0) - // Wait for first message to be processed, then try second message - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // Now try to process another message - this should be ignored due to pending experience - let secondMessage = Message(payload: ["payload": mockPayload]) - self.experiencesPublisher.onNewMessage(secondMessage) - - // Wait a bit more and check result - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let result = self.experiencesPublisher.getActiveMobileContent() - // Should still be the first (flow) content - if case .flow(let content) = result { - let expectedId = (mockPayload["mobile_contents"] as? [String: Any])?["id"] as? Int - XCTAssertEqual(content.id, expectedId) - } else { - XCTFail("Expected flow content to remain") - } - expectation.fulfill() - } + XCTAssertNil(experiencesPublisher.getActiveMobileContent()) + XCTAssertEqual(userpilot.experienceStateMachine.getCachedExperienceContent()?.experienceId(), 77) + let closed = expectation(description: "original renderer remains active") + renderer.onTriggerClose = { manual in + XCTAssertTrue(manual) + closed.fulfill() } - - wait(for: [expectation], timeout: 1.0) + experiencesPublisher.endExperience(manualClose: true) + wait(for: [closed], timeout: 1.0) } // MARK: - publishInternalSDKEvent Tests @@ -755,16 +752,25 @@ final class ExperiencesPublisherTests: XCTestCase { } func testPublishInternalSDKEvent_shouldHandleCloseEvent_WithDeepLink() { - // Arrange - let mockEvent = MockSDKEvent(eventName: "close-event", hasDeepLink: true) - mockEvent.isCloseEvent = true + userpilot.experienceStateMachine.markActiveFromCurrentState( + content: .survey(content: MockContentFactory.makeSurveyContent()) + ) + XCTAssertTrue(userpilot.experienceStateMachine.isActive()) + var published: [String] = [] + var reloadCount = 0 + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { published.append($0.eventName) } + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in + reloadCount += 1 + return true + } + let event = MockSDKEvent(eventName: "dismissed_mobile_content", hasDeepLink: true) + event.isCloseEvent = true - // Act - experiencesPublisher.publishInternalSDKEvent(mockEvent) - let result = experiencesPublisher.getActiveMobileContent() + experiencesPublisher.publishInternalSDKEvent(event) - // Assert - XCTAssertNil(result) + XCTAssertEqual(published, ["dismissed_mobile_content"]) + XCTAssertFalse(userpilot.experienceStateMachine.isActive()) + XCTAssertEqual(reloadCount, 0) } func testPublishInternalSDKEvent_shouldHandleCloseEvent_withoutDeepLink() { @@ -819,21 +825,19 @@ final class ExperiencesPublisherTests: XCTestCase { let mockSurveyContent = MockContentFactory.makeSurveyContent() let mockSurveyTheme = MockContentFactory.makeSurveyTheme() - let expectation = XCTestExpectation(description: "Wait for thank you message") let mockVC = MockUPExperience() experiencesPublisher.topViewControllerProvider = { return mockVC } // Act experiencesPublisher.showThankYouMessage(mockSurveyContent, mockSurveyTheme, 0) - // Assert - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - let canRequest = self.experiencesPublisher.canRequestScreenEvent() - XCTAssertFalse(canRequest) // Should be false when triggering thank you message - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) + // Assert - `triggerThankYouMessageView` calls `markShowingThankYou()` synchronously + // before it hops to main, so the state is already set when the call returns. Waiting a + // second bought nothing and raced its own timeout; it also let the main-queue block's + // nil-host path reset the state back. + XCTAssertFalse( + experiencesPublisher.canRequestScreenEvent(), + "Showing the thank-you view must block further screen requests") } // MARK: - Preview Experience Tests @@ -886,27 +890,31 @@ final class ExperiencesPublisherTests: XCTestCase { expectation.expectedFulfillmentCount = 10 // Act + // Held locally: these closures can outlive `tearDown()`, and reading the IUO property + // after it is nilled traps the entire test host. + let publisher = experiencesPublisher! + for _ in 0..<10 { - DispatchQueue.global(qos: .background).async { + DispatchQueue.global(qos: .userInitiated).async { let mockPayload: [String: Any?] = MockContentFactory.makeFlowContentPayload() let message = Message(payload: ["payload": mockPayload]) - self.experiencesPublisher.onNewMessage(message) + publisher.onNewMessage(message) expectation.fulfill() } } - // Assert - wait(for: [expectation], timeout: 2.0) + // Assert - the bounds below are failure bounds, not expected durations. + wait(for: [expectation], timeout: 10.0) // Verify that we still have valid state after concurrent access let resultExpectation = XCTestExpectation(description: "Wait for pending content check") DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - let result = self.experiencesPublisher.getActiveMobileContent() + let result = publisher.getActiveMobileContent() // Should have some content (the last one to be processed) XCTAssertNotNil(result) resultExpectation.fulfill() } - wait(for: [resultExpectation], timeout: 1.0) + wait(for: [resultExpectation], timeout: 10.0) } // MARK: - Overlay Window Threading Tests @@ -923,11 +931,14 @@ final class ExperiencesPublisherTests: XCTestCase { // Act let loggedOut = XCTestExpectation(description: "logout ran to completion off the main thread") - performOn(.background) { + // Deliberately not `performOn(.background)`: that queue is pinned to QoS `.background` + // (DispatchQueue+Extensions.swift), which a loaded CI runner can starve for seconds. The + // regression under test is "arrives off the main thread", not "arrives on that queue". + DispatchQueue.global(qos: .userInitiated).async { self.experiencesPublisher.logout() loggedOut.fulfill() } - wait(for: [loggedOut], timeout: 2.0) + wait(for: [loggedOut], timeout: 10.0) // Assert — collapsing an idle overlay must never build one XCTAssertNil( @@ -946,11 +957,14 @@ final class ExperiencesPublisherTests: XCTestCase { // Act let loggedOut = XCTestExpectation(description: "logout ran to completion off the main thread") - performOn(.background) { + // Deliberately not `performOn(.background)`: that queue is pinned to QoS `.background` + // (DispatchQueue+Extensions.swift), which a loaded CI runner can starve for seconds. The + // regression under test is "arrives off the main thread", not "arrives on that queue". + DispatchQueue.global(qos: .userInitiated).async { self.experiencesPublisher.logout() loggedOut.fulfill() } - wait(for: [loggedOut], timeout: 2.0) + wait(for: [loggedOut], timeout: 10.0) // The hide is enqueued on the main queue before `logout()` returns, so this // barrier block runs strictly after it (main queue is FIFO). diff --git a/Tests/UserpilotTests/MockUserpilot.swift b/Tests/UserpilotTests/MockUserpilot.swift index 0b38e865..2917c7e4 100644 --- a/Tests/UserpilotTests/MockUserpilot.swift +++ b/Tests/UserpilotTests/MockUserpilot.swift @@ -57,12 +57,6 @@ class MockUserpilot: Userpilot { container.registerLazy(AutoCaptureCoordinating.self, initializer: AutoCaptureCoordinater.init) } - var onIdentify: ((String, Payload, Payload) -> Void)? - override func identify(userId: String, properties: Payload = nil, company: Payload = nil) { - onIdentify?(userId, properties, company) - super.identify(userId: userId, properties: properties, company: company) - } - var analyticsPublisher = MockAnalyticsPublisher() var storage = MockStorage() var networkMonitor = MockNetworkMonitor() @@ -437,6 +431,7 @@ class MockNetworkMonitor: NetworkMonitoring { func stopMonitoring() { onStopMonitoring?() } + var recheckIfOfflineCount = 0 func recheckIfOffline() { recheckIfOfflineCount += 1 diff --git a/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift b/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift index be8a887c..deae065e 100644 --- a/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift +++ b/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift @@ -144,16 +144,19 @@ final class MultiInstanceIntegrationTests: XCTestCase { // Act — drop the last strong reference off the main thread let released = XCTestExpectation(description: "instance deallocated off the main thread") - performOn(.background) { + // Not `performOn(.background)`: that queue is pinned to QoS `.background` + // (DispatchQueue+Extensions.swift) and starves on a loaded runner. Any non-main queue + // proves the same point. + DispatchQueue.global(qos: .userInitiated).async { instance = nil released.fulfill() } - wait(for: [released], timeout: 2.0) + wait(for: [released], timeout: 10.0) // `deinit` marshals the teardown, so drain the main queue behind it (FIFO). let mainQueueDrained = XCTestExpectation(description: "main queue processed the teardown") performOn(.main) { mainQueueDrained.fulfill() } - wait(for: [mainQueueDrained], timeout: 2.0) + wait(for: [mainQueueDrained], timeout: 10.0) // Assert — the teardown ran (on main; off main it would abort in UIKit) XCTAssertTrue(overlay.isHidden, "A destroyed instance must collapse its overlay") diff --git a/Tests/UserpilotTests/UserpilotTests.swift b/Tests/UserpilotTests/UserpilotTests.swift index da2a53ca..7130ca20 100644 --- a/Tests/UserpilotTests/UserpilotTests.swift +++ b/Tests/UserpilotTests/UserpilotTests.swift @@ -75,36 +75,19 @@ class UserpilotTests: XCTestCase { /// Verifies that user properties and company are passed correctly during identify func testIdentify_tracksUserPropertiesAndCompanyCorrectly() throws { - // Arrange - var trackedUserId: String? - var trackedProperties: Payload? - var trackedCompany: Payload? - - userpilot.onIdentify = { userId, properties, company in - trackedUserId = userId - trackedProperties = properties - trackedCompany = company - } + var published: Event? + userpilot.analyticsPublisher.onPublish = { published = $0 } - // Act userpilot.identify( userId: "default-00000", properties: ["email": "test@mail.com"], company: ["id": "1"] ) - // Assert - XCTAssertEqual(trackedUserId, "default-00000", "User Id should be 'default-00000'") - - guard let properties = trackedProperties ?? nil else { - return XCTFail("Properties should not be nil") - } - XCTAssertEqual(properties["email"] as? String, "test@mail.com") - - guard let company = trackedCompany ?? nil else { - return XCTFail("Company should not be nil") - } - XCTAssertEqual(company["id"] as? String, "1") + let event = try XCTUnwrap(published) + XCTAssertEqual(event.userId, "default-00000") + XCTAssertEqual(event.properties?["email"] as? String, "test@mail.com") + XCTAssertEqual(event.company?["id"] as? String, "1") } // MARK: - Anonymous From 537cc1d852ad01d5962eaeee1954fab642ae732f Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 25 Sep 2026 22:25:25 +0300 Subject: [PATCH 84/95] =?UTF-8?q?=F0=9F=92=A5=20refactor(api)!:=20Remove?= =?UTF-8?q?=20identify-before-screen=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore analytics publishing from 8731504 and remove the optional identify refresh setting, ACK tracking, and screen hooks. Keep the test improvements from d08cfe6 and adapt screen cases. BREAKING CHANGE: Remove enableRequestIdentifyBeforeScreen(_:). Integrations using this unreleased setter must drop the call. Current Flutter, React Native, and Capacitor wrappers need no changes. --- README.md | 1 - .../Analytics/AnalyticsPublisher.swift | 323 ++---------------- Sources/Userpilot/Userpilot+Config.swift | 28 -- .../Analytics/AnalyticsPublisherTests.swift | 321 +---------------- 4 files changed, 50 insertions(+), 623 deletions(-) diff --git a/README.md b/README.md index 1ed4f231..d0989f51 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,6 @@ userpilot = Userpilot( .logging(enabled: true) // Enable or disable logging. .enableUseInAppBrowser(true) // Enable Open external link In-app browser using SFSafariViewController. .disableRequestPushNotificationsPermission() // Disable SDK push permission request flow (default false). - .enableRequestIdentifyBeforeScreen() // Re-state the identified user before every screen event (default false). ) userpilot.navigationDelegate = self userpilot.analyticsDelegate = self diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index e16226c2..00011472 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -151,30 +151,6 @@ internal class AnalyticsPublisher { /// The user session state machine. private let userSessionStateMachine: UserSessionStateManaging - /// Guards identify insertion, predecessor, and parked reload state across processing queues. - private let publishCorrelationLock = NSLock() - - /// True while an identify inserted by `insertIdentifyBeforeScreenIfNeeded()` is still - /// working its way through the queue ahead of the screen that asked for it. - /// - /// A one-shot, not a counter: the identify goes through `eventsQueue` like any other event, - /// so its resolution already belongs to a queue head and needs no separate accounting. This - /// only stops the screen from inserting a second identify when it is reprocessed. - private var identifyBeforeScreenInserted = false - - /// True when the event this queue last resolved was an identify. - /// - /// A screen that already follows an acknowledged identify does not need the user restated - /// again - the identify ahead of it did exactly that one push ago. - private var lastResolvedQueuedEventWasIdentify = false - - /// A fake reload that yielded to an inserted identify, replayed once that identify resolves. - private struct PendingFakeReload { - let isFakeReload: Bool - } - - private var pendingFakeReload: PendingFakeReload? - // MARK: - Queues & State /// Internal SDK events cached while the socket is reconnecting. Responses are @@ -271,33 +247,25 @@ extension AnalyticsPublisher: AnalyticsPublishing { func flush() { tryCatch { let events = eventsQueue.getAndClear() - let pendingIdentifyIndex = events.firstIndex { - $0.isIdentifyEvent && $0.userId != storage.userId - } - if let pendingIdentifyIndex { - // Requeue the whole tail, not just the identify: those events are that user's - // session and `resume()` replays them once the socket is back. Keeping only the - // identify dropped everything queued behind it. - let pendingEvents = events[pendingIdentifyIndex...] - storage.temporaryUser = pendingEvents.first?.toUser().toJson() - pendingEvents.forEach { eventsQueue.enqueue($0) } + // In case of a new user, clear all queue and cache the identify new user event only + let newUserEvents = events.filter { $0.isIdentifyEvent && $0.userId != storage.userId } + if let firstNewUserEvent = newUserEvents.first { + storage.temporaryUser = firstNewUserEvent.toUser().toJson() + eventsQueue.enqueue(firstNewUserEvent) } else { events.forEach { event in switch event.type { case .identify: _ = identify(event) case .screen: - // No identify insertion here: `flush` has already drained the queue, so - // requeueing would put an identify back into a queue that is being torn - // down and drop this screen entirely. - _ = resolveScreenOutcome(event) + _ = screen(event) case .event, .autoCaptureEvent: _ = trackEvent(event) } } - closeSocket() - userSessionStateMachine.markUserBackFromBackground() } + closeSocket() + userSessionStateMachine.markUserBackFromBackground() resetProcessingEventStatus() } } @@ -323,9 +291,6 @@ extension AnalyticsPublisher: AnalyticsPublishing { } // Reset start session for next login user startSession = true - // The socket closes below, so an identify sent ahead of a screen will never resolve. Left - // outstanding, its stale claim would swallow the next user's first identify ack. - clearIdentifyBeforeScreenInsertion() // Reset content states experiencesPublisher?.logout() // Clear seen contents from screenSessionStateMachine @@ -356,7 +321,6 @@ extension AnalyticsPublisher: AnalyticsPublishing { func reset() { startSession = true eventThrottle.clear() - clearIdentifyBeforeScreenInsertion() } /** @@ -378,7 +342,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { * * Routing order: * 1. Drop while the app is not active. - * 2. Identify refresh policy (suppress / refresh once per screen / carries new data). + * 2. Ignore unchanged identifies and cache pending user updates. * 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. @@ -515,15 +479,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 classify - * - Returns: The facts the refresh policy needs to decide - */ - /** - * Drops identify events that carry nothing new, and caches the pending user for the rest. - * - * Re-stating an unchanged user is the job of `Config.enableRequestIdentifyBeforeScreen(_:)`, - * which re-sends the cached identify where it matters: immediately before a screen event. - * * - Parameter event: The identify event to handle * - Returns: true if the event should be ignored, false if processing should continue */ @@ -633,20 +588,11 @@ extension AnalyticsPublisher: AnalyticsPublishing { return } - switch publishQueuedEvent(event) { - case .published: - break - case .skipped: - // Nothing was pushed (throttled, invalid, or blocked): drop the head - // and continue, otherwise the gate would wait for an ACK that will - // never arrive. + // Nothing was pushed (throttled, invalid, or blocked): drop the head + // and continue, otherwise the gate would wait for an ACK that will + // never arrive. + if !publishQueuedEvent(event) { skipHeadEvent() - case .requeued: - // The head was pushed back behind an identify this cycle inserted ahead of - // it. The head is still valid work - restart the cycle so the identify goes - // out first and this event follows on its resolution. - resetProcessingEventStatus() - processEvent() } } } @@ -669,7 +615,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { } userSessionStateMachine.markNormal() - if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false), !isRestatingUserBeforeScreen { + if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) { releaseAfterEmptyQueue() } } @@ -688,25 +634,14 @@ extension AnalyticsPublisher: AnalyticsPublishing { } /// Publishes the queued event using the matching event-specific path. - /// What happened to the queue head this cycle. - private enum QueuedPublishOutcome { - /// A push went out; the head stays enqueued until its resolution. - case published - /// Nothing went out and nothing will - drop the head. - case skipped - /// Nothing went out, but the head is still wanted: an identify was inserted ahead of - /// it and must go first. Restart the cycle rather than dropping anything. - case requeued - } - - private func publishQueuedEvent(_ event: Event) -> QueuedPublishOutcome { + private func publishQueuedEvent(_ event: Event) -> Bool { switch event.type { case .identify: - return identify(event) ? .published : .skipped + return identify(event) case .screen: return screen(event) case .event, .autoCaptureEvent: - return trackEvent(event) ? .published : .skipped + return trackEvent(event) } } @@ -768,12 +703,8 @@ extension AnalyticsPublisher: AnalyticsPublishing { socketManager.publish(event.eventName, payload: identifyPayload(for: event)) - // Socket is connected with the same user id — request post-identify screen. - // Not for the restatement: the screen it precedes IS that event, and arming this would - // leave the state machine awaiting a screen that is already on its way. - if !isRestatingUserBeforeScreen { - userSessionStateMachine.markAwaitingInitialScreen() - } + // Socket is connected with the same user id — request post-identify screen + userSessionStateMachine.markAwaitingInitialScreen() return true } @@ -789,165 +720,33 @@ extension AnalyticsPublisher: AnalyticsPublishing { } /** - * Re-publishes the device push token for a host identify ACK or cached-user restatement. + * Re-publishes the device push token alongside an acknowledged identify. * * `PushNotificationMonitor.setPushToken` only publishes when the token value changes, so a * returning user whose token is unchanged would otherwise never re-pair token ↔ user on the - * backend. Host identifies sync after their ACK; restatements cache it at insertion. + * backend. Called once the identify is on the backend so it sees the user first. */ private func syncPushToken() { guard canRequestEvent else { return } pushNotificationMonitor?.resyncPushToken() } - /** - * Re-states the cached user immediately before a screen event, when - * `Config.enableRequestIdentifyBeforeScreen(_:)` is on. - * - * A screen event makes the backend evaluate content for the current surface, so the user is - * restated first. The identify is inserted at the FRONT of `eventsQueue` and then travels - * the ordinary path: it is published as the queue head, and the screen behind it follows on - * its resolution. Nothing is pushed around the queue, so every resolution maps to exactly - * one queue head and the ack needs no disambiguation. - * - * One-shot per screen: `identifyBeforeScreenInserted` stops the screen from inserting another - * identify when it comes back round as the head after this one resolves, which would loop - * forever. - * - * - Returns: true when an identify was inserted and the caller must yield to it. - */ - private func insertIdentifyBeforeScreenIfNeeded() -> Bool { - guard config.requestIdentifyBeforeScreen else { return false } - - // An identify was just acknowledged, so the user is already restated on the backend for - // the screen that follows. Repeating it here would be a duplicate. - publishCorrelationLock.lock() - let shouldInsert = !lastResolvedQueuedEventWasIdentify && !identifyBeforeScreenInserted - if shouldInsert { identifyBeforeScreenInserted = true } - publishCorrelationLock.unlock() - guard shouldInsert else { return false } - - let cachedUser = User.fromJson(storage.user) - guard cachedUser.userId.isNotEmpty else { - clearIdentifyBeforeScreenInsertion() - return false - } - - // A reload can enter outside processEvent. Hold its cycle while the token is cached, - // or resyncPushToken re-enters processing before the caller has parked that reload. - _ = isProcessingEvent.compareAndSet(expected: false, new: true) - // Ahead of the screen that triggered it. `isInternalEvent` inserts at index 0. - eventsQueue.enqueue( - Event( - type: .identify(cachedUser.userId), - properties: cachedUser.properties, - company: cachedUser.company.isEmpty ? nil : cachedUser.company - ), - isInternalEvent: true - ) - - // Re-pair token <-> user alongside the restatement. This is why the identify ack no - // longer syncs the token: doing it here keeps it deliberate and keeps the restatement's - // resolution free of the queue path's side effects. - syncPushToken() - return true - } - - /// True while the restatement inserted by ``insertIdentifyBeforeScreenIfNeeded()`` is the - /// event on the wire. - /// - /// The old out-of-band push deliberately skipped the queue path's identify side effects - - /// no `markAwaitingInitialScreen()`, no `storage.temporaryUser` write, no host broadcast - - /// because nothing about the user changed. Routing it through the queue would have - /// subscribed it to all of them, so it is recognised here instead. - /// - /// While the one-shot is claimed the queue head is that identify, then the screen it - /// precedes; a host identify arriving meanwhile lands behind them, never ahead. - private var isRestatingUserBeforeScreen: Bool { - publishCorrelationLock.lock() - defer { publishCorrelationLock.unlock() } - return identifyBeforeScreenInserted - } - - /// Replays the fake reload that was waiting on the identify which has now resolved. - /// - /// The one-shot stays claimed across the replay so it cannot insert a second identify; if - /// nothing goes out it is released here, or it would stay claimed forever and no later screen - /// could restate the user again. - private func replayParkedFakeReload(_ parked: PendingFakeReload) { - // Admission already updated seen content and consumed the throttle allowance. Keep the - // gate held and preserve the original reload flag rather than admitting the reload again. - if canRequestEvent, eventsQueue.isEmpty() { - scheduleProcessingWatchdog() - if publishScreenEvent(isFakeReload: parked.isFakeReload) { return } - } - clearIdentifyBeforeScreenInsertion() - resetProcessingEventStatus() - processEvent() - } - - /// Claims a parked fake reload, if one is waiting on an identify. - private func takePendingFakeReload() -> PendingFakeReload? { - publishCorrelationLock.lock() - defer { publishCorrelationLock.unlock() } - - let parked = pendingFakeReload - pendingFakeReload = nil - return parked - } - - /// Replays an admitted reload or releases the gate for the next queued event. - private func continueAfterResolution() { - if let parked = takePendingFakeReload() { - replayParkedFakeReload(parked) - } else { - resetProcessingEventStatus() - processEvent() - } - } - - /// Releases the one-shot so the next screen can restate the user again. - private func clearIdentifyBeforeScreenInsertion() { - publishCorrelationLock.lock() - defer { publishCorrelationLock.unlock() } - - identifyBeforeScreenInserted = false - lastResolvedQueuedEventWasIdentify = false - pendingFakeReload = nil - } - /** * Processes and sends screen view events. * * - Parameter event: The screen event to process * - Returns: true when a screen push went out */ - private func screen(_ event: Event) -> QueuedPublishOutcome { - // An identify must precede this screen, and it is now the queue head. This screen - // stays enqueued behind it and is republished when the identify resolves. - if insertIdentifyBeforeScreenIfNeeded() { - return .requeued - } - let outcome = resolveScreenOutcome(event) - // The screen this identify preceded is not going out (throttled, or not a screen the - // publisher will report). Release the one-shot, or no later screen could ever restate - // the user again. - if case .skipped = outcome { - clearIdentifyBeforeScreenInsertion() - } - return outcome - } - - private func resolveScreenOutcome(_ event: Event) -> QueuedPublishOutcome { + private func screen(_ event: Event) -> Bool { // Returns true if this is a new screen, which triggers screen event if setupScreenEvent(event) { - return publishScreenEvent(isFakeReload: false) ? .published : .skipped + return publishScreenEvent(isFakeReload: false) } // Not a new screen, check if valid to trigger screen event if experiencesPublisher?.canRequestScreenEvent() == true { - return publishScreenEvent(isFakeReload: false) ? .published : .skipped + return publishScreenEvent(isFakeReload: false) } - return .skipped + return false } /** @@ -1158,17 +957,8 @@ extension AnalyticsPublisher: SocketSubscription { func onSocketClosed() { tryCatch { resetProcessingEventStatus() - // A transport error keeps the queue, including an inserted identify. Keep its - // classification and parked reload for the retry; otherwise its next ACK would - // broadcast a host identify and erase the host's pending user update. - if socketManager.didCloseFromError { - publishCorrelationLock.lock() - lastResolvedQueuedEventWasIdentify = false - publishCorrelationLock.unlock() - return - } - // Intentional closes end the insertion's lifetime with the old session. - clearIdentifyBeforeScreenInsertion() + // Socket closed from error state, don't reopen, keep events for next open + if socketManager.didCloseFromError { return } // Background close: keep the queue as-is (a queued new-user identify is // picked up by resume() on foreground). Re-publishing here would hit the // app-inactive guard and silently drop the event. @@ -1199,39 +989,20 @@ extension AnalyticsPublisher: SocketSubscription { // through their own direct subscriptions guard eventName.isAnalyticsEvent() else { return } - // Remove the in-flight head - it stayed enqueued until this resolution. - // - // Only when the head is what actually resolved. A screen pushed around the queue (a - // fake reload, which is allowed out only while the queue is empty) owns no head, so an - // unconditional dequeue let its resolution consume whatever the app enqueued during - // the round trip - dropping an event that was never sent. Matching on the event name - // needs no per-push identity: the out-of-band pushes are screens, and a screen can - // only be queued behind them, never ahead. - let event = eventsQueue.getFirst()?.eventName == eventName - ? eventsQueue.dequeue() - : nil - - publishCorrelationLock.lock() - let wasRestatement = identifyBeforeScreenInserted && event?.isIdentifyEvent == true - // A failed intervening event also breaks adjacency. Only a successful queued - // identify can satisfy the next screen's requirement. - lastResolvedQueuedEventWasIdentify = eventSent && event?.isIdentifyEvent == true - publishCorrelationLock.unlock() + // Remove the in-flight head - it stayed enqueued until this resolution + let event = eventsQueue.dequeue() guard eventSent else { logger.error( "⚠️ Event not acknowledged (%{public}@), dropping and continuing queue", eventName) - continueAfterResolution() + resetProcessingEventStatus() + processEvent() return } - // The restatement carries nothing new about the user, so it must not run any of - // this: no cached-user write, no `temporaryUser` clear, no host broadcast, no token - // resync. That is what the out-of-band push it replaced deliberately skipped. // Update cached user object - if let event, !wasRestatement, - eventName == Constants.Event.identifyEvent && event.userId == storage.userId { + if let event, eventName == Constants.Event.identifyEvent && event.userId == storage.userId { var newUser = User.fromJson(storage.user) storage.user = newUser.updateUser(event: event).toJson() ?? "" logger.info("👤 USER %{public}@", storage.user) @@ -1248,7 +1019,7 @@ extension AnalyticsPublisher: SocketSubscription { } // Handle request screen event after user identify event - if !wasRestatement && userSessionStateMachine.isPostIdentificationContext(eventName) + if userSessionStateMachine.isPostIdentificationContext(eventName) && userSessionStateMachine.shouldRequestInitialScreenEvent( eventsQueue.isEmpty(), experiencesPublisher?.getCurrentScreen.isNotEmpty == true) { @@ -1263,7 +1034,8 @@ extension AnalyticsPublisher: SocketSubscription { } } else { // Continue processing the next event in the queue - continueAfterResolution() + resetProcessingEventStatus() + processEvent() } } } @@ -1445,19 +1217,6 @@ extension AnalyticsPublisher { screenTitle: screenSessionStateMachine.event.screenTitle ?? "") { return } - - // A fake reload is not a queue entry, so it cannot simply wait its turn behind the - // identify. Park it, let the identify go out as the queue head, and replay it from - // the identify's resolution so the order still holds. - if insertIdentifyBeforeScreenIfNeeded() { - publishCorrelationLock.lock() - pendingFakeReload = PendingFakeReload(isFakeReload: isFakeReload) - publishCorrelationLock.unlock() - resetProcessingEventStatus() - processEvent() - return - } - published = publishScreenEvent(isFakeReload: isFakeReload) } return published @@ -1490,14 +1249,7 @@ extension AnalyticsPublisher { * Publishes the current screen session state as a screen event, carrying * session-start state, fake-reload flag, and seen experiences/surveys. * - * This is the single place a `screen` message is published, and the single place the cached - * internal SDK events are flushed ahead of it. - * - * The preceding identify is NOT pushed from here. It is inserted at the front of - * `eventsQueue` by ``screen(_:)`` for client screens, and by - * ``publishFakeReloadScreenEvent(_:_:isFakeReload:)`` for SDK fake reloads, so it always - * travels the ordinary ack-gated path ahead of the screen it precedes. - * + * Cached internal SDK events are flushed before the screen message. * - Returns: true when a screen push went out */ @discardableResult @@ -1551,9 +1303,6 @@ extension AnalyticsPublisher { existingMetadata.merging(newMetadata) { _, new in new } socketManager.publish(screenEvent.eventName, payload: payload) - // The screen this identify preceded is now on the wire: let the next screen - // restate the user again. - clearIdentifyBeforeScreenInsertion() if isFakeReload { suppressScreenAutocaptureAfterFakeReload() diff --git a/Sources/Userpilot/Userpilot+Config.swift b/Sources/Userpilot/Userpilot+Config.swift index 16f1f068..b4eb77c3 100644 --- a/Sources/Userpilot/Userpilot+Config.swift +++ b/Sources/Userpilot/Userpilot+Config.swift @@ -58,10 +58,6 @@ extension Userpilot { /// Open external link In-app browser using SFSafariViewController var useInAppBrowser: Bool = false - /// Re-states the identified user on the socket immediately before every screen event. - /// See `enableRequestIdentifyBeforeScreen(_:)`. - var requestIdentifyBeforeScreen: Bool = false - // MARK: - Autocapture Configuration Options /// Whether or not to enable screen autocapture. Defaults to false. @@ -240,30 +236,6 @@ extension Userpilot { return self } - /// Re-states the identified user on the socket immediately before every screen event. - /// - /// A screen event is what makes the backend evaluate content for the current surface. With - /// this enabled the SDK re-sends the cached user's `identify` first, so that evaluation - /// always runs against freshly stated user properties — on app-tracked screens and on the - /// SDK's own `fake_reload` screens alike (experience dismissal, preview dismissal, return - /// from background). - /// - /// The screen event the SDK sends right after an `identify` is the one exception: that - /// identify has just been acknowledged, so repeating it would be a duplicate. - /// - /// Doubles the number of identify messages the SDK sends, so it defaults to `false` and - /// existing integrations are unchanged unless the host opts in. No effect until a user is - /// identified. - /// - /// - Parameter enabled: A boolean indicating whether to re-state the user before screens. - /// - Returns: The `Configuration` object, allowing for method chaining. - @discardableResult - @objc - public func enableRequestIdentifyBeforeScreen(_ enabled: Bool = true) -> Self { - requestIdentifyBeforeScreen = enabled - return self - } - // MARK: - Autocapture Configuration Options /// Enables or disables automatic screen capture. diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 20eb6317..679148fa 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -374,32 +374,6 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) } - /// A fake reload owns no queue entry. Its ACK must release a waiting track without - /// removing it before the track's own ACK arrives. - func testTrackEnqueuedDuringAFakeReloadRoundTrip_isNotConsumedByTheScreenAck() { - userpilot.experiencesPublisher.onCanRequestScreenEvent = { true } - arrangeReloadableScreen(title: "Home") - var published = recordPublishedEvents() - - XCTAssertTrue(analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true)) - XCTAssertEqual(published(), [Constants.Event.screenEvent]) - published = recordPublishedEvents() - - // Hold the track in the queue until the screen ACK resumes processing. - userpilot.socketManager.isJoiningSocket = true - analyticsPublisher.publish(Event(type: .event("purchase_completed"))) - XCTAssertTrue(published().isEmpty) - XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.eventTitle, "purchase_completed") - userpilot.socketManager.isJoiningSocket = false - - analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) - - XCTAssertEqual(published(), [Constants.Event.trackEvent]) - XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().count, 1) - analyticsPublisher.onSocketEventSent(Constants.Event.trackEvent, nil, Message(), true) - XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) - } - // MARK: - Experience Events Tests func testCanRequestEvent_shouldReturnSocketState() { @@ -962,223 +936,6 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertFalse(connectCalled) } - // MARK: - identify-before-screen side effects - - /// The restatement carries nothing new about the user, so its ack must not run the identify - /// bookkeeping. `syncPushToken` used to fire from there - once per screen, invisibly. It is - /// now published deliberately alongside the restatement instead. - func testRestatement_resyncsTheTokenOnce_andNotAgainFromItsAck() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - - var resyncCount = 0 - userpilot.pushNotificationMonitor.onResyncPushToken = { resyncCount += 1 } - - analyticsPublisher.publish(Event(type: .screen("Another Screen"))) - XCTAssertEqual(resyncCount, 1, "the token is re-paired alongside the restatement") - - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - XCTAssertEqual( - resyncCount, 1, - "the restatement's ack must not re-sync the token a second time") - } - - /// A screen that already follows an acknowledged identify does not need the user restated - /// again - the identify one push ago did exactly that. - func testScreenFollowingAnIdentify_doesNotInsertASecondIdentify() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - - let published = recordPublishedEvents() - // Use changed properties so the host identify is actually sent, then queue its screen. - analyticsPublisher.publish(identifyEventForCurrentUser()) - analyticsPublisher.publish(Event(type: .screen("Another Screen"))) - XCTAssertEqual(published(), [Constants.Event.identifyEvent]) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) - XCTAssertEqual(User.fromJson(userpilot.storage.user).properties["plan"] as? String, "enterprise") - } - - /// `flush` drains the queue itself, so a screen it replays must go out rather than be pushed - /// back into a queue that is being torn down. - func testFlush_withTheFlagOn_stillPublishesAQueuedScreen() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - // Keep the screen unsent behind a track event until flush drains the queue. - analyticsPublisher.publish(Event(type: .event("before-background"))) - analyticsPublisher.publish(Event(type: .screen("Checkout"))) - let published = recordPublishedEvents() - - analyticsPublisher.flush() - - XCTAssertEqual(published(), [Constants.Event.trackEvent, Constants.Event.screenEvent]) - XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) - } - - private func identifyEventForCurrentUser() -> Event { - Event(type: .identify(userpilot.storage.userId), properties: ["plan": "enterprise"]) - } - - func testRestatement_preservesPendingHostIdentifyAndDoesNotBroadcast() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - let delegate = IdentifyDelegate() - userpilot.analyticsDelegate = delegate - let cachedUser = userpilot.storage.user - let state = userpilot.container.resolve(UserSessionStateManaging.self) - - analyticsPublisher.publish(Event(type: .screen("Checkout"))) - XCTAssertTrue(state.isNormal(), "restating a user must not arm the initial-screen state") - analyticsPublisher.publish(identifyEventForCurrentUser()) - let pendingUser = userpilot.storage.temporaryUser - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(userpilot.storage.user, cachedUser) - XCTAssertEqual(userpilot.storage.temporaryUser, pendingUser) - XCTAssertTrue(delegate.identifies.isEmpty) - - analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - let delivered = expectation(description: "analytics delegate callbacks delivered") - DispatchQueue.main.async { delivered.fulfill() } - wait(for: [delivered], timeout: 2) - XCTAssertEqual(delegate.identifies, ["reload-user"]) - XCTAssertNil(userpilot.storage.temporaryUser) - XCTAssertEqual(User.fromJson(userpilot.storage.user).properties["plan"] as? String, "enterprise") - } - - func testRestatement_cachesPushTokenUntilTheProcessingCycleContinues() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - // Use the real token monitor and route it back into this publisher. - userpilot.container.register(AnalyticsPublishing.self, value: analyticsPublisher) - let monitor = PushNotificationMonitor(container: userpilot.container) - userpilot.storage.pushToken = "test-token" - let published = recordPublishedEvents() - userpilot.pushNotificationMonitor.onResyncPushToken = { - monitor.resyncPushToken() - XCTAssertTrue(published().isEmpty, "the token must wait in the SDK cache while the gate is held") - } - - analyticsPublisher.publish(Event(type: .screen("Checkout"))) - - XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent]) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent, Constants.Event.screenEvent]) - } - - func testScreenAfterFailedTrack_insertsIdentifyEvenIfAnEarlierIdentifySucceeded() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - analyticsPublisher.publish(identifyEventForCurrentUser()) - analyticsPublisher.publish(Event(type: .event("between-identify-and-screen"))) - analyticsPublisher.publish(Event(type: .screen("Checkout"))) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - let published = recordPublishedEvents() - - analyticsPublisher.onSocketEventSent(Constants.Event.trackEvent, nil, Message(), false) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent]) - } - - func testRestatement_forBackgroundReload_preservesTheReloadFlag() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - var screenMetadata: [String: Any]? - userpilot.socketManager.onPublish = { name, payload in - if name == Constants.Event.screenEvent { - screenMetadata = payload?[Constants.Analytics.metaDataProperty] as? [String: Any] - } - } - analyticsPublisher.publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(screenMetadata?[Constants.Analytics.fakeReload] as? Bool, false) - } - - func testRestatement_whenIdentifyFails_stillReplaysTheParkedReload() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - let published = recordPublishedEvents() - analyticsPublisher.publishFakeReloadScreenEvent(nil, nil, isFakeReload: true) - - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), false) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) - } - - func testRestatement_forReloadWithPushToken_sendsOnlyOneIdentify() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - userpilot.container.register(AnalyticsPublishing.self, value: analyticsPublisher) - let monitor = PushNotificationMonitor(container: userpilot.container) - userpilot.storage.pushToken = "test-token" - userpilot.pushNotificationMonitor.onResyncPushToken = { monitor.resyncPushToken() } - let published = recordPublishedEvents() - - analyticsPublisher.publishFakeReloadScreenEvent(nil, nil, isFakeReload: true) - XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent]) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - XCTAssertEqual(published(), ["user_token", Constants.Event.identifyEvent, Constants.Event.screenEvent]) - } - - func testUserSwitch_queuedBehindRestatement_reconnectsAsTheNewUser() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - var closes = 0 - userpilot.socketManager.onClose = { closes += 1 } - - analyticsPublisher.publish(Event(type: .screen("Old user screen"))) - analyticsPublisher.publish(Event(type: .identify("next-user"))) - analyticsPublisher.publish(Event(type: .screen("New user screen"))) - let pendingUser = userpilot.storage.temporaryUser - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - XCTAssertEqual(closes, 0) - XCTAssertEqual(userpilot.storage.temporaryUser, pendingUser) - - analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) - XCTAssertEqual(closes, 1) - XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.userId, "next-user") - userpilot.socketManager.isSocketOpened = false - analyticsPublisher.onSocketClosed() - XCTAssertEqual(userpilot.storage.userId, "next-user") - userpilot.socketManager.isSocketOpened = true - analyticsPublisher.onSocketOpened() - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(User.fromJson(userpilot.storage.user).userId, "next-user") - XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "New user screen") - } - - func testRestatement_afterReconnect_staysInternalAndPreservesPendingHostIdentify() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - analyticsPublisher.publish(Event(type: .screen("Checkout"))) - analyticsPublisher.publish(identifyEventForCurrentUser()) - let pendingUser = userpilot.storage.temporaryUser - userpilot.socketManager.didCloseFromError = true - userpilot.socketManager.isSocketOpened = false - analyticsPublisher.onSocketClosed() - userpilot.socketManager.didCloseFromError = false - userpilot.socketManager.isSocketOpened = true - analyticsPublisher.onSocketOpened() - - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(userpilot.storage.temporaryUser, pendingUser) - XCTAssertTrue(userpilot.container.resolve(UserSessionStateManaging.self).isNormal()) - XCTAssertEqual(analyticsPublisher.screenSessionStateMachine?.event.screenTitle, "Checkout") - } - - private final class IdentifyDelegate: NSObject, UserpilotAnalyticsDelegate { - var identifies: [String] = [] - - func didTrack(analytic: UserpilotAnalytic, value: String, properties: [String: Any]?) { - if analytic == .identify { identifies.append(value) } - } - } - // MARK: - Autocapture screen guard (Android parity) /// An autocapture event with no screen is meaningless, so it must be neither sent nor STORED. @@ -1437,7 +1194,7 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(metadata[Constants.Analytics.fakeReload] as? Bool, false) } - // MARK: - Identify before screen + // MARK: - Screen reloads and host identify /// Establishes a cached user and a live screen session, then waits out the screen throttle so /// the reload under test is not swallowed by it. @@ -1456,11 +1213,6 @@ class AnalyticsPublisherTests: XCTestCase { ).toJson() ?? "" analyticsPublisher.publish(Event(type: .screen(title))) - // With the flag on the identify is inserted ahead of the screen and is the first - // thing on the wire, so the screen only goes out once that identify is resolved. - if userpilot.config.requestIdentifyBeforeScreen { - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - } analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) let throttleWindow = XCTestExpectation(description: "screen throttle window elapsed") @@ -1484,8 +1236,7 @@ class AnalyticsPublisherTests: XCTestCase { } } - func testReloadSendsTheScreenEventAlone_whenTheFlagIsOff() { - // The default configuration is unchanged by this feature. + func testReloadSendsTheScreenEventAlone() { arrangeReloadableScreen() let published = recordPublishedEvents() @@ -1494,53 +1245,25 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(published(), [Constants.Event.screenEvent]) } - func testReloadPublishesTheIdentifyFirst_whenTheFlagIsOn() { - userpilot.config.requestIdentifyBeforeScreen = true + func testAppScreenEvent_publishesWithoutIdentify() { arrangeReloadableScreen() - let published = recordPublishedEvents() - - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) - - // The identify travels the queue like any other event, so the reload it precedes does - // not go out until that identify resolves. - XCTAssertEqual(published(), [Constants.Event.identifyEvent]) - - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) - } - - func testAppScreenEventIsPrecededByTheIdentify_whenTheFlagIsOn() { - // The flag covers app-tracked screens too, not just the SDK's fake reloads. - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - let published = recordPublishedEvents() - - analyticsPublisher.publish(Event(type: .screen("Another Screen"))) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent]) - - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) - } - - func testScreenIdentifyCarriesTheCachedUser_whenTheFlagIsOn() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - var identifyPayload: [String: Any]? + var published: [String] = [] + var screenTitle: String? userpilot.socketManager.onPublish = { name, payload in - if name == Constants.Event.identifyEvent { identifyPayload = payload } + published.append(name) + screenTitle = payload?[Constants.Analytics.screenTitleProperty] as? String } analyticsPublisher.publish(Event(type: .screen("Another Screen"))) - let metadata = identifyPayload?[Constants.Analytics.metaDataProperty] as? [String: String] - XCTAssertEqual(metadata, ["plan": "pro"]) + XCTAssertEqual(published, [Constants.Event.screenEvent]) + XCTAssertEqual(screenTitle, "Another Screen") + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.screenTitle, "Another Screen") + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) } - func testReloadSendsNothingExtra_whenNoUserIsIdentified() { - userpilot.config.requestIdentifyBeforeScreen = true + func testReloadSendsOnlyScreen_whenCachedUserIsEmpty() { arrangeReloadableScreen() userpilot.storage.user = "" let published = recordPublishedEvents() @@ -1550,24 +1273,9 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(published(), [Constants.Event.screenEvent]) } - func testIdentifyBeforeScreenAckDoesNotRequestASecondScreenEvent() { - userpilot.config.requestIdentifyBeforeScreen = true - arrangeReloadableScreen() - let published = recordPublishedEvents() - analyticsPublisher.publish(Event(type: .screen("Another Screen"))) - - // The identify owns a queue head now, so its ack releases exactly one screen - it can - // no longer satisfy isPostIdentificationContext and duplicate the screen event. - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) - - XCTAssertEqual(published(), [Constants.Event.identifyEvent, Constants.Event.screenEvent]) - } - - func testReloadSendsNeitherEvent_forAThrottledScreen() { - userpilot.config.requestIdentifyBeforeScreen = true + func testReloadSendsNothing_forAThrottledScreen() { arrangeReloadableScreen() analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) - analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) let published = recordPublishedEvents() @@ -1579,7 +1287,6 @@ class AnalyticsPublisherTests: XCTestCase { } func testDuplicateIdentifyIsDropped() { - // Re-stating an unchanged user is the flag's job, not the identify path's. arrangeReloadableScreen() let published = recordPublishedEvents() From 8715e94186952accef5e02364ff5a43cb85a67d5 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sat, 26 Sep 2026 10:46:25 +0300 Subject: [PATCH 85/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Queue=20?= =?UTF-8?q?generated=20screens=20until=20acknowledged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give generated screens their own entries in the analytics queue so their replies cannot remove a following screen or track event. Preserve reload metadata and verify delivery order and callbacks. --- .../Analytics/AnalyticsPublisher.swift | 65 +++++++------ .../Userpilot/Analytics/Models/Event.swift | 3 + .../Analytics/AnalyticsPublisherTests.swift | 91 +++++++++++++++++++ 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 00011472..2d890c1a 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -44,7 +44,7 @@ internal protocol AnalyticsPublishing: AnyObject { func publishInternalSDKEvent(_ sdkEvent: SDKEvent) /// publish fake reload event - /// - Returns: true when a screen push actually went out + /// - Returns: true when a screen refresh was accepted into the queue @discardableResult func publishFakeReloadScreenEvent( _ experienceType: ExperienceType?, @@ -615,9 +615,10 @@ extension AnalyticsPublisher: AnalyticsPublishing { } userSessionStateMachine.markNormal() - if !publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) { - releaseAfterEmptyQueue() - } + // Admission happens while this cycle owns the gate; the next cycle sends it. + publishFakeReloadScreenEvent(nil, nil, isFakeReload: false) + resetProcessingEventStatus() + processEvent() } /// Releases the gate, then re-drives processing if an internal SDK event reached the cache @@ -738,8 +739,13 @@ extension AnalyticsPublisher: AnalyticsPublishing { * - Returns: true when a screen push went out */ private func screen(_ event: Event) -> Bool { + let isNewScreen = setupScreenEvent(event) + // Generated refreshes have already passed admission and must retain their ACK slot. + if let isFakeReload = event.isFakeReload { + return publishScreenEvent(isFakeReload: isFakeReload) + } // Returns true if this is a new screen, which triggers screen event - if setupScreenEvent(event) { + if isNewScreen { return publishScreenEvent(isFakeReload: false) } // Not a new screen, check if valid to trigger screen event @@ -1023,20 +1029,11 @@ extension AnalyticsPublisher: SocketSubscription { && userSessionStateMachine.shouldRequestInitialScreenEvent( eventsQueue.isEmpty(), experiencesPublisher?.getCurrentScreen.isNotEmpty == true) { - // Keep the gate claimed for the post-identify screen push, with a - // fresh watchdog window for the new in-flight push - scheduleProcessingWatchdog() - let published = publishScreenEvent( + enqueueScreenRefresh( isFakeReload: userSessionStateMachine.getPostIdentificationFakeReloadConfig()) - if !published { - resetProcessingEventStatus() - processEvent() - } - } else { - // Continue processing the next event in the queue - resetProcessingEventStatus() - processEvent() } + resetProcessingEventStatus() + processEvent() } } @@ -1188,8 +1185,8 @@ extension AnalyticsPublisher { * Publishes a fake reload screen event when an experience is shown/closed. * This ensures proper state tracking for experiences. * - * The throttle is checked before the screen push, so a throttled reload sends neither the - * screen message nor the identify that may precede it. + * The throttle is checked before enqueueing. Accepted refreshes wait for their own ACK + * before another analytics event can be sent. * * - Parameter experienceType: The type of experience (FLOW or SURVEY) * - Parameter experienceId: The ID of the experience being shown @@ -1201,7 +1198,7 @@ extension AnalyticsPublisher { _ experienceId: Int?, isFakeReload: Bool ) -> Bool { - var published = false + var enqueued = false tryCatch { // Never bypass queue ordering: a fake reload only goes out when no // live analytics event is queued or in flight @@ -1217,9 +1214,11 @@ extension AnalyticsPublisher { screenTitle: screenSessionStateMachine.event.screenTitle ?? "") { return } - published = publishScreenEvent(isFakeReload: isFakeReload) + enqueueScreenRefresh(isFakeReload: isFakeReload) + enqueued = true + processEvent() } - return published + return enqueued } /** @@ -1245,6 +1244,14 @@ extension AnalyticsPublisher { extension AnalyticsPublisher { + /// Gives generated screens the same queue ownership as app screen events. + private func enqueueScreenRefresh(isFakeReload: Bool) { + ensureScreenSessionStateMachine() + guard var event = screenSessionStateMachine?.event else { return } + event.isFakeReload = isFakeReload + eventsQueue.enqueue(event) + } + /** * Publishes the current screen session state as a screen event, carrying * session-start state, fake-reload flag, and seen experiences/surveys. @@ -1259,17 +1266,9 @@ extension AnalyticsPublisher { ensureScreenSessionStateMachine() guard let screenSessionStateMachine else { return false } - // Cached internal SDK events go out first. A `screen` message makes the backend - // re-evaluate content for this surface, so a step-seen/completed/dismissed event still - // sitting in the cache would be judged against a screen the backend already answered — a - // dismissal arriving after the fake reload it triggered gets the dismissed content served - // straight back. `processEvent` drains ahead of the event queue, but its single-flight gate - // makes that drain a no-op while another cycle holds it — an event awaiting its ack (the - // post-identify screen push in `onSocketEventSent` runs inside exactly that claimed cycle) - // or an asynchronous offline restore — and the screen still goes out. A persisted offline - // batch keeps its head start: it is published from - // `restoreOfflineEventsIfNeeded()` on the processing cycle that a socket open always runs, - // before any screen message can be pushed from here. + // A screen request re-evaluates content, so pending seen/completed/dismissed events + // must reach the backend first. Queue processing already drains them; flush can also + // call this method directly and needs the same ordering. processSDKEvent() let screenEvent = screenSessionStateMachine.event diff --git a/Sources/Userpilot/Analytics/Models/Event.swift b/Sources/Userpilot/Analytics/Models/Event.swift index 893c9095..6a84025f 100644 --- a/Sources/Userpilot/Analytics/Models/Event.swift +++ b/Sources/Userpilot/Analytics/Models/Event.swift @@ -28,6 +28,9 @@ internal struct Event { /// Auto-capture interaction category sent as `InteractionEventName`. var interactionEventName: String? + /// Queue-only reload metadata for SDK-generated screens; nil for app screen events. + var isFakeReload: Bool? + // MARK: - EventType helpers var isIdentifyEvent: Bool { diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 679148fa..1d291211 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -1245,6 +1245,97 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(published(), [Constants.Event.screenEvent]) } + func testReloadACK_keepsTheNextScreenQueuedUntilItsOwnACK() { + arrangeReloadableScreen(title: "Home") + var titles: [String] = [] + userpilot.socketManager.onPublish = { name, payload in + XCTAssertEqual(name, Constants.Event.screenEvent) + titles.append(payload?[Constants.Analytics.screenTitleProperty] as? String ?? "") + } + + XCTAssertTrue(analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true)) + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + + XCTAssertEqual(titles, ["Home"], "Navigation must wait for the reload ACK") + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().map(\.screenTitle), ["Home", "Checkout"]) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertEqual(titles, ["Home", "Checkout"]) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().map(\.screenTitle), ["Checkout"]) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + } + + func testPostIdentifyScreenACK_doesNotDiscardTheFollowingTrackEvent() { + userpilot.storage.userId = "user-1" + userpilot.socketManager.isSocketOpened = true + userpilot.experiencesPublisher.updateScreen("Home") + var sent: [(String, Payload)] = [] + userpilot.socketManager.onPublish = { sent.append(($0, $1)) } + analyticsPublisher.publish(Event(type: .identify("user-1"), properties: ["plan": "pro"])) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(sent.map { $0.0 }, [Constants.Event.identifyEvent, Constants.Event.screenEvent]) + + analyticsPublisher.publish(Event(type: .event("Purchase"), properties: ["amount": 42])) + XCTAssertEqual(sent.count, 2) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + + XCTAssertEqual(sent.map { $0.0 }, [ + Constants.Event.identifyEvent, Constants.Event.screenEvent, Constants.Event.trackEvent + ]) + XCTAssertEqual(sent.last?.1?[Constants.Analytics.eventNameProperty] as? String, "Purchase") + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.eventTitle, "Purchase") + analyticsPublisher.onSocketEventSent(Constants.Event.trackEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + } + + func testBackgroundScreenRefresh_holdsTheQueueUntilItResolves() { + arrangeReloadableScreen(title: "Home") + userpilot.sessionMonitor.isAppActive = false + analyticsPublisher.flush() + userpilot.sessionMonitor.isAppActive = true + var sent: [(String, Payload)] = [] + userpilot.socketManager.onPublish = { sent.append(($0, $1)) } + + analyticsPublisher.onSocketOpened() + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent.first?.1?[Constants.Analytics.screenTitleProperty] as? String, "Home") + let metadata = sent.first?.1?[Constants.Analytics.metaDataProperty] as? [String: Any] + XCTAssertEqual(metadata?[Constants.Analytics.fakeReload] as? Bool, false) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().map(\.screenTitle), ["Home", "Checkout"]) + + // A failed/timeout resolution must also release only the refresh's queue entry. + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), false) + XCTAssertEqual(sent.count, 2) + XCTAssertEqual(sent.last?.1?[Constants.Analytics.screenTitleProperty] as? String, "Checkout") + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().map(\.screenTitle), ["Checkout"]) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + } + + func testReload_waitsForOfflineReplayBeforeSendingItsQueuedScreen() { + arrangeReloadableScreen(title: "Home") + userpilot.offlineEventsHandler.hasCachedEvents = true + userpilot.offlineEventsHandler.holdRestoreCompletion = true + let sent = recordPublishedEvents() + + let accepted = analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + + XCTAssertTrue(accepted, "The refresh waits in the same queue during offline replay") + XCTAssertEqual(sent(), []) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().map(\.screenTitle), ["Home"]) + analyticsPublisher.publish(Event(type: .event("Purchase"))) + userpilot.offlineEventsHandler.finishRestore() + XCTAssertEqual(sent(), [Constants.Event.screenEvent]) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().count, 2) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertEqual(sent(), [Constants.Event.screenEvent, Constants.Event.trackEvent]) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.eventTitle, "Purchase") + analyticsPublisher.onSocketEventSent(Constants.Event.trackEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + } + func testAppScreenEvent_publishesWithoutIdentify() { arrangeReloadableScreen() var published: [String] = [] From 64d574ab87ec4327343a6a0080ba5d9c9bed4c0a Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sat, 26 Sep 2026 10:46:42 +0300 Subject: [PATCH 86/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Preserve?= =?UTF-8?q?=20identity=20across=20background=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain only the latest identify when backgrounding during a user switch. Preserve it through socket closure and mark the switch before resume adopts its user ID. Verify metadata and first-screen session flags. --- .../Analytics/AnalyticsPublisher.swift | 20 ++++--- .../Analytics/AnalyticsPublisherTests.swift | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 2d890c1a..240d3a00 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -242,16 +242,16 @@ extension AnalyticsPublisher: AnalyticsPublishing { /** * Flushes all pending events when the app enters background. - * This ensures no events are lost when the app is backgrounded. + * A pending user switch keeps only the latest identify for the next connection. */ func flush() { tryCatch { let events = eventsQueue.getAndClear() - // In case of a new user, clear all queue and cache the identify new user event only - let newUserEvents = events.filter { $0.isIdentifyEvent && $0.userId != storage.userId } - if let firstNewUserEvent = newUserEvents.first { - storage.temporaryUser = firstNewUserEvent.toUser().toJson() - eventsQueue.enqueue(firstNewUserEvent) + // A user switch discards the queue and caches only the latest requested identity. + if let latestIdentify = events.last(where: { $0.isIdentifyEvent }), + events.contains(where: { $0.isIdentifyEvent && $0.userId != storage.userId }) { + storage.temporaryUser = latestIdentify.toUser().toJson() + eventsQueue.enqueue(latestIdentify) } else { events.forEach { event in switch event.type { @@ -310,7 +310,13 @@ extension AnalyticsPublisher: AnalyticsPublishing { */ func resume() { updateSessionState() - if let userId = getUserIdFromQueue() { storage.userId = userId } + if let userId = getUserIdFromQueue() { + // Preserve the switch before the socket adopts the queued user's channel. + if storage.userId.isNotEmpty, userId != storage.userId { + userSessionStateMachine.markUserSwitch() + } + storage.userId = userId + } // connect() gates itself on the socket state - always safe to call if storage.userId.isNotEmpty { openSocket() } } diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 1d291211..d5d8eb93 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -204,6 +204,64 @@ class AnalyticsPublisherTests: XCTestCase { // MARK: - Resume Tests + func testFlush_keepsOnlyTheLatestIdentifyWhenSwitchingUsers() { + assertFlushKeepsLatestIdentify(latestUserId: "newest-user") + } + + func testFlush_keepsTheLatestIdentifyWhenSwitchingBackToTheOriginalUser() { + assertFlushKeepsLatestIdentify(latestUserId: "old-user") + } + + private func assertFlushKeepsLatestIdentify(latestUserId: String) { + arrangeReloadableScreen(title: "Home", userId: "old-user") + userpilot.storage.sessionDate = Date() + var closeCalled = false + userpilot.socketManager.onClose = { closeCalled = true } + userpilot.socketManager.isJoiningSocket = true + analyticsPublisher.publish(Event(type: .event("Before switch"))) + analyticsPublisher.publish(Event(type: .identify("intermediate-user"), properties: ["plan": "basic"])) + analyticsPublisher.publish(Event(type: .screen("Checkout"))) + analyticsPublisher.publish(Event(type: .identify(latestUserId), properties: ["plan": "pro"])) + analyticsPublisher.publish(Event( + type: .identify(latestUserId), properties: ["plan": "enterprise"], company: ["id": "latest-company"])) + analyticsPublisher.publish(Event(type: .event("Purchase"), properties: ["amount": 42])) + userpilot.sessionMonitor.isAppActive = false + + analyticsPublisher.flush() + + XCTAssertTrue(closeCalled) + analyticsPublisher.onSocketClosed() + let queued = analyticsPublisher.mockGetEventsToFlush() + XCTAssertEqual(queued.count, 1) + XCTAssertEqual(queued.first?.userId, latestUserId) + XCTAssertEqual(queued.first?.properties?["plan"] as? String, "enterprise") + let cached = User.fromJson(userpilot.storage.temporaryUser.orEmpty()) + XCTAssertEqual(cached.userId, latestUserId) + XCTAssertEqual(cached.properties["plan"] as? String, "enterprise") + XCTAssertEqual(cached.company["id"] as? String, "latest-company") + + var sent: [(String, Payload)] = [] + userpilot.socketManager.onPublish = { sent.append(($0, $1)) } + userpilot.socketManager.isJoiningSocket = false + userpilot.socketManager.isSocketOpened = true + userpilot.sessionMonitor.isAppActive = true + analyticsPublisher.resume() + analyticsPublisher.onSocketOpened() + XCTAssertEqual(userpilot.storage.userId, latestUserId) + XCTAssertEqual(sent.map { $0.0 }, [Constants.Event.identifyEvent]) + let metadata = sent.first?.1?[Constants.Analytics.metaDataProperty] as? [String: Any] + XCTAssertEqual(metadata?["plan"] as? String, "enterprise") + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + XCTAssertEqual(sent.map { $0.0 }, [Constants.Event.identifyEvent, Constants.Event.screenEvent]) + XCTAssertEqual(sent.last?.1?[Constants.Analytics.screenTitleProperty] as? String, "Home") + let screenMetadata = sent.last?.1?[Constants.Analytics.metaDataProperty] as? [String: Any] + XCTAssertEqual(screenMetadata?[Constants.Analytics.isSessionStartedProperty] as? Bool, latestUserId != "old-user") + XCTAssertEqual(screenMetadata?[Constants.Analytics.fakeReload] as? Bool, latestUserId == "old-user") + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + XCTAssertEqual(sent.count, 2) + } + func testResume_shouldConnectSocketWhenUserIdExists() { // Arrange userpilot.storage.userId = "test-user" From c797da679dcfb90248f91839c7d129ea9069623b Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 10:35:51 +0300 Subject: [PATCH 87/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Experiences):=20Settle?= =?UTF-8?q?=20preview=20sessions=20after=20dismissal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the session that reached the renderer and settle it when UIKit dismissal completes. Prevent an outgoing experience from cancelling a newer QR preview and cover replacement and final cleanup. The change is internal to experience lifecycle handling. --- .../Experiences/ExperiencesPublisher.swift | 25 +++- .../Experiences/PreviewSessionTracker.swift | 18 +++ .../ExperiencesPublisherTests.swift | 124 ++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 467b3bf6..1e079c32 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -338,7 +338,23 @@ internal class ExperiencesPublisher: ExperiencesPublishing { } /// Cleans up the overlay after the actual UIKit dismissal completion fires. + /// + /// This is the one hook every renderer reaches — through `onExperienceDismissalCompleted()` + /// when the user closes it, and through `endExperience`'s completion when an incoming scan + /// replaces it — so it is where the preview session is settled, mirroring Android's + /// `completeExperienceDismissal`. func experienceDidFinishDismissing() { + // Settle the preview that just went away. Its session is ended only while the preview that + // rendered under it still owns it: a scan that arrived while this experience was closing + // has already begun the next session, and that one must outlive this close — otherwise the + // scan would dismiss the content on screen and show nothing. + if experienceStateMachine.isPreviewMode() { + if previewSessionTracker.ownsRenderedSession() { + resetProcessingPreviewExperienceStatus() + } else { + resetProcessingExperienceStatus() + } + } performOn(.main) { [weak self] in self?.hideExperienceOverlayIfIdle() } @@ -405,7 +421,11 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func publishInternalSDKEvent(_ sdkEvent: SDKEvent) { tryCatch { - if isPreviewExperienceMode() { + // Deliberately the state machine alone, not `isPreviewExperienceMode()`: a QR deep link + // claims its preview session before the experience it replaces has finished closing, so + // the wider check would route that experience's own close into this branch and let it + // cancel the session belonging to the preview that replaced it. + if experienceStateMachine.isPreviewMode() { if sdkEvent.isEventForCloseExperience() || sdkEvent.isEventForCloseNPSExperience() { activeExperience = nil requestFakeScreenReloadEventDate = Date() @@ -413,7 +433,6 @@ internal class ExperiencesPublisher: ExperiencesPublishing { sdkEvent.getContentType(), sdkEvent.getContentId() ) - resetProcessingPreviewExperienceStatus() } return } @@ -1260,6 +1279,8 @@ extension ExperiencesPublisher { experienceQueue.async { [weak self] in guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } + // This session is the one about to be on screen, so it is the one its close owns. + self.previewSessionTracker.markRendered(previewSessionId) self.pendingExperiences.append(experience) self.themeHandler.saveTheme(theme) self.openExperienceFlow() diff --git a/Sources/Userpilot/Experiences/PreviewSessionTracker.swift b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift index b6ab5f07..d10ed272 100644 --- a/Sources/Userpilot/Experiences/PreviewSessionTracker.swift +++ b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift @@ -25,6 +25,7 @@ internal final class PreviewSessionTracker { private let lock = NSLock() private var nextId: UInt64 = 0 private var activeId: UInt64? + private var renderedId: UInt64? /// Starts a new preview session, superseding any previous one. /// - Returns: The id to carry through this attempt's async hops. @@ -67,4 +68,21 @@ internal final class PreviewSessionTracker { activeId = nil lock.unlock() } + + /// Records the session whose preview actually reached the screen. That session is the only one + /// the close of this preview is entitled to end. + func markRendered(_ id: UInt64) { + lock.lock() + defer { lock.unlock() } + renderedId = id + } + + /// Whether the preview on screen still owns the live session. False once a newer scan has begun + /// one, so that scan outlives the close of the preview it replaced. + func ownsRenderedSession() -> Bool { + lock.lock() + defer { lock.unlock() } + guard let renderedId else { return false } + return activeId == renderedId + } } diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 9c5add15..951c9d44 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -648,6 +648,8 @@ final class ExperiencesPublisherTests: XCTestCase { // Act experiencesPublisher.publishInternalSDKEvent(completedSurveyEvent) + // The preview is settled once the dismissal actually lands, not on the close event. + experiencesPublisher.experienceDidFinishDismissing() // Assert XCTAssertNil(publishedEvent) @@ -692,6 +694,8 @@ final class ExperiencesPublisherTests: XCTestCase { // Act experiencesPublisher.publishInternalSDKEvent(closeEvent) + // The preview is settled once the dismissal actually lands, not on the close event. + experiencesPublisher.experienceDidFinishDismissing() // Assert XCTAssertFalse(userpilot.experienceStateMachine.isPreviewMode()) @@ -1024,6 +1028,126 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertFalse(savedThemeIds.contains(11)) } + func testTriggerPreviewExperience_shouldSurviveTheCloseOfTheExperienceItReplaces() { + // Arrange — an experience on screen that reports its dismissal *before* the close + // completion runs, which is the order the real renderers use: `closeExperience` calls + // `onDismissStep()` and only then invokes the completion. + let onScreen = MockUPExperience() + experiencesPublisher.mockActiveExperience(experience: onScreen) + + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 15] + ) + closeEvent.isCloseEvent = true + onScreen.onTriggerClose = { [weak self] _ in + self?.experiencesPublisher.publishInternalSDKEvent(closeEvent) + } + + let fetchExpectation = XCTestExpectation(description: "preview fetch requested") + userpilot.remoteSource.onFetchPreviewExperience = { _, completion in + completion(.failure(.emptyResponse)) + fetchExpectation.fulfill() + } + + // Act — a QR deep link starts a preview while that experience is still closing + experiencesPublisher.triggerPreviewExperience("19", []) + + // Assert — the replaced experience's close must not tear down the incoming preview + wait(for: [fetchExpectation], timeout: 1.0) + } + + func testTriggerPreviewExperience_shouldSurviveTheCloseOfThePreviewItReplaces() throws { + // Arrange — a first preview whose response landed, so it owns the live session + let firstFetch = XCTestExpectation(description: "first preview fetch requested") + var firstCompletion: ((Result) -> Void)? + userpilot.remoteSource.onFetchPreviewExperience = { _, completion in + firstCompletion = completion + firstFetch.fulfill() + } + let firstRendered = XCTestExpectation(description: "first preview reached render") + userpilot.themeHandler.onSaveTheme = { theme in + if theme.id == 11 { firstRendered.fulfill() } + } + experiencesPublisher.triggerPreviewExperience("first", []) + wait(for: [firstFetch], timeout: 1.0) + firstCompletion?(.success(try makePreviewExperience(themeId: 11))) + wait(for: [firstRendered], timeout: 1.0) + + // ...and is now the experience on screen, closing the same way the real renderers do + let onScreen = MockUPExperience() + experiencesPublisher.mockActiveExperience(experience: onScreen) + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + userpilot.experienceStateMachine.markActive(.preview, .flow(content: flow)) + + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 10] + ) + closeEvent.isCloseEvent = true + onScreen.onTriggerClose = { [weak self] _ in + self?.experiencesPublisher.publishInternalSDKEvent(closeEvent) + } + + let secondFetch = XCTestExpectation(description: "second preview fetch requested") + userpilot.remoteSource.onFetchPreviewExperience = { _, completion in + completion(.failure(.emptyResponse)) + secondFetch.fulfill() + } + + // Act — a second QR scan arrives while that preview is still on screen + experiencesPublisher.triggerPreviewExperience("second", []) + + // Assert — a scan always wins: the outgoing preview owns only its own session + wait(for: [secondFetch], timeout: 1.0) + } + + func testPublishInternalSDKEvent_shouldEndPreviewSession_WhenTheRenderedPreviewCloses() throws { + // Arrange — a preview that reached the screen the way it does in production: that handoff + // is what records the session the close is then entitled to end. + let fetchExpectation = XCTestExpectation(description: "preview fetch requested") + var fetchCompletion: ((Result) -> Void)? + userpilot.remoteSource.onFetchPreviewExperience = { _, completion in + fetchCompletion = completion + fetchExpectation.fulfill() + } + let rendered = XCTestExpectation(description: "preview reached render") + userpilot.themeHandler.onSaveTheme = { theme in + if theme.id == 33 { rendered.fulfill() } + } + experiencesPublisher.triggerPreviewExperience("19", []) + wait(for: [fetchExpectation], timeout: 1.0) + fetchCompletion?(.success(try makePreviewExperience(themeId: 33))) + wait(for: [rendered], timeout: 1.0) + + let flow = try XCTUnwrap( + MockContentFactory.makeFlowContentPayload() + .toJSONString()? + .toFlowContent()? + .flowContent + ) + userpilot.experienceStateMachine.markActive(.preview, .flow(content: flow)) + XCTAssertTrue(experiencesPublisher.isPreviewExperienceMode()) + + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 19] + ) + closeEvent.isCloseEvent = true + + // Act — the close event, then the dismissal callback that every renderer reaches + experiencesPublisher.publishInternalSDKEvent(closeEvent) + experiencesPublisher.experienceDidFinishDismissing() + + // Assert — the preview that was on screen does own its session, so closing it ends it + XCTAssertFalse(experiencesPublisher.isPreviewExperienceMode()) + } + private func makePreviewExperience(themeId: Int) throws -> PreviewExperience { let flow = try XCTUnwrap( MockContentFactory.makeFlowContentPayload() From 10b31817d1fb98ba1d284febd86cc0cf7867265a Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 10:36:57 +0300 Subject: [PATCH 88/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Experiences):=20Skip?= =?UTF-8?q?=20preview=20reloads=20for=20deep=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let navigation trigger screen evaluation when a preview closes with a deep link. Apply the normal experience close exclusion to the preview reload request while keeping cleanup at dismissal completion. --- .../Userpilot/Experiences/ExperiencesPublisher.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 1e079c32..4801366a 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -429,10 +429,12 @@ internal class ExperiencesPublisher: ExperiencesPublishing { if sdkEvent.isEventForCloseExperience() || sdkEvent.isEventForCloseNPSExperience() { activeExperience = nil requestFakeScreenReloadEventDate = Date() - analyticsPublisher.publishFakeReloadScreenEvent( - sdkEvent.getContentType(), - sdkEvent.getContentId() - ) + if !sdkEvent.hasDeepLink { + analyticsPublisher.publishFakeReloadScreenEvent( + sdkEvent.getContentType(), + sdkEvent.getContentId() + ) + } } return } From ab4d038c7534a22a3020d7f4f98581fcd42eba6a Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 13:26:14 +0300 Subject: [PATCH 89/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Clear=20?= =?UTF-8?q?stale=20SDK=20requests=20on=20user=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discard cached SDK requests when identity changes or logout begins. Keep queued analytics and the new identify event so reconnect behavior is preserved, and cover offline, disconnected, and resume paths. --- .../Analytics/AnalyticsPublisher.swift | 24 ++++-- .../Analytics/AnalyticsPublisherTests.swift | 79 +++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 240d3a00..f2e03221 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -295,8 +295,8 @@ extension AnalyticsPublisher: AnalyticsPublishing { experiencesPublisher?.logout() // Clear seen contents from screenSessionStateMachine screenSessionStateMachine?.resetState() - // Clear all queues for app logout; on user switch keep them so the new - // user's identify re-establishes the connection after the old channel closes + // Clear SDK requests for the old user; retain queued analytics during a switch + // so the new user's identify can re-establish the connection. clearAllCachedProperties(clearCachedIdentifyEvent) // Old-user offline events must never replay under a new user offlineEventsHandler.clearLocalEvents() @@ -315,7 +315,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { if storage.userId.isNotEmpty, userId != storage.userId { userSessionStateMachine.markUserSwitch() } - storage.userId = userId + updateUserId(userId) } // connect() gates itself on the socket state - always safe to call if storage.userId.isNotEmpty { openSocket() } @@ -473,7 +473,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { // offline event belongs to that user, and old persisted events are cleared // before this identify is saved. if event.isIdentifyEvent, let userId = event.userId, !userId.isEmpty { - storage.userId = userId + updateUserId(userId) } offlineEventsHandler.saveEventToLocalStorage( @@ -535,12 +535,20 @@ extension AnalyticsPublisher: AnalyticsPublishing { } if let userId = event.userId, !userId.isEmpty { - storage.userId = userId + updateUserId(userId) } else if let cachedUserId = getUserIdFromQueue() { - storage.userId = cachedUserId + updateUserId(cachedUserId) } } + /// SDK requests belong to the current user, even when no socket is connected yet. + private func updateUserId(_ userId: String) { + if storage.userId.isNotEmpty, storage.userId != userId { + cachedSDKEvents.clear() + } + storage.userId = userId + } + private func isUserSwitchIdentifyEvent(_ event: Event?) -> Bool { guard let event else { return false } return event.isIdentifyEvent @@ -1071,15 +1079,15 @@ extension AnalyticsPublisher: NetworkMonitoringDelegate { private extension AnalyticsPublisher { /** - * Clears all cached properties on app-level logout; user switch keeps them. + * Clears cached SDK requests on every logout; user switches retain queued analytics. * * - Parameter clearCachedIdentifyEvent: Whether to clear the cached identify event */ private func clearAllCachedProperties(_ clearCachedIdentifyEvent: Bool = false) { + cachedSDKEvents.clear() guard clearCachedIdentifyEvent else { return } eventsQueue.clear() initialQueue.clear() - cachedSDKEvents.clear() self.clearCachedIdentifyEvent() } diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index d5d8eb93..59a43ae2 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -699,6 +699,85 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(publishedEvents.isEmpty) } + func testOfflineUserSwitch_dropsOldSDKRequestsAndKeepsNewRequests() { + userpilot.storage.userId = "user-a" + userpilot.offlineEventsHandler.shouldSaveOffline = true + let published = recordPublishedEvents() + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_content")) + + analyticsPublisher.publish(Event(type: .identify("user-b"))) + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_theme")) + + XCTAssertEqual(userpilot.storage.userId, "user-b") + XCTAssertEqual(userpilot.offlineEventsHandler.savedEvents.first?.event.userId, "user-b") + userpilot.offlineEventsHandler.shouldSaveOffline = false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + XCTAssertEqual(published(), ["get_mobile_theme"]) + } + + func testDisconnectedUserSwitch_dropsOldSDKRequestsAndKeepsIdentify() { + userpilot.storage.userId = "user-a" + let published = recordPublishedEvents() + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_content")) + + analyticsPublisher.publish(Event(type: .identify("user-b"))) + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + XCTAssertEqual(userpilot.storage.userId, "user-b") + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.userId, "user-b") + } + + func testResumeWithQueuedUserSwitch_dropsSDKRequestsCachedAfterIdentify() { + userpilot.storage.userId = "user-a" + userpilot.socketManager.isJoiningSocket = true + analyticsPublisher.publish(Event(type: .identify("user-b"))) + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_content")) + let published = recordPublishedEvents() + + analyticsPublisher.resume() + userpilot.socketManager.isJoiningSocket = false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + XCTAssertEqual(userpilot.storage.userId, "user-b") + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + } + + func testUserSwitchLogout_dropsSDKRequestsAndPreservesQueuedIdentify() { + userpilot.storage.userId = "user-a" + userpilot.socketManager.isJoiningSocket = true + analyticsPublisher.publish(Event(type: .identify("user-b"))) + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_content")) + let published = recordPublishedEvents() + + analyticsPublisher.logout(clearCachedIdentifyEvent: false) + + XCTAssertEqual(analyticsPublisher.mockGetEventsToFlush().first?.userId, "user-b") + userpilot.storage.userId = "user-b" + userpilot.socketManager.isJoiningSocket = false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + } + + func testOfflineIdentifyForSameUser_preservesCachedSDKRequests() { + userpilot.storage.userId = "user-a" + userpilot.offlineEventsHandler.shouldSaveOffline = true + let published = recordPublishedEvents() + analyticsPublisher.publishInternalSDKEvent(MockSDKEvent(eventName: "get_mobile_content")) + + analyticsPublisher.publish(Event(type: .identify("user-a"))) + userpilot.offlineEventsHandler.shouldSaveOffline = false + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + + XCTAssertEqual(published(), ["get_mobile_content"]) + } + func testIsExperienceSeen_shouldUseSeenSetForContentType() throws { // Arrange — the screen session (which owns the seen sets) is only created once the // screen event actually goes out, which requires an open socket. From 22d6a742d6a426d4ddf9002b988458979c575790 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 13:26:26 +0300 Subject: [PATCH 90/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Analytics):=20Preserve?= =?UTF-8?q?=20metadata=20when=20replaying=20offline=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain saved screen properties, including the capture source, when building offline batches. Override fake_reload to false because these entries represent original screen visits. --- .../Analytics/OfflineEventsHandler.swift | 4 +- .../Analytics/OfflineEventsHandlerTests.swift | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift index c194024e..883d678c 100644 --- a/Sources/Userpilot/Analytics/OfflineEventsHandler.swift +++ b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift @@ -388,7 +388,9 @@ internal class OfflineEventsHandler: OfflineEventsHandling { eventData[Constants.OfflineEvents.createdAtProperty] = formatTimestampWithTimezone( eventStorage.createdAt) eventData[Constants.Analytics.screenTitleProperty] = event.screenTitle ?? "" - eventData[Constants.Analytics.metaDataProperty] = [Constants.Analytics.fakeReload: false] + var metadata = event.properties ?? [:] + metadata[Constants.Analytics.fakeReload] = false + eventData[Constants.Analytics.metaDataProperty] = metadata return eventData } diff --git a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift index c04514fb..5ba0cdc4 100644 --- a/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift +++ b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift @@ -229,6 +229,43 @@ class OfflineEventsHandlerTests: XCTestCase { // MARK: - Internal Event Replay + func testRestore_preservesScreenPropertiesAndOverridesFakeReload() throws { + let sources = [Constants.AutoCapture.manualCaptureSourceValue, Constants.AutoCapture.autoCaptureSourceValue] + for source in sources { + handler.saveEventToLocalStorage(event: Event( + type: .screen("Home"), + properties: [ + Constants.AutoCapture.source: source, + Constants.Analytics.fakeReload: true, + "custom_property": "retained" + ] + )) + } + + let events = try captureRestoredBatch() + + XCTAssertEqual(events.count, sources.count) + for (item, source) in zip(events, sources) { + XCTAssertEqual(item[Constants.OfflineEvents.eventTypeProperty] as? String, "screen") + XCTAssertEqual(item[Constants.Analytics.screenTitleProperty] as? String, "Home") + let metadata = try XCTUnwrap(item[Constants.Analytics.metaDataProperty] as? [String: Any]) + XCTAssertEqual(metadata[Constants.AutoCapture.source] as? String, source) + XCTAssertEqual(metadata["custom_property"] as? String, "retained") + XCTAssertEqual(metadata[Constants.Analytics.fakeReload] as? Bool, false) + } + } + + func testRestore_screenWithoutPropertiesKeepsFakeReloadFalse() throws { + handler.saveEventToLocalStorage(event: Event(type: .screen("Home"))) + + let events = try captureRestoredBatch() + + let item = try XCTUnwrap(events.first) + let metadata = try XCTUnwrap(item[Constants.Analytics.metaDataProperty] as? [String: Any]) + XCTAssertEqual(metadata.count, 1) + XCTAssertEqual(metadata[Constants.Analytics.fakeReload] as? Bool, false) + } + func testRestore_spreadsTheInternalPayloadAtTheTopLevelOfTheBatchItem() throws { handler.saveSDKEventToLocalStorage( MockSDKEvent( From e9dd0a1ca28298666767ef0662a4d5e806697507 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 13:26:38 +0300 Subject: [PATCH 91/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Experiences):=20Keep?= =?UTF-8?q?=20NPS=20suppression=20across=20preview=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the per-screen NPS flag while QR previews open and close. Clear it for a new screen or logout, and skip reload requests when a previewed NPS closes, matching the normal NPS behavior. --- .../Experiences/ExperiencesPublisher.swift | 10 +- .../ExperiencesPublisherTests.swift | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 4801366a..1013cb6c 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -118,7 +118,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { private lazy var currentScreen: String = "" /// Set when NPS is presented so it shows at most once per screen visit, matching Android. - /// Cleared by `resetState()` when the screen changes or the user logs out. + /// Cleared when the screen changes or the user logs out, but preserved across QR previews. /// Atomic because presentation writes on main while socket and reset paths use other queues. private let npsShownOnCurrentScreen = AtomicReference(false) @@ -191,6 +191,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { * This prevents experiences from being shown to the wrong user. */ func logout() { + npsShownOnCurrentScreen.value = false resetState() } @@ -429,7 +430,10 @@ internal class ExperiencesPublisher: ExperiencesPublishing { if sdkEvent.isEventForCloseExperience() || sdkEvent.isEventForCloseNPSExperience() { activeExperience = nil requestFakeScreenReloadEventDate = Date() - if !sdkEvent.hasDeepLink { + // Same exclusions as the non-preview path below, and as Android's + // `handlePreviewCloseEvent`: NPS is the last content shown, and a deep link + // opens a new screen that will ask for content on its own. + if !sdkEvent.isEventForCloseNPSExperience(), !sdkEvent.hasDeepLink { analyticsPublisher.publishFakeReloadScreenEvent( sdkEvent.getContentType(), sdkEvent.getContentId() @@ -495,6 +499,7 @@ extension ExperiencesPublisher: SocketSubscription { tryCatch { if currentScreen == screenName { return } currentScreen = screenName + npsShownOnCurrentScreen.value = false // A preview renders on whatever screen the deep link landed on, so the screen change // that reveals it must resume it instead of resetting it away. if isPreviewExperienceMode() { @@ -1094,7 +1099,6 @@ extension ExperiencesPublisher { tryCatch { delayUtils.cancelDelay() clearPendingExperiences() - npsShownOnCurrentScreen.value = false if hasActiveExperience || experienceStateMachine.getActiveComponent() != nil { endExperience(manualClose: true, completion: completion) diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 951c9d44..df81f422 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -447,6 +447,45 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + func testUpdateScreen_duringPreviewMakesNPSEligibleOnTheNewScreen() { + experiencesPublisher.mockSetCurrentScreen(title: "Home") + experiencesPublisher.mockSetNPSShownOnCurrentScreen(true) + userpilot.experienceStateMachine.markPreviewMode() + + experiencesPublisher.updateScreen("Details") + userpilot.experienceStateMachine.markIdle() + assertNPSIsEligible() + } + + func testLogout_makesNPSEligibleForTheNextUser() { + experiencesPublisher.mockSetCurrentScreen(title: "Home") + experiencesPublisher.mockSetNPSShownOnCurrentScreen(true) + + experiencesPublisher.logout() + + assertNPSIsEligible() + } + + private func assertNPSIsEligible(file: StaticString = #filePath, line: UInt = #line) { + let processed = expectation(description: "NPS accepted after screen or identity change") + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, + nil, + Message(payload: MockContentFactory.makeNPSContentPayload()), + true + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self else { return } + if case .nps = self.experiencesPublisher.getActiveMobileContent() { + processed.fulfill() + } else { + XCTFail("Expected NPS to be eligible", file: file, line: line) + processed.fulfill() + } + } + wait(for: [processed], timeout: 1.0) + } + func testOnSocketEventSent_shouldSelectSurvey_WhenHigherPriorityFlowWasSeen() { // Arrange let expectation = XCTestExpectation(description: "Unseen survey should be selected") @@ -755,6 +794,46 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertFalse(canRequest) } + /// NPS is the last content shown, so closing a previewed NPS must not ask the backend for more + /// — matching the non-preview path here and Android's `handlePreviewCloseEvent`. + func testPublishInternalSDKEvent_shouldNotRequestReload_ForPreviewNPSClose() { + // Arrange + userpilot.experienceStateMachine.markPreviewMode() + var reloadCount = 0 + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in + reloadCount += 1 + return true + } + let closeEvent = MockSDKEvent(eventName: SDKEventsName.npsExperienceSubmitted.rawValue) + closeEvent.isCloseNPSEvent = true + + // Act + experiencesPublisher.publishInternalSDKEvent(closeEvent) + + // Assert + XCTAssertEqual(reloadCount, 0) + } + + /// The ordinary preview close still asks for the next content — that request is what lets the + /// next experience appear after a QR preview is dismissed. + func testPublishInternalSDKEvent_shouldRequestReload_ForPreviewFlowClose() { + // Arrange + userpilot.experienceStateMachine.markPreviewMode() + var reloadCount = 0 + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in + reloadCount += 1 + return true + } + let closeEvent = MockSDKEvent(eventName: SDKEventsName.flowExperienceDismissed.rawValue) + closeEvent.isCloseEvent = true + + // Act + experiencesPublisher.publishInternalSDKEvent(closeEvent) + + // Assert + XCTAssertEqual(reloadCount, 1) + } + func testPublishInternalSDKEvent_shouldHandleCloseEvent_WithDeepLink() { userpilot.experienceStateMachine.markActiveFromCurrentState( content: .survey(content: MockContentFactory.makeSurveyContent()) @@ -1108,6 +1187,9 @@ final class ExperiencesPublisherTests: XCTestCase { } func testPublishInternalSDKEvent_shouldEndPreviewSession_WhenTheRenderedPreviewCloses() throws { + // A real NPS was already shown and dismissed on this screen before the QR scan. + experiencesPublisher.mockSetCurrentScreen(title: "Home") + experiencesPublisher.mockSetNPSShownOnCurrentScreen(true) // Arrange — a preview that reached the screen the way it does in production: that handoff // is what records the session the close is then entitled to end. let fetchExpectation = XCTestExpectation(description: "preview fetch requested") @@ -1124,6 +1206,7 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [fetchExpectation], timeout: 1.0) fetchCompletion?(.success(try makePreviewExperience(themeId: 33))) wait(for: [rendered], timeout: 1.0) + XCTAssertNotNil(experiencesPublisher.getActiveMobileContent(), "The preview renderer consumes its content") let flow = try XCTUnwrap( MockContentFactory.makeFlowContentPayload() @@ -1146,6 +1229,21 @@ final class ExperiencesPublisherTests: XCTestCase { // Assert — the preview that was on screen does own its session, so closing it ends it XCTAssertFalse(experiencesPublisher.isPreviewExperienceMode()) + + let repeatProcessed = expectation(description: "NPS stays suppressed after closing the preview") + experiencesPublisher.updateScreen("Home") + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, + nil, + Message(payload: MockContentFactory.makeNPSContentPayload()), + true + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self else { return } + XCTAssertNil(self.experiencesPublisher.getActiveMobileContent(), "Preview must not start a new screen visit") + repeatProcessed.fulfill() + } + wait(for: [repeatProcessed], timeout: 1.0) } private func makePreviewExperience(themeId: Int) throws -> PreviewExperience { From c947f93ce8031b6a329654166ef80c95e7558473 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 13:26:48 +0300 Subject: [PATCH 92/95] =?UTF-8?q?=F0=9F=90=9B=20fix(Experiences):=20Preser?= =?UTF-8?q?ve=20pending=20triggers=20and=20cached=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture each pending experience's trigger before theme fetches and delays. Keep cached manual requests separate from active state, with manual priority and replay after dismissal or abandoned preparation. Cover theme loading, targeting, empty replies, and preview thank-you. --- .../Experiences/ExperienceStateMachine.swift | 66 +++-- .../Experiences/ExperiencesPublisher.swift | 232 +++++++++------- .../ExperienceStateMachineTests.swift | 79 +++++- .../ExperiencesPublisherTests.swift | 249 ++++++++++++++++++ 4 files changed, 493 insertions(+), 133 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperienceStateMachine.swift b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift index 843b58c4..ec50ef44 100644 --- a/Sources/Userpilot/Experiences/ExperienceStateMachine.swift +++ b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift @@ -51,12 +51,6 @@ internal enum ExperienceFlowState { /// its `triggerType`, so without it a previewed survey's thank-you screen looks like a real one /// and publishes real events. case showingThankYou(isPreview: Bool) - - /// Manual experience cached because another experience is in progress. - case cachedPendingManual(experienceId: String) - - /// Automatic experience cached because another experience is in progress. - case cachedPendingAutomatic(experience: ExperienceContent) } // MARK: - State Checks @@ -66,7 +60,7 @@ extension ExperienceFlowState { /// Checks if experience was manually triggered. func isManualTrigger() -> Bool { switch self { - case .pendingManual, .cachedPendingManual: + case .pendingManual: return true case .waitingDelay(let triggerType), .active(let triggerType, _): return triggerType == .manual @@ -109,16 +103,6 @@ extension ExperienceFlowState { } } - /// Checks if a cached experience is waiting to be processed. - func hasCachedExperience() -> Bool { - switch self { - case .cachedPendingManual, .cachedPendingAutomatic: - return true - default: - return false - } - } - /// Checks if screen validation should be bypassed. func shouldBypassScreenValidation() -> Bool { isManualTrigger() || isPreviewMode() @@ -144,10 +128,6 @@ extension ExperienceFlowState: CustomStringConvertible { return "Active(\(triggerType), content=\(type(of: content)))" case .showingThankYou(let isPreview): return "ShowingThankYou(preview=\(isPreview))" - case .cachedPendingManual(let experienceId): - return "CachedPendingManual(id=\(experienceId))" - case .cachedPendingAutomatic: - return "CachedPendingAutomatic" } } } @@ -193,6 +173,7 @@ internal protocol ExperienceStateManaging: AnyObject { func markShowingThankYou() func markCachedManual(_ experienceId: String) func markCachedAutomatic(_ experience: ExperienceContent) + func clearCachedExperience() // MARK: - Active Experience Component Management @@ -210,6 +191,7 @@ internal protocol ExperienceStateManaging: AnyObject { // MARK: - High-Level Operations func markActiveFromCurrentState(content: ExperienceContent) + func processCachedExperience() -> ExperienceStateMachine.CachedExperienceAction } // MARK: - ExperienceStateMachine @@ -217,12 +199,22 @@ internal protocol ExperienceStateManaging: AnyObject { /// Manages experience flow state transitions and provides thread-safe access to current state. internal final class ExperienceStateMachine { + /// Work saved while another experience is being prepared or displayed. + enum CachedExperienceAction { + case none + case triggerManual(experienceId: String) + case processAutomatic(experience: ExperienceContent) + } + // MARK: - Properties private let logger: Logging private let state: AtomicReference private var activeComponent: WeakExperienceReference? + /// A queued request must not replace the current experience's trigger or rendering state. + private let cachedExperience = AtomicReference(.none) + // MARK: - Initialization init(container: DIContainer) { @@ -262,7 +254,10 @@ extension ExperienceStateMachine: ExperienceStateManaging { } func hasCachedExperience() -> Bool { - state.value.hasCachedExperience() + if case .none = cachedExperience.value { + return false + } + return true } // MARK: - State Transition Methods @@ -311,13 +306,25 @@ extension ExperienceStateMachine: ExperienceStateManaging { } func markCachedManual(_ experienceId: String) { - state.value = .cachedPendingManual(experienceId: experienceId) + cachedExperience.value = .triggerManual(experienceId: experienceId) logger.info("Experience state: CachedPendingManual(id=%@)", experienceId) } func markCachedAutomatic(_ experience: ExperienceContent) { - state.value = .cachedPendingAutomatic(experience: experience) - logger.info("Experience state: CachedPendingAutomatic") + let action = cachedExperience.update { current in + if case .triggerManual = current { return current } + return .processAutomatic(experience: experience) + } + if case .triggerManual = action { + logger.info("Experience cache: automatic content ignored - manual request has priority") + } else { + logger.info("Experience state: CachedPendingAutomatic") + } + } + + /// Abandons cached work on logout, screen changes, and preview replacement. + func clearCachedExperience() { + cachedExperience.value = .none } // MARK: - Active Experience Component Management @@ -352,14 +359,14 @@ extension ExperienceStateMachine: ExperienceStateManaging { // MARK: - State Query Helpers func getCachedExperienceId() -> String? { - if case .cachedPendingManual(let experienceId) = state.value { + if case .triggerManual(let experienceId) = cachedExperience.value { return experienceId } return nil } func getCachedExperienceContent() -> ExperienceContent? { - if case .cachedPendingAutomatic(let experience) = state.value { + if case .processAutomatic(let experience) = cachedExperience.value { return experience } return nil @@ -367,6 +374,11 @@ extension ExperienceStateMachine: ExperienceStateManaging { // MARK: - High-Level Operations + /// Takes the queued request once without changing the active experience's state. + func processCachedExperience() -> CachedExperienceAction { + cachedExperience.getAndSet(.none) + } + func markActiveFromCurrentState(content: ExperienceContent) { let triggerType: TriggerType if isManualTrigger() { diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 1013cb6c..87b9564b 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -122,8 +122,14 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Atomic because presentation writes on main while socket and reset paths use other queues. private let npsShownOnCurrentScreen = AtomicReference(false) - /// Queue to track pending experience content waiting to be displayed - private var pendingExperiences: [ExperienceContent] = [] + /// Keeps trigger provenance with content across theme fetching and display delays. + private struct PendingExperience { + let experienceContent: ExperienceContent + let triggerType: TriggerType + } + + /// Queue to track pending experience content waiting to be displayed. + private var pendingExperiences: [PendingExperience] = [] /// Rejects preview responses that were overtaken by a newer preview or a lifecycle reset. private let previewSessionTracker = PreviewSessionTracker() @@ -223,20 +229,24 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func triggerExperience(_ experienceId: String) { experienceQueue.async { [weak self] in - guard let self else { return } - if self.hasActiveExperience - || self.experienceStateMachine.isActive() - || self.experienceStateMachine.hasCachedExperience() - || !self.pendingExperiences.isEmpty { - self.experienceStateMachine.markCachedManual(experienceId) - self.logger.info("Experience cached - active experience in progress") - return - } + self?.triggerExperienceIfIdle(experienceId) + } + } - self.delayUtils.cancelDelay() - self.experienceStateMachine.markManualTrigger(experienceId) - self.publishInternalSDKEvent(ExperienceContentEvent(experienceId: experienceId)) + /// Runs on `experienceQueue` for both host requests and cached manual replay. + private func triggerExperienceIfIdle(_ experienceId: String) { + guard case .idle = experienceStateMachine.getCurrentState(), + !hasActiveExperience, + !experienceStateMachine.hasCachedExperience(), + pendingExperiences.isEmpty else { + experienceStateMachine.markCachedManual(experienceId) + logger.info("Experience cached - active experience in progress") + return } + + delayUtils.cancelDelay() + experienceStateMachine.markManualTrigger(experienceId) + publishInternalSDKEvent(ExperienceContentEvent(experienceId: experienceId)) } /// Helper method to get top view controller @@ -355,7 +365,12 @@ internal class ExperiencesPublisher: ExperiencesPublishing { } else { resetProcessingExperienceStatus() } + } else if experienceStateMachine.isActivelyRendered() { + resetProcessingExperienceStatus() } + // A cached request belongs to the next experience. Start it only after the current + // renderer has finished dismissing, and never while a replacement preview owns the flow. + processCachedExperienceAfterClose() performOn(.main) { [weak self] in self?.hideExperienceOverlayIfIdle() } @@ -386,7 +401,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func getActiveMobileContent() -> ExperienceContent? { guard !pendingExperiences.isEmpty else { return nil } - let content = pendingExperiences.first + let content = pendingExperiences.first?.experienceContent clearPendingExperiences() return content } @@ -475,8 +490,6 @@ internal class ExperiencesPublisher: ExperiencesPublishing { analyticsPublisher.publishFakeReloadScreenEvent( sdkEvent.getContentType(), sdkEvent.getContentId() ) - } else if sdkEvent.isEventForCloseExperience() { - processCachedExperienceAfterClose() } } } @@ -545,16 +558,15 @@ extension ExperiencesPublisher: SocketSubscription { _ eventSent: Bool ) { experienceQueue.async { [weak self] in - guard let self, - // If there's an active experience, ignore new content - !hasActiveExperience, + guard let self else { return } + defer { self.finishManualRequestIfEmpty(eventName, payload) } + // If there's an active experience, ignore new content. + guard !hasActiveExperience, !experienceStateMachine.isActive(), !experienceStateMachine.isPreviewMode(), !message.payload.isEmpty, - let response = message.payload.toJSONString() - else { - return - } + let response = message.payload.toJSONString() else { return } + if eventName == SDKEventsName.fetchExperienceContent.rawValue, !eventSent { return } // Cache theme data if this is a fetch theme event if eventName == SDKEventsName.fetchExperienceTheme.rawValue, @@ -589,21 +601,35 @@ extension ExperiencesPublisher: SocketSubscription { } else { self.experienceStateMachine.markAutomaticTrigger(experience) } - self.pendingExperiences.append(experience) + self.pendingExperiences.append( + PendingExperience(experienceContent: experience, triggerType: triggerType) + ) } } // Process the first pending experience - if let experience = self.pendingExperiences.first { - if experience.asNPSContent() != nil { - self.openNPSBottomSheetExperience(experience) + if let pendingExperience = self.pendingExperiences.first { + if pendingExperience.experienceContent.asNPSContent() != nil { + self.openNPSBottomSheetExperience(pendingExperience) } else { - self.checkCachedThemes(experience.experienceThemeId()) + self.checkCachedThemes(pendingExperience.experienceContent.experienceThemeId()) } } } } + /// Empty or failed manual replies end preparation even when there is no renderer to dismiss. + private func finishManualRequestIfEmpty(_ eventName: String, _ payload: Payload) { + guard eventName == SDKEventsName.fetchExperienceContent.rawValue, + case .pendingManual(let experienceId) = experienceStateMachine.getCurrentState(), + pendingExperiences.isEmpty, + !hasActiveExperience, + !previewSessionTracker.isActive() else { return } + if let requestedId = payload?["mobile_content_token"] as? String, requestedId != experienceId { return } + resetProcessingExperienceStatus() + processCachedExperienceAfterClose() + } + /** * Handles new messages received from the socket. * This is triggered from manual experience events. @@ -645,7 +671,9 @@ extension ExperiencesPublisher: SocketSubscription { self.experienceStateMachine.markManualTrigger( experience.experienceId().toString() ) - self.pendingExperiences.append(experience) + self.pendingExperiences.append( + PendingExperience(experienceContent: experience, triggerType: .manual) + ) self.checkCachedThemes(experience.experienceThemeId()) } } @@ -683,7 +711,10 @@ extension ExperiencesPublisher { */ private func fetchThemeData(_ themeId: Int) { guard analyticsPublisher.canRequestEvent else { - clearPendingExperiences() + // Theme preparation is abandoned on this queue; leave the flow ready for cached work. + pendingExperiences.removeAll() + resetProcessingExperienceStatus() + processCachedExperienceAfterClose() return } @@ -701,34 +732,34 @@ extension ExperiencesPublisher { * Handles flows (carousel, slide-out), surveys (list, step), and NPS experiences. */ private func openExperienceFlow() { - if let experienceContent = pendingExperiences.first { - switch experienceContent { + if let pendingExperience = pendingExperiences.first { + switch pendingExperience.experienceContent { case .flow(let content): switch content.type { case .carousel: - self.openCarouselExperience(experienceContent) + self.openCarouselExperience(pendingExperience) case .slideout: if self.isBottomSheetContent(content) { - self.openSlideOutBottomSheetExperience(experienceContent) + self.openSlideOutBottomSheetExperience(pendingExperience) } else { - self.openSlideOutDialogExperience(experienceContent) + self.openSlideOutDialogExperience(pendingExperience) } } case .survey(let content): switch content.type { case .list: - self.openSurveyListExperience(experienceContent) + self.openSurveyListExperience(pendingExperience) case .step: if self.isBottomSheetSurveyContent(content) { - self.openSurveyBottomSheetExperience(experienceContent) + self.openSurveyBottomSheetExperience(pendingExperience) } else { - self.openSurveyDialogExperience(experienceContent) + self.openSurveyDialogExperience(pendingExperience) } } case .nps: - openNPSBottomSheetExperience(experienceContent) + openNPSBottomSheetExperience(pendingExperience) } } } @@ -773,9 +804,9 @@ extension ExperiencesPublisher { * Opens a carousel experience in a full-screen activity. * Content is passed to prevent issues if pending experiences are cleared during screen transitions. */ - private func openCarouselExperience(_ experienceContent: ExperienceContent) { + private func openCarouselExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: ExperienceViewModel.init, makeViewController: CarouselExperienceViewController.init, presentation: .fullScreen @@ -783,9 +814,9 @@ extension ExperiencesPublisher { } /** Opens a slide-out experience as a dialog fragment */ - private func openSlideOutDialogExperience(_ experienceContent: ExperienceContent) { + private func openSlideOutDialogExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: ExperienceViewModel.init, makeViewController: SlideOutDialogViewController.init, presentation: .dialog @@ -793,9 +824,9 @@ extension ExperiencesPublisher { } /** Opens a slide-out experience as a bottom sheet fragment */ - private func openSlideOutBottomSheetExperience(_ experienceContent: ExperienceContent) { + private func openSlideOutBottomSheetExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: ExperienceViewModel.init, makeViewController: SlideOutBottomSheetViewController.init, presentation: .bottomSheet @@ -803,9 +834,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience in a full-screen activity with list view */ - private func openSurveyListExperience(_ experienceContent: ExperienceContent) { + private func openSurveyListExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: SurveyViewModel.init, makeViewController: SurveyListViewController.init, presentation: .fullScreen @@ -813,9 +844,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience as a dialog fragment */ - private func openSurveyDialogExperience(_ experienceContent: ExperienceContent) { + private func openSurveyDialogExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: SurveyViewModel.init, makeViewController: SurveyDialogViewController.init, presentation: .dialog @@ -823,9 +854,9 @@ extension ExperiencesPublisher { } /** Opens a survey experience as a bottom sheet fragment */ - private func openSurveyBottomSheetExperience(_ experienceContent: ExperienceContent) { + private func openSurveyBottomSheetExperience(_ pendingExperience: PendingExperience) { showExperience( - experienceContent, + pendingExperience, makeViewModel: SurveyViewModel.init, makeViewController: SurveyBottomSheetViewController.init, presentation: .bottomSheet @@ -833,7 +864,7 @@ extension ExperiencesPublisher { } /** Opens an NPS experience as a bottom sheet, at most once per screen visit. */ - private func openNPSBottomSheetExperience(_ experienceContent: ExperienceContent) { + private func openNPSBottomSheetExperience(_ pendingExperience: PendingExperience) { if npsShownOnCurrentScreen.value { logger.info("🎯 NPS suppressed - already shown on the current screen") // Drain the queue rather than only resetting the state: every launch path reads @@ -843,7 +874,7 @@ extension ExperiencesPublisher { return } showExperience( - experienceContent, + pendingExperience, makeViewModel: NPSViewModel.init, makeViewController: NPSBottomSheetViewController.init, presentation: .bottomSheet @@ -862,7 +893,6 @@ extension ExperiencesPublisher { let self = self, let host = self.experiencePresentationHost() else { - self?.resetProcessingExperienceStatus() self?.experienceDidFinishDismissing() return } @@ -884,7 +914,6 @@ extension ExperiencesPublisher { } } thankYouBottomSheetViewController.onDismissCompleted = { [weak self] in - self?.resetProcessingExperienceStatus() self?.experienceDidFinishDismissing() } host.presentBottomSheet(viewController: thankYouBottomSheetViewController) @@ -920,26 +949,19 @@ extension ExperiencesPublisher { * dropped frames and interrupts opening content animations. */ private func showExperience( - _ experienceContent: ExperienceContent, + _ pendingExperience: PendingExperience, makeViewModel: @escaping (DIContainer) -> VM, makeViewController: @escaping (VM) -> VC, presentation: PresentationStyle ) { tryCatch { - let triggerType: TriggerType - if experienceStateMachine.isManualTrigger() { - triggerType = .manual - } else if experienceStateMachine.isPreviewMode() { - triggerType = .preview - } else { - triggerType = .automatic - } - experienceStateMachine.markWaitingDelay(triggerType) + let experienceContent = pendingExperience.experienceContent + experienceStateMachine.markWaitingDelay(pendingExperience.triggerType) delayUtils.delayAction(delayTime: resolvedDelay(experienceContent)) { [weak self] in guard let self, - self.canShowExperience(experienceContent), + self.canShowExperience(pendingExperience), let container = self.container else { // Delay was cancelled through resetState, stop processing this experience @@ -948,9 +970,9 @@ extension ExperiencesPublisher { return } - performOn(.main) { - guard let host = self.experiencePresentationHost() else { - self.processNextPendingExperiences() + performOn(.main) { [weak self] in + guard let self, let host = self.experiencePresentationHost() else { + self?.processNextPendingExperiences() return } let viewModel = makeViewModel(container) @@ -959,7 +981,7 @@ extension ExperiencesPublisher { viewController.modalPresentationStyle = .fullScreen } self.activeExperience = viewController - self.experienceStateMachine.markActiveFromCurrentState(content: experienceContent) + self.experienceStateMachine.markActive(pendingExperience.triggerType, experienceContent) if let upExperience = viewController as? UPExperience { self.experienceStateMachine.setActiveComponent(upExperience) } @@ -996,10 +1018,10 @@ extension ExperiencesPublisher { * (these experiences have no screens to check, so they are shown without screen validation). * - Otherwise, validates screen targeting rules for Flow, Survey, or NPS content. * - * @param experienceContent The experience content to validate. + * @param pendingExperience The content and original trigger to validate. * @return `true` if the experience can be shown, `false` otherwise. */ - private func canShowExperience(_ experienceContent: ExperienceContent) -> Bool { + private func canShowExperience(_ pendingExperience: PendingExperience) -> Bool { guard !pendingExperiences.isEmpty else { logger.info("Cannot show experience: pending experiences is empty") return false @@ -1010,7 +1032,7 @@ extension ExperiencesPublisher { return false } - if experienceStateMachine.shouldBypassScreenValidation() { + if pendingExperience.triggerType == .manual || pendingExperience.triggerType == .preview { logger.info("Can show experience: bypassing screen validation") return true } @@ -1020,6 +1042,7 @@ extension ExperiencesPublisher { return true } + let experienceContent = pendingExperience.experienceContent let flowContent = experienceContent.asFlowContent() let surveyContent = experienceContent.asSurveyContent() let npsContent = experienceContent.asNPSContent() @@ -1073,10 +1096,10 @@ extension ExperiencesPublisher { self.resetProcessingExperienceStatus() if self.pendingExperiences.count == 1 { self.pendingExperiences.removeAll() + self.processCachedExperienceAfterClose() } else { if let lastContent = self.pendingExperiences.last, self.pendingExperiences.count > 1 { self.pendingExperiences = [lastContent] - self.experienceStateMachine.markAutomaticTrigger(lastContent) self.openExperienceFlow() } } @@ -1099,6 +1122,7 @@ extension ExperiencesPublisher { tryCatch { delayUtils.cancelDelay() clearPendingExperiences() + experienceStateMachine.clearCachedExperience() if hasActiveExperience || experienceStateMachine.getActiveComponent() != nil { endExperience(manualClose: true, completion: completion) @@ -1153,7 +1177,7 @@ extension ExperiencesPublisher { if sameExperience(experienceStateMachine.getActiveContent(), experience) { return true } - if pendingExperiences.contains(where: { sameExperience($0, experience) }) { + if pendingExperiences.contains(where: { sameExperience($0.experienceContent, experience) }) { return true } return sameExperience(experienceStateMachine.getCachedExperienceContent(), experience) @@ -1190,31 +1214,37 @@ extension ExperiencesPublisher { } private func processCachedExperienceAfterClose() { - let state = experienceStateMachine.getCurrentState() - switch state { - case .cachedPendingAutomatic(let experience): - activeExperience = nil - // It waited behind another experience; if it was shown in the meantime, replaying it - // would show the same content twice on one screen. - if analyticsPublisher.isExperienceSeen(experience) { - logger.info( - "Ignoring cached experience already seen: %@", - experience.experienceId().toString() - ) - resetProcessingExperienceStatus() - return - } - pendingExperiences.append(experience) - experienceStateMachine.markAutomaticTrigger(experience) - checkCachedThemes(experience.experienceThemeId()) + experienceQueue.async { [weak self] in + guard let self, + case .idle = self.experienceStateMachine.getCurrentState(), + !self.previewSessionTracker.isActive(), + self.pendingExperiences.isEmpty else { return } + + switch self.experienceStateMachine.processCachedExperience() { + case .processAutomatic(let experience): + // Cached content may have been shown while waiting for the previous renderer. + guard !self.analyticsPublisher.isExperienceSeen(experience) else { + self.logger.info( + "Ignoring cached experience already seen: %@", + experience.experienceId().toString() + ) + return + } + let pendingExperience = PendingExperience(experienceContent: experience, triggerType: .automatic) + self.pendingExperiences.append(pendingExperience) + self.experienceStateMachine.markAutomaticTrigger(experience) + if experience.asNPSContent() != nil { + self.openNPSBottomSheetExperience(pendingExperience) + } else { + self.checkCachedThemes(experience.experienceThemeId()) + } - case .cachedPendingManual(let experienceId): - activeExperience = nil - experienceStateMachine.markIdle() - triggerExperience(experienceId) + case .triggerManual(let experienceId): + self.triggerExperienceIfIdle(experienceId) - default: - resetProcessingExperienceStatus() + case .none: + break + } } } } @@ -1287,7 +1317,9 @@ extension ExperiencesPublisher { guard let self, self.previewSessionTracker.isCurrent(previewSessionId) else { return } // This session is the one about to be on screen, so it is the one its close owns. self.previewSessionTracker.markRendered(previewSessionId) - self.pendingExperiences.append(experience) + self.pendingExperiences.append( + PendingExperience(experienceContent: experience, triggerType: .preview) + ) self.themeHandler.saveTheme(theme) self.openExperienceFlow() } diff --git a/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift index 434185a5..78f04c45 100644 --- a/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift @@ -175,10 +175,10 @@ final class ExperienceStateMachineTests: XCTestCase { stateManager.markCachedManual("cached-exp") // Assert - if case .cachedPendingManual(let experienceId) = stateManager.getCurrentState() { - XCTAssertEqual(experienceId, "cached-exp") + if case .idle = stateManager.getCurrentState() { + // Caching does not replace the current experience's state. } else { - XCTFail("Expected cachedPendingManual state") + XCTFail("Caching must preserve the current state") } XCTAssertTrue(stateManager.hasCachedExperience()) XCTAssertEqual(stateManager.getCachedExperienceId(), "cached-exp") @@ -192,10 +192,10 @@ final class ExperienceStateMachineTests: XCTestCase { stateManager.markCachedAutomatic(content) // Assert - if case .cachedPendingAutomatic(let cachedContent) = stateManager.getCurrentState() { - XCTAssertEqual(cachedContent.experienceId(), content.experienceId()) + if case .idle = stateManager.getCurrentState() { + // Caching does not replace the current experience's state. } else { - XCTFail("Expected cachedPendingAutomatic state") + XCTFail("Caching must preserve the current state") } XCTAssertTrue(stateManager.hasCachedExperience()) XCTAssertEqual(stateManager.getCachedExperienceContent()?.experienceId(), content.experienceId()) @@ -221,6 +221,73 @@ final class ExperienceStateMachineTests: XCTestCase { XCTAssertNil(stateManager.getActiveComponent()) } + func testCachedManual_survivesAutomaticThemeDelayPresentationAndDismissal() { + let content = makeExperienceContent() + stateManager.markAutomaticTrigger(content) + stateManager.markCachedManual("manual-B") + + if case .pendingAutomatic = stateManager.getCurrentState() { + // A is still automatic while its theme is loading. + } else { + XCTFail("Caching B must not replace A's pending trigger") + } + XCTAssertFalse(stateManager.shouldBypassScreenValidation()) + + stateManager.markWaitingDelay(.automatic) + XCTAssertEqual(stateManager.getCachedExperienceId(), "manual-B") + stateManager.markActiveFromCurrentState(content: content) + XCTAssertEqual(stateManager.getActiveTriggerType(), .automatic) + XCTAssertEqual(stateManager.getCachedExperienceId(), "manual-B") + stateManager.markIdle() + XCTAssertEqual(stateManager.getCachedExperienceId(), "manual-B") + } + + func testCachedManual_doesNotReplaceActivePreviewOrThankYouState() { + let content = makeExperienceContent() + stateManager.markActive(.preview, content) + stateManager.markCachedManual("manual-B") + + XCTAssertTrue(stateManager.isPreviewMode()) + XCTAssertEqual(stateManager.getActiveContent()?.experienceId(), content.experienceId()) + stateManager.markShowingThankYou() + XCTAssertTrue(stateManager.isPreviewMode()) + XCTAssertEqual(stateManager.getCachedExperienceId(), "manual-B") + } + + func testCachedAutomatic_doesNotOverwriteQueuedManualRequest() { + stateManager.markWaitingDelay(.automatic) + stateManager.markCachedManual("manual-B") + stateManager.markCachedAutomatic(makeExperienceContent()) + + XCTAssertEqual(stateManager.getCachedExperienceId(), "manual-B") + XCTAssertNil(stateManager.getCachedExperienceContent()) + if case .waitingDelay(let trigger) = stateManager.getCurrentState() { + XCTAssertEqual(trigger, .automatic) + } else { + XCTFail("Caching content must preserve the active delay") + } + } + + func testProcessCachedExperience_takesManualRequestOnceWithoutChangingActiveContent() { + let content = makeExperienceContent() + stateManager.markActive(.automatic, content) + stateManager.markCachedManual("manual-B") + + if case .triggerManual(let id) = stateManager.processCachedExperience() { + XCTAssertEqual(id, "manual-B") + } else { + XCTFail("Expected cached manual B") + } + XCTAssertEqual(stateManager.getActiveTriggerType(), .automatic) + XCTAssertEqual(stateManager.getActiveContent()?.experienceId(), content.experienceId()) + if case .none = stateManager.processCachedExperience() { + // Duplicate dismissal cannot replay B twice. + } else { + XCTFail("Cached request must only be consumed once") + } + XCTAssertFalse(stateManager.hasCachedExperience()) + } + // MARK: - Component Management func testSetActiveComponent_storesWeakComponentReference() { diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index df81f422..6d0dbd74 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -141,12 +141,200 @@ final class ExperiencesPublisherTests: XCTestCase { // Act experiencesPublisher.publishInternalSDKEvent(closeEvent) + XCTAssertTrue(publishedExperienceIds.isEmpty, "B must wait until A has finished dismissing") + experiencesPublisher.experienceDidFinishDismissing() // Assert wait(for: [replayExpectation], timeout: 1.0) XCTAssertEqual(publishedExperienceIds, ["cached-experience-id"]) } + func testAutomaticExperience_keepsItsTriggerAndReplaysManualRequestAfterThemeAndDismissal() throws { + let themeRequested = expectation(description: "automatic A waits for its theme") + let manualReplayed = expectation(description: "manual B requested after dismissal") + var requestedManualIds: [String] = [] + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + if event is ThemeContentEvent { + themeRequested.fulfill() + } else if let event = event as? ExperienceContentEvent { + requestedManualIds.append(event.experienceId) + manualReplayed.fulfill() + } + } + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, nil, + Message(payload: MockContentFactory.makeFlowContentPayload()), true + ) + wait(for: [themeRequested], timeout: 1.0) + + experiencesPublisher.triggerExperience("manual-B") + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" + }, object: nil + ) + wait(for: [cached], timeout: 2.0) + XCTAssertFalse(userpilot.experienceStateMachine.isManualTrigger()) + XCTAssertTrue(requestedManualIds.isEmpty) + + userpilot.themeHandler.onGetThemeById = { _ in + ThemeData(carousel: nil, slideOut: nil, survey: nil) + } + experiencesPublisher.onSocketEventSent( + SDKEventsName.fetchExperienceTheme.rawValue, ["theme_id": 1], + Message(payload: ["id": 1, "theme_data": [:]]), true + ) + let automaticActive = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getActiveTriggerType() == .automatic + }, object: nil + ) + wait(for: [automaticActive], timeout: 3.0) + XCTAssertEqual(userpilot.experienceStateMachine.getCachedExperienceId(), "manual-B") + XCTAssertTrue(requestedManualIds.isEmpty) + // The real renderer consumes this entry through getActiveMobileContent(). + XCTAssertEqual(experiencesPublisher.getActiveMobileContent()?.experienceId(), 77) + + let close = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 77] + ) + close.isCloseEvent = true + experiencesPublisher.publishInternalSDKEvent(close) + XCTAssertTrue(requestedManualIds.isEmpty, "The close signal precedes actual UI dismissal") + experiencesPublisher.experienceDidFinishDismissing() + wait(for: [manualReplayed], timeout: 2.0) + + XCTAssertEqual(requestedManualIds, ["manual-B"]) + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + experiencesPublisher.logout() + } + + func testLogout_discardsManualRequestCachedBehindThemeLoading() { + let themeRequested = expectation(description: "automatic A waits for its theme") + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + if event is ThemeContentEvent { themeRequested.fulfill() } + } + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, nil, + Message(payload: MockContentFactory.makeFlowContentPayload()), true + ) + wait(for: [themeRequested], timeout: 1.0) + experiencesPublisher.triggerExperience("manual-B") + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" + }, object: nil + ) + wait(for: [cached], timeout: 2.0) + + experiencesPublisher.logout() + + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + XCTAssertNil(userpilot.experienceStateMachine.getCachedExperienceId()) + } + + func testCachedManualRequest_doesNotBypassAutomaticScreenTargetingAfterThemeLoads() throws { + experiencesPublisher.mockSetCurrentScreen(title: "Home") + var payload = MockContentFactory.makeFlowContentPayload() + var flow = try XCTUnwrap(payload["mobile_contents"] as? [String: Any]) + flow["screen_type"] = "selected" + flow["screens"] = ["OtherScreen"] + payload["mobile_contents"] = flow + let themeRequested = expectation(description: "automatic A waits for theme") + let manualReplayed = expectation(description: "manual B continues after A fails targeting") + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + if event is ThemeContentEvent { + themeRequested.fulfill() + } else if let event = event as? ExperienceContentEvent { + XCTAssertEqual(event.experienceId, "manual-B") + manualReplayed.fulfill() + } + } + experiencesPublisher.onSocketEventSent(EventType.screenEvent, nil, Message(payload: payload), true) + wait(for: [themeRequested], timeout: 1.0) + experiencesPublisher.triggerExperience("manual-B") + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" + }, object: nil + ) + wait(for: [cached], timeout: 2.0) + userpilot.themeHandler.onGetThemeById = { _ in + ThemeData(carousel: nil, slideOut: nil, survey: nil) + } + + experiencesPublisher.onSocketEventSent( + SDKEventsName.fetchExperienceTheme.rawValue, ["theme_id": 1], + Message(payload: ["id": 1, "theme_data": [:]]), true + ) + + wait(for: [manualReplayed], timeout: 3.0) + XCTAssertNil(userpilot.experienceStateMachine.getActiveContent(), "Automatic A must respect its screen target") + XCTAssertNil(experiencesPublisher.getActiveMobileContent()) + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + } + + func testCachedManualRequest_continuesAfterEmptyManualContentResponse() { + assertCachedManualContinuesAfterEmptyResponse(eventSent: true) + } + + func testCachedManualRequest_continuesAfterFailedManualContentResponse() { + assertCachedManualContinuesAfterEmptyResponse(eventSent: false) + } + + func testManualRequest_continuesWhenPreviousContentCannotFetchItsTheme() { + userpilot.analyticsPublisher.canRequestEvent = false + let manualRequested = expectation(description: "manual B is not blocked by abandoned theme preparation") + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + guard let event = event as? ExperienceContentEvent else { return } + XCTAssertEqual(event.experienceId, "manual-B") + manualRequested.fulfill() + } + + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, nil, + Message(payload: MockContentFactory.makeFlowContentPayload()), true + ) + experiencesPublisher.triggerExperience("manual-B") + + wait(for: [manualRequested], timeout: 2.0) + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + XCTAssertNil(experiencesPublisher.getActiveMobileContent()) + } + + private func assertCachedManualContinuesAfterEmptyResponse(eventSent: Bool) { + let firstRequested = expectation(description: "manual A requested") + let nextRequested = expectation(description: "manual B requested after A resolves without content") + var requestedIds: [String] = [] + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + guard let event = event as? ExperienceContentEvent else { return } + requestedIds.append(event.experienceId) + if event.experienceId == "manual-A" { + firstRequested.fulfill() + } else if event.experienceId == "manual-B" { + nextRequested.fulfill() + } + } + experiencesPublisher.triggerExperience("manual-A") + wait(for: [firstRequested], timeout: 1.0) + experiencesPublisher.triggerExperience("manual-B") + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" + }, object: nil + ) + wait(for: [cached], timeout: 2.0) + + experiencesPublisher.onSocketEventSent( + SDKEventsName.fetchExperienceContent.rawValue, nil, Message(payload: [:]), eventSent + ) + + wait(for: [nextRequested], timeout: 2.0) + XCTAssertEqual(requestedIds, ["manual-A", "manual-B"]) + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + } + // MARK: - endExperience Tests func testEndExperience_shouldTriggerCloseOnTopViewController() { @@ -559,6 +747,14 @@ final class ExperiencesPublisherTests: XCTestCase { // Act experiencesPublisher.publishInternalSDKEvent(closeEvent) + experiencesPublisher.experienceDidFinishDismissing() + let drained = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !self.userpilot.experienceStateMachine.hasCachedExperience() + }, object: nil + ) + wait(for: [drained], timeout: 2.0) + // Assert XCTAssertNil(userpilot.experienceStateMachine.getCachedExperienceContent()) XCTAssertNil(experiencesPublisher.getActiveMobileContent()) @@ -1246,6 +1442,59 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [repeatProcessed], timeout: 1.0) } + func testPreviewThankYouDismissal_releasesPreviewAndReplaysCachedManualRequest() throws { + let fetched = expectation(description: "preview requested") + let rendered = expectation(description: "preview session owns rendered content") + var completion: ((Result) -> Void)? + userpilot.remoteSource.onFetchPreviewExperience = { _, callback in + completion = callback + fetched.fulfill() + } + userpilot.themeHandler.onSaveTheme = { _ in rendered.fulfill() } + experiencesPublisher.triggerPreviewExperience("preview-A", []) + wait(for: [fetched], timeout: 1.0) + completion?(.success(try makePreviewExperience(themeId: 33))) + wait(for: [rendered], timeout: 1.0) + XCTAssertNotNil(experiencesPublisher.getActiveMobileContent()) + + let survey = MockContentFactory.makeSurveyContent() + userpilot.experienceStateMachine.markActive(.preview, .survey(content: survey)) + experiencesPublisher.showThankYouMessage(survey, MockContentFactory.makeSurveyTheme(), 1) + let thankYouPresented = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceOverlayWindow.rootViewController?.presentedViewController + is ThankYouBottomSheetViewController + }, object: nil + ) + wait(for: [thankYouPresented], timeout: 3.0) + let thankYou = try XCTUnwrap( + userpilot.experienceOverlayWindow.rootViewController?.presentedViewController + as? ThankYouBottomSheetViewController + ) + let manualRequested = expectation(description: "manual B starts after preview thank-you dismissal") + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + guard let event = event as? ExperienceContentEvent else { return } + XCTAssertEqual(event.experienceId, "manual-B") + manualRequested.fulfill() + } + experiencesPublisher.triggerExperience("manual-B") + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" + }, object: nil + ) + wait(for: [cached], timeout: 2.0) + XCTAssertTrue(experiencesPublisher.isPreviewExperienceMode()) + + // Exercise the production completion closure without depending on UIKit animation timing. + thankYou.onDismissCompleted() + + wait(for: [manualRequested], timeout: 2.0) + XCTAssertFalse(experiencesPublisher.isPreviewExperienceMode()) + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + thankYou.dismiss(animated: false) + } + private func makePreviewExperience(themeId: Int) throws -> PreviewExperience { let flow = try XCTUnwrap( MockContentFactory.makeFlowContentPayload() From bec2a78fa09d7261c346521d87cf4e7b60915c86 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 13:26:59 +0300 Subject: [PATCH 93/95] =?UTF-8?q?=E2=9C=85=20test(Utilities):=20Reject=20p?= =?UTF-8?q?review=20links=20from=20other=20environments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover unrelated tokens and staging schemes passed to a production instance so preview links retain the full-token environment boundary. --- .../Utilities/Link/DeepLinkHandlerTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift b/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift index 0c713751..2f6b5a5b 100644 --- a/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift +++ b/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift @@ -108,6 +108,18 @@ final class DeepLinkHandlerTests: XCTestCase { XCTAssertNil(DeepLinkHandler.Action(url: url, token: "STG-NX-12345")) } + func testAction_stagingToken_rejectsUnrelatedToken() { + for scheme in ["userpilot-nx-99999", "userpilot-stg-nx-99999"] { + let url = URL(string: "\(scheme)://sdk/experience_preview/EX-123")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "STG-NX-12345")) + } + } + + func testAction_productionToken_rejectsStagingScheme() { + let url = URL(string: "userpilot-stg-nx-12345://sdk/experience_preview/EX-123")! + XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) + } + func testAction_caseInsensitiveScheme_createsAction() { let url = URL(string: "USERPILOT-NX-12345://sdk/experience_preview/EX-123")! XCTAssertNotNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) From c5323405b06b92afa87c8a22ad340bd7b9f9f548 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 15:05:05 +0300 Subject: [PATCH 94/95] =?UTF-8?q?=E2=9C=85=20test(Experiences):=20Stabiliz?= =?UTF-8?q?e=20pending=20experience=20gate=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control the display delay and stub UIKit presentation in the pending experience tests so CI timing does not decide their outcome. Keep assertions for automatic targeting, trigger ownership, and manual request replay after dismissal; expose the delay setter only in DEBUG. --- .../Experiences/ExperiencesPublisher.swift | 5 + .../ExperiencesPublisherTests.swift | 118 +++++++++++++++--- 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift index 87b9564b..1e4f0d04 100644 --- a/Sources/Userpilot/Experiences/ExperiencesPublisher.swift +++ b/Sources/Userpilot/Experiences/ExperiencesPublisher.swift @@ -1363,6 +1363,11 @@ extension ExperiencesPublisher { #if DEBUG extension ExperiencesPublisher { + /// Set before delivering events so tests can control when the display delay finishes. + func mockSetDelayUtils(_ delayUtils: DelayUtils) { + self.delayUtils = delayUtils + } + func mockSetCurrentScreen(title: String) { currentScreen = title } diff --git a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift index 6d0dbd74..1835f914 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -150,14 +150,23 @@ final class ExperiencesPublisherTests: XCTestCase { } func testAutomaticExperience_keepsItsTriggerAndReplaysManualRequestAfterThemeAndDismissal() throws { + let displayScheduled = expectation(description: "automatic A reaches its display delay") + let automaticPresented = expectation(description: "automatic A is presented") + let displayDelay = MockExperienceDisplayDelay { displayScheduled.fulfill() } + let publisher = MockPresentingExperiencesPublisher(container: userpilot.container) + experiencesPublisher = publisher + publisher.mockSetDelayUtils(displayDelay) + publisher.presentationHost.onPresent = { automaticPresented.fulfill() } + defer { publisher.logout() } + let themeRequested = expectation(description: "automatic A waits for its theme") let manualReplayed = expectation(description: "manual B requested after dismissal") - var requestedManualIds: [String] = [] + let requestedManualIds = AtomicReference<[String]>([]) userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in if event is ThemeContentEvent { themeRequested.fulfill() } else if let event = event as? ExperienceContentEvent { - requestedManualIds.append(event.experienceId) + requestedManualIds.update { $0 + [event.experienceId] } manualReplayed.fulfill() } } @@ -165,7 +174,7 @@ final class ExperiencesPublisherTests: XCTestCase { EventType.screenEvent, nil, Message(payload: MockContentFactory.makeFlowContentPayload()), true ) - wait(for: [themeRequested], timeout: 1.0) + wait(for: [themeRequested], timeout: 5.0) experiencesPublisher.triggerExperience("manual-B") let cached = XCTNSPredicateExpectation( @@ -173,9 +182,9 @@ final class ExperiencesPublisherTests: XCTestCase { self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" }, object: nil ) - wait(for: [cached], timeout: 2.0) + wait(for: [cached], timeout: 5.0) XCTAssertFalse(userpilot.experienceStateMachine.isManualTrigger()) - XCTAssertTrue(requestedManualIds.isEmpty) + XCTAssertTrue(requestedManualIds.value.isEmpty) userpilot.themeHandler.onGetThemeById = { _ in ThemeData(carousel: nil, slideOut: nil, survey: nil) @@ -184,14 +193,19 @@ final class ExperiencesPublisherTests: XCTestCase { SDKEventsName.fetchExperienceTheme.rawValue, ["theme_id": 1], Message(payload: ["id": 1, "theme_data": [:]]), true ) - let automaticActive = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in - self.userpilot.experienceStateMachine.getActiveTriggerType() == .automatic - }, object: nil - ) - wait(for: [automaticActive], timeout: 3.0) + wait(for: [displayScheduled], timeout: 5.0) + guard case .waitingDelay(.automatic) = userpilot.experienceStateMachine.getCurrentState() else { + XCTFail("Automatic A must keep its trigger while waiting for the display delay") + return + } + XCTAssertEqual(userpilot.experienceStateMachine.getCachedExperienceId(), "manual-B") + XCTAssertTrue(requestedManualIds.value.isEmpty) + + displayDelay.fire() + wait(for: [automaticPresented], timeout: 5.0) + XCTAssertEqual(userpilot.experienceStateMachine.getActiveTriggerType(), .automatic) XCTAssertEqual(userpilot.experienceStateMachine.getCachedExperienceId(), "manual-B") - XCTAssertTrue(requestedManualIds.isEmpty) + XCTAssertTrue(requestedManualIds.value.isEmpty) // The real renderer consumes this entry through getActiveMobileContent(). XCTAssertEqual(experiencesPublisher.getActiveMobileContent()?.experienceId(), 77) @@ -201,13 +215,13 @@ final class ExperiencesPublisherTests: XCTestCase { ) close.isCloseEvent = true experiencesPublisher.publishInternalSDKEvent(close) - XCTAssertTrue(requestedManualIds.isEmpty, "The close signal precedes actual UI dismissal") + XCTAssertTrue(requestedManualIds.value.isEmpty, "The close signal precedes actual UI dismissal") + publisher.presentationHost.presentedExperience = nil experiencesPublisher.experienceDidFinishDismissing() - wait(for: [manualReplayed], timeout: 2.0) + wait(for: [manualReplayed], timeout: 5.0) - XCTAssertEqual(requestedManualIds, ["manual-B"]) + XCTAssertEqual(requestedManualIds.value, ["manual-B"]) XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) - experiencesPublisher.logout() } func testLogout_discardsManualRequestCachedBehindThemeLoading() { @@ -235,6 +249,14 @@ final class ExperiencesPublisherTests: XCTestCase { } func testCachedManualRequest_doesNotBypassAutomaticScreenTargetingAfterThemeLoads() throws { + let displayScheduled = expectation(description: "automatic A reaches its display delay") + let displayDelay = MockExperienceDisplayDelay { displayScheduled.fulfill() } + let publisher = MockPresentingExperiencesPublisher(container: userpilot.container) + experiencesPublisher = publisher + publisher.mockSetDelayUtils(displayDelay) + publisher.presentationHost.onPresent = { XCTFail("Automatic A must respect its screen target") } + defer { publisher.logout() } + experiencesPublisher.mockSetCurrentScreen(title: "Home") var payload = MockContentFactory.makeFlowContentPayload() var flow = try XCTUnwrap(payload["mobile_contents"] as? [String: Any]) @@ -252,14 +274,14 @@ final class ExperiencesPublisherTests: XCTestCase { } } experiencesPublisher.onSocketEventSent(EventType.screenEvent, nil, Message(payload: payload), true) - wait(for: [themeRequested], timeout: 1.0) + wait(for: [themeRequested], timeout: 5.0) experiencesPublisher.triggerExperience("manual-B") let cached = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" }, object: nil ) - wait(for: [cached], timeout: 2.0) + wait(for: [cached], timeout: 5.0) userpilot.themeHandler.onGetThemeById = { _ in ThemeData(carousel: nil, slideOut: nil, survey: nil) } @@ -269,8 +291,17 @@ final class ExperiencesPublisherTests: XCTestCase { Message(payload: ["id": 1, "theme_data": [:]]), true ) - wait(for: [manualReplayed], timeout: 3.0) + wait(for: [displayScheduled], timeout: 5.0) + guard case .waitingDelay(.automatic) = userpilot.experienceStateMachine.getCurrentState() else { + XCTFail("Caching manual B must not change automatic A's trigger") + return + } + XCTAssertEqual(userpilot.experienceStateMachine.getCachedExperienceId(), "manual-B") + + displayDelay.fire() + wait(for: [manualReplayed], timeout: 5.0) XCTAssertNil(userpilot.experienceStateMachine.getActiveContent(), "Automatic A must respect its screen target") + XCTAssertNil(publisher.presentationHost.presentedExperience) XCTAssertNil(experiencesPublisher.getActiveMobileContent()) XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) } @@ -1514,4 +1545,53 @@ final class ExperiencesPublisherTests: XCTestCase { } } +/// Exercises the publisher's real presentation path without creating a UIKit window or animations. +private final class MockPresentingExperiencesPublisher: ExperiencesPublisher { + let presentationHost = MockExperiencePresentationHost() + + override func experiencePresentationHost() -> UIViewController? { + presentationHost + } +} + +private final class MockExperiencePresentationHost: UIViewController { + var presentedExperience: UIViewController? + var onPresent: (() -> Void)? + + override func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) { + presentedExperience = viewControllerToPresent + onPresent?() + completion?() + } +} + +/// Scheduling occurs on the experience queue; tests explicitly fire the captured action on main. +private final class MockExperienceDisplayDelay: DelayUtils { + private let pendingAction = AtomicReference<(() -> Void)?>(nil) + private let onSchedule: () -> Void + + init(onSchedule: @escaping () -> Void) { + self.onSchedule = onSchedule + super.init() + } + + override func delayAction(delayTime: TimeInterval, action: @escaping () -> Void) { + pendingAction.value = action + onSchedule() + } + + override func cancelDelay() { + pendingAction.value = nil + } + + func fire(file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(Thread.isMainThread, file: file, line: line) + guard let action = pendingAction.getAndSet(nil) else { + XCTFail("Expected a scheduled display action", file: file, line: line) + return + } + action() + } +} + // swiftlint:disable all From 13240762474de8bbf631f3ecf0a096580fedbd8f Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 27 Sep 2026 23:17:33 +0300 Subject: [PATCH 95/95] =?UTF-8?q?=F0=9F=90=9B=20fix(AutoCapture):=20Disabl?= =?UTF-8?q?e=20interaction=20value=20capture=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match Android by keeping control value capture opt-in. Update the default-value test and integration documentation. Explicit enableInteractionValueCapture() calls still enable capture. --- README.md | 2 ++ Sources/Userpilot/Userpilot+Config.swift | 6 +++--- Sources/Userpilot/Userpilot.docc/Autocapture.md | 2 +- Tests/UserpilotTests/Config/UserpilotConfigTests.swift | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d0989f51..56f6302d 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ userpilot.triggerExperience("") If you have additional configuration needs, you can pass a custom configuration when initializing Userpilot. You can enable logging, provide navigation and experience delegates, and set up analytics listeners. +Interaction value capture is disabled by default. Call `.enableInteractionValueCapture(true)` on the configuration to capture control values when interaction autocapture is enabled. + ```swift userpilot = Userpilot( config: Userpilot.Config(token: "APP_TOKEN") diff --git a/Sources/Userpilot/Userpilot+Config.swift b/Sources/Userpilot/Userpilot+Config.swift index b4eb77c3..0b411f7f 100644 --- a/Sources/Userpilot/Userpilot+Config.swift +++ b/Sources/Userpilot/Userpilot+Config.swift @@ -84,7 +84,7 @@ extension Userpilot { // swiftlint:disable:next identifier_name var enableInteractionAccessibilityLabelCapture: Bool = true - /// Whether or not to enable capturing control values. Defaults to true. + /// Whether or not to enable capturing control values. Defaults to false. /// If set to true, the SDK will capture values from controls such as: /// - UISwitch: captures the on/off state /// - UISegmentedControl: captures the selected segment value @@ -92,7 +92,7 @@ extension Userpilot { /// - UIStepper: captures the selected step value /// - UIPickerView: captures the selected value /// - UIDatePicker: captures the selected date/time value - var enableInteractionValueCapture: Bool = true + var enableInteractionValueCapture: Bool = false /// When true (default), a tap event is not sent for UITextField/UITextView when the action is /// a text-editing action (e.g. textChanged:, editingChanged:). Only the text_field_changed / @@ -318,7 +318,7 @@ extension Userpilot { } /// Enables or disables capturing control values during interaction tracking. - /// When enabled (default), the SDK will capture values from controls such as: + /// Disabled by default. When enabled, the SDK will capture values from controls such as: /// - UISwitch: on/off state /// - UISegmentedControl: selected segment value /// - UISlider: selected value diff --git a/Sources/Userpilot/Userpilot.docc/Autocapture.md b/Sources/Userpilot/Userpilot.docc/Autocapture.md index 28d27291..3a4f93f8 100644 --- a/Sources/Userpilot/Userpilot.docc/Autocapture.md +++ b/Sources/Userpilot/Userpilot.docc/Autocapture.md @@ -90,7 +90,7 @@ Configure global capture and privacy via ``Userpilot/Config``. | ``enableInteractionAutoCapture`` | ``enableInteractionAutoCapture(_:)`` | Turn automatic interaction capture on or off. | `false` | | ``enableInteractionTextCapture`` | ``enableInteractionTextCapture(_:)`` | When enabled, user-visible text (labels, button titles, etc.) may be captured. Set to `false` to disable globally. | `true` | | ``enableInteractionAccessibilityLabelCapture`` | ``enableInteractionAccessibilityLabelCapture(_:)`` | When enabled, accessibility labels may be captured. Set to `false` to disable globally. Use with `enableInteractionTextCapture(false)` when accessibility may mirror on-screen text. | `true` | -| ``enableInteractionValueCapture`` | ``enableInteractionValueCapture(_:)`` | When enabled, captures values from controls like switches, sliders, and pickers. | `true` | +| ``enableInteractionValueCapture`` | ``enableInteractionValueCapture(_:)`` | When enabled, captures values from controls like switches, sliders, and pickers. | `false` | | ``ignoreTapForTextInputEditingActions`` | ``ignoreTapForTextInputEditingActions(_:)`` | When true, prevents duplicate events by not sending tap events for text input editing actions. | `true` | | ``preferUIKitOverSwiftUIForNavigationBar`` | ``preferUIKitOverSwiftUIForNavigationBar(_:)`` | When true, prefers UIKit events over SwiftUI for navigation bar interactions to avoid duplicates. | `true` | diff --git a/Tests/UserpilotTests/Config/UserpilotConfigTests.swift b/Tests/UserpilotTests/Config/UserpilotConfigTests.swift index 5ef5746c..664618f8 100644 --- a/Tests/UserpilotTests/Config/UserpilotConfigTests.swift +++ b/Tests/UserpilotTests/Config/UserpilotConfigTests.swift @@ -24,7 +24,7 @@ final class UserpilotConfigTests: XCTestCase { XCTAssertFalse(config.enableInteractionAutoCapture) XCTAssertTrue(config.enableInteractionTextCapture) XCTAssertTrue(config.enableInteractionAccessibilityLabelCapture) - XCTAssertTrue(config.enableInteractionValueCapture) + XCTAssertFalse(config.enableInteractionValueCapture) XCTAssertTrue(config.ignoreTapForTextInputEditingActions) XCTAssertTrue(config.preferUIKitOverSwiftUIForNavigationBar) XCTAssertTrue(config.isDefault)