diff --git a/.github/workflows/ci.yml b/.github/workflows/quality-gate.yml similarity index 98% rename from .github/workflows/ci.yml rename to .github/workflows/quality-gate.yml index 2b1f64ab..49e4ca3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/quality-gate.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: diff --git a/.gitignore b/.gitignore index 54ec2867..659e524d 100644 --- a/.gitignore +++ b/.gitignore @@ -63,7 +63,8 @@ fastlane/test_output # Mac OS X *.DS_Store -# AI agent tooling (Cursor / Claude / local agent memory) +# 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/ @@ -72,3 +73,8 @@ 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/ diff --git a/README.md b/README.md index dcac6cf7..56f6302d 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). @@ -187,11 +191,13 @@ 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") - .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). ) userpilot.navigationDelegate = self @@ -210,7 +216,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 @@ -289,17 +306,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/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj index a01993bd..3d634658 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj +++ b/Sample/UserpilotSwiftSample/UserpilotSample.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - AB1942A92CD25CD700924844 /* MainViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = AB1942A82CD25CD700924844 /* MainViewController.xib */; }; AB1942AC2CD25DFE00924844 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AB1942AB2CD25DFE00924844 /* Localizable.xcstrings */; }; AB1942AF2CD25FD200924844 /* ScreenTwoViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = AB1942AE2CD25FD200924844 /* ScreenTwoViewController.xib */; }; AB1942B32CD2609700924844 /* ScreenOneViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = AB1942B22CD2609700924844 /* ScreenOneViewController.xib */; }; @@ -21,8 +20,6 @@ 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 */; }; AB5ED5932D0E07D5002C815A /* PaddingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5ED5922D0E07CD002C815A /* PaddingLabel.swift */; }; AB6ABAA62CE88B9300FB72A8 /* SDKEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6ABAA52CE88B9300FB72A8 /* SDKEventsViewController.swift */; }; @@ -61,7 +58,6 @@ 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 */; }; ABED3F622C98051500004D1C /* UIStackView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABED3F612C98051500004D1C /* UIStackView+Extensions.swift */; }; - ABED3F652C9831BD00004D1C /* MainViewController+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABED3F642C9831BD00004D1C /* MainViewController+TableView.swift */; }; ABED3F692C985E2000004D1C /* CustomEventViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABED3F682C985E2000004D1C /* CustomEventViewController.swift */; }; ABEF6D3A2CB3E34800DAB48E /* Merriweather-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = ABEF6D392CB3E34400DAB48E /* Merriweather-Bold.ttf */; }; ABF38C7C2C73688000013F18 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF38C7B2C73688000013F18 /* MainViewController.swift */; }; @@ -78,11 +74,17 @@ 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 */ - AB1942A72CD25CD700924844 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainViewController.xib; sourceTree = ""; }; - AB1942AA2CD25CD700924844 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/MainViewController.xcstrings; sourceTree = ""; }; AB1942AB2CD25DFE00924844 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; AB1942AD2CD25FD200924844 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/ScreenTwoViewController.xib; sourceTree = ""; }; AB1942B02CD25FD200924844 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/ScreenTwoViewController.xcstrings; sourceTree = ""; }; @@ -102,8 +104,6 @@ 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 = ""; }; AB585F672CD79EF400E2A66D /* Dev.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Dev.xcconfig; sourceTree = ""; }; AB5ED5922D0E07CD002C815A /* PaddingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingLabel.swift; sourceTree = ""; }; @@ -158,7 +158,6 @@ ABED3F5D2C9803D000004D1C /* IdentifyViewController+UserProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IdentifyViewController+UserProperties.swift"; sourceTree = ""; }; ABED3F5F2C9803DE00004D1C /* IdentifyViewController+CompanyProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IdentifyViewController+CompanyProperties.swift"; sourceTree = ""; }; ABED3F612C98051500004D1C /* UIStackView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIStackView+Extensions.swift"; sourceTree = ""; }; - ABED3F642C9831BD00004D1C /* MainViewController+TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+TableView.swift"; sourceTree = ""; }; ABED3F682C985E2000004D1C /* CustomEventViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventViewController.swift; sourceTree = ""; }; ABEF6D392CB3E34400DAB48E /* Merriweather-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Merriweather-Bold.ttf"; sourceTree = ""; }; ABF38C7B2C73688000013F18 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; @@ -176,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 */ @@ -206,15 +213,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 +256,8 @@ AB6DBD352C68CD3B0009BB11 /* Featues */ = { isa = PBXGroup; children = ( + FACF000000000000000000C1 /* Config */, + UP08E667051DEB4096A385C2 /* Debug */, ABEB31742F73F58400E1789D /* AutoCapture */, AB6ABAA42CE8890700FB72A8 /* SDKEvents */, ABCDBF3E2CAEAB250083DF1D /* DeepLink */, @@ -290,10 +290,8 @@ AB6DBD402C68D2180009BB11 /* Main */ = { isa = PBXGroup; children = ( - ABED3F632C9831AC00004D1C /* Extensions */, ABF38C7F2C73713100013F18 /* Models */, ABF38C7B2C73688000013F18 /* MainViewController.swift */, - AB1942A82CD25CD700924844 /* MainViewController.xib */, ); path = Main; sourceTree = ""; @@ -305,6 +303,7 @@ ABACA8592C96D591006C9B77 /* PropertyView */, ABFB5FE32C9173D60054E142 /* CardView.swift */, ABFB5FE52C91743B0054E142 /* OutlineButton.swift */, + UPA91C2E4B7D6E8F0A1B2C02 /* SampleAppearance.swift */, ); path = CustomViews; sourceTree = ""; @@ -356,7 +355,6 @@ ABACA85E2C96E0C2006C9B77 /* Dialogs */ = { isa = PBXGroup; children = ( - AB585F602CD79C1100E2A66D /* Configurations */, AB585F5F2CD79C0400E2A66D /* Properties */, ); path = Dialogs; @@ -424,6 +422,7 @@ ABEB31742F73F58400E1789D /* AutoCapture */ = { isa = PBXGroup; children = ( + UPDFBEEB3C1F6B418793B6DE /* AutoCaptureHubViewController.swift */, ABAD7ABF2F7BE8C6003BC33A /* AutoCaptureTabsTestViewController.swift */, ABCD472F2F793CB000D71EA8 /* ScreenAPIContainerChildViewController.swift */, ABCD472D2F793C7600D71EA8 /* IgnoredScreenDemoViewController.swift */, @@ -446,14 +445,6 @@ path = Extensions; sourceTree = ""; }; - ABED3F632C9831AC00004D1C /* Extensions */ = { - isa = PBXGroup; - children = ( - ABED3F642C9831BD00004D1C /* MainViewController+TableView.swift */, - ); - path = Extensions; - sourceTree = ""; - }; ABED3F662C985DFA00004D1C /* CustomEvent */ = { isa = PBXGroup; children = ( @@ -567,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 */ @@ -624,7 +667,7 @@ ); mainGroup = C5C897AC2C5CFFFA0083928E; packageReferences = ( - AB25648F2DB4F4AB00393126 /* XCLocalSwiftPackageReference "../../../ios-sdk" */, + AB68595F30594DA300EAEF1A /* XCLocalSwiftPackageReference "../../../ios-sdk copy" */, ); productRefGroup = C5C897B62C5CFFFA0083928E /* Products */; projectDirPath = ""; @@ -644,7 +687,6 @@ 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 */, AB1942AF2CD25FD200924844 /* ScreenTwoViewController.xib in Resources */, AB1942C32CD261F100924844 /* CustomEventViewController.xib in Resources */, @@ -652,7 +694,6 @@ AB1942C72CD2624000924844 /* EventsViewController.xib in Resources */, ABC0B5162CB3CBB200139174 /* Qwitcher-Grypen-Regular.ttf in Resources */, ABAC8E6A2CE9CF21005EEC9D /* SDKEventsViewController.xib in Resources */, - AB1942A92CD25CD700924844 /* MainViewController.xib in Resources */, ABC0B5152CB3CBB200139174 /* Merriweather-Regular.ttf in Resources */, ABADA1AD2CB3FB5800C6CC61 /* Merriweather-BoldItalic.ttf in Resources */, AB1942BF2CD2615800924844 /* AddPropertyDialogViewController.xib in Resources */, @@ -694,6 +735,7 @@ files = ( AB6DBD2F2C68BF510009BB11 /* IdentifyViewController.swift in Sources */, ABFB5FE62C91743B0054E142 /* OutlineButton.swift in Sources */, + UPA91C2E4B7D6E8F0A1B2C01 /* SampleAppearance.swift in Sources */, ABCDBF462CAEACED0083DF1D /* NibLoadable.swift in Sources */, C5C897B92C5CFFFA0083928E /* AppDelegate.swift in Sources */, ABCD472C2F793C5300D71EA8 /* DemoBackButtonViewController.swift in Sources */, @@ -703,7 +745,12 @@ 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 */, + UP9B74448B7CF24C7EBF611D /* DebugViewController.swift in Sources */, + UPDE5091EFDF0F402DB75A79 /* OnlineQueueViewController.swift in Sources */, + UP2547E6FA14E34C4C8FE2F8 /* OfflineEventsViewController.swift in Sources */, ABCD472E2F793C7600D71EA8 /* IgnoredScreenDemoViewController.swift in Sources */, ABAD7AC02F7BE8C6003BC33A /* AutoCaptureTabsTestViewController.swift in Sources */, ABF38C872C73889100013F18 /* ScreenOneViewController.swift in Sources */, @@ -723,7 +770,6 @@ ABACA8602C96E0DD006C9B77 /* AddPropertyDialogViewController.swift in Sources */, AB5ED5932D0E07D5002C815A /* PaddingLabel.swift in Sources */, 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 */, @@ -733,6 +779,7 @@ ABEB31762F73F58400E1789D /* TableViewTestViewController.swift in Sources */, ABEB31772F73F58400E1789D /* CollectionViewTestViewController.swift in Sources */, ABEB31782F73F58400E1789D /* AutoCaptureTestViewController.swift in Sources */, + UP29C06D0153AF4F2ABE3C29 /* AutoCaptureHubViewController.swift in Sources */, ABEB31792F73F58400E1789D /* PickerViewTestViewController.swift in Sources */, AB4721352DBA1E8800B92EE2 /* AppDelegate+PushNotification.swift in Sources */, ); @@ -741,15 +788,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - AB1942A82CD25CD700924844 /* MainViewController.xib */ = { - isa = PBXVariantGroup; - children = ( - AB1942A72CD25CD700924844 /* Base */, - AB1942AA2CD25CD700924844 /* mul */, - ); - name = MainViewController.xib; - sourceTree = ""; - }; AB1942AE2CD25FD200924844 /* ScreenTwoViewController.xib */ = { isa = PBXVariantGroup; children = ( @@ -1070,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 */ diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/CardView.swift b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/CardView.swift index 9beb1f32..d005ddf8 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/CardView.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/CardView.swift @@ -21,21 +21,11 @@ class CardView: UIView { } private func setupCardView() { - // Set corner radius - self.layer.cornerRadius = 10.0 - self.layer.masksToBounds = false - - // Set shadow properties - self.layer.shadowColor = UIColor.black.cgColor - self.layer.shadowOffset = CGSize(width: 0, height: 2) - self.layer.shadowOpacity = 0.2 - self.layer.shadowRadius = 4.0 - - // Optionally, add a border - self.layer.borderColor = UIColor.systemGray6.cgColor - self.layer.borderWidth = 0.5 - - // Set the background color of the card - self.backgroundColor = UIColor.white + layer.cornerRadius = SampleAppearance.contentCardCornerRadius + layer.cornerCurve = .continuous + layer.masksToBounds = true + layer.shadowOpacity = 0 + layer.borderWidth = 0 + backgroundColor = SampleAppearance.elevatedBackground } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/OutlineButton.swift b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/OutlineButton.swift index edf47ac2..8226362a 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/OutlineButton.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/OutlineButton.swift @@ -12,18 +12,15 @@ class OutlineButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) - setupButton() + applyLiquidGlassStyle(.regular) } required init?(coder: NSCoder) { super.init(coder: coder) - setupButton() } - private func setupButton() { - self.layer.cornerRadius = 10 - self.layer.masksToBounds = true - self.layer.borderWidth = 1.0 - self.layer.borderColor = UIColor(named: "AccentColor")?.cgColor + override func awakeFromNib() { + super.awakeFromNib() + applyLiquidGlassStyle(.regular) } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/PropertyView/PropertyView.xib b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/PropertyView/PropertyView.xib index 0f7e4424..12b03650 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/PropertyView/PropertyView.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/PropertyView/PropertyView.xib @@ -63,7 +63,7 @@ - + @@ -85,7 +85,7 @@ - + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/SampleAppearance.swift b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/SampleAppearance.swift new file mode 100644 index 00000000..8a1b0da5 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/CustomViews/SampleAppearance.swift @@ -0,0 +1,152 @@ +// +// SampleAppearance.swift +// UserpilotSample +// + +import UIKit + +enum SampleAppearance { + static var screenBackground: UIColor { .systemGroupedBackground } + static var elevatedBackground: UIColor { .secondarySystemGroupedBackground } + static var accentColor: UIColor { UIColor(named: "AccentColor") ?? .systemBlue } + static let buttonHeight: CGFloat = 52 + static let contentCardCornerRadius: CGFloat = 16 + static let contentCardInset: CGFloat = 16 +} + +enum LiquidGlassButtonStyle { + case prominent + case regular +} + +extension UIButton { + + func applyLiquidGlassStyle( + _ style: LiquidGlassButtonStyle = .prominent, + title: String? = nil, + tintColor: UIColor? = SampleAppearance.accentColor, + unifiedHeight: Bool = true + ) { + let resolvedTitle = title ?? configuration?.title ?? self.title(for: .normal) + let resolvedImage = configuration?.image ?? image(for: .normal) + + backgroundColor = nil + layer.borderWidth = 0 + layer.borderColor = nil + layer.shadowOpacity = 0 + clipsToBounds = false + + var config: UIButton.Configuration + if #available(iOS 26.0, *) { + config = style == .prominent ? .prominentGlass() : .glass() + } else if style == .prominent { + config = .filled() + config.baseBackgroundColor = tintColor + config.baseForegroundColor = .white + } else { + config = .bordered() + config.baseForegroundColor = tintColor + config.background.strokeColor = tintColor + config.background.strokeWidth = 1 + } + + if let resolvedTitle { + config.title = resolvedTitle + } + if let resolvedImage { + config.image = resolvedImage + } + config.titleAlignment = .center + config.titleLineBreakMode = .byWordWrapping + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 18, bottom: 14, trailing: 18) + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 16, weight: .semibold) + return outgoing + } + + configuration = config + if let tintColor { + self.tintColor = tintColor + } + if unifiedHeight { + pinToStandardButtonHeight() + } + } + + func pinToStandardButtonHeight() { + let target = SampleAppearance.buttonHeight + constraints + .filter { $0.firstAttribute == .height && $0.firstItem as? UIView == self } + .forEach { $0.constant = target } + + if !constraints.contains(where: { $0.firstAttribute == .height && $0.firstItem as? UIView == self }) { + heightAnchor.constraint(equalToConstant: target).isActive = true + } + } +} + +final class LiquidGlassButton: UIButton { + + @IBInspectable var isProminent: Bool = true + + override init(frame: CGRect) { + super.init(frame: frame) + applyLiquidGlassStyle(.prominent) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func awakeFromNib() { + super.awakeFromNib() + applyLiquidGlassStyle(isProminent ? .prominent : .regular) + } +} + +extension UIViewController { + + /// Wraps the first `UIStackView` inside the screen's primary `UIScrollView` + /// in a rounded white content card on the grouped background. + func wrapPrimaryScrollContentInCard() { + guard + let scrollView = view.subviews.compactMap({ $0 as? UIScrollView }).first, + let content = scrollView.subviews.first(where: { $0 is UIStackView }) + else { return } + + let conflicting = (scrollView.constraints + view.constraints).filter { constraint in + let first = constraint.firstItem as? UIView + let second = constraint.secondItem as? UIView + return first == content || second == content + } + NSLayoutConstraint.deactivate(conflicting) + + let card = CardView() + card.translatesAutoresizingMaskIntoConstraints = false + content.removeFromSuperview() + scrollView.addSubview(card) + card.addSubview(content) + content.translatesAutoresizingMaskIntoConstraints = false + + let inset = SampleAppearance.contentCardInset + NSLayoutConstraint.activate([ + card.topAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.topAnchor, constant: inset), + card.leadingAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.leadingAnchor, constant: inset), + card.trailingAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.trailingAnchor, constant: -inset), + card.bottomAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -8), + card.widthAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -(inset * 2)), + + content.topAnchor.constraint(equalTo: card.topAnchor, constant: 8), + content.leadingAnchor.constraint(equalTo: card.leadingAnchor), + content.trailingAnchor.constraint(equalTo: card.trailingAnchor), + content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -8) + ]) + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift deleted file mode 100644 index 43458d16..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Configurations/ConfigurationsDialogViewController.swift +++ /dev/null @@ -1,68 +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() - self.doneButtonHandler?() - return - } - - executeDoneButtonHandler() - - self.dismiss(animated: true, completion: { - self.doneButtonHandler?() - }) - } - - func executeDoneButtonHandler() { - if self.doneButtonHandler != nil, - let appToken = textFieldAppToken.text, !appToken.isEmpty { - StorageManager.shared.set(value: appToken, forKey: StorageManager.Keys.appToken) - } - } - -} 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/Dialogs/Properties/Base.lproj/AddPropertyDialogViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Properties/Base.lproj/AddPropertyDialogViewController.xib index d3f10c19..8163dcf0 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Properties/Base.lproj/AddPropertyDialogViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Dialogs/Properties/Base.lproj/AddPropertyDialogViewController.xib @@ -5,6 +5,7 @@ + @@ -51,47 +52,32 @@ - + - - - + - + @@ -144,5 +130,8 @@ + + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureHubViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureHubViewController.swift new file mode 100644 index 00000000..a4c81edd --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureHubViewController.swift @@ -0,0 +1,115 @@ +// +// AutoCaptureHubViewController.swift +// UserpilotSample +// +// Hub listing all Auto Capture test screens. +// + +import UIKit + +final class AutoCaptureHubViewController: BaseViewController { + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let items: [AutoCaptureContent] = [ + .controls, + .pickerView, + .tableView, + .collectionView, + .textConfig, + .tabs, + .ignoredScreen + ] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Auto Capture" + setupTable() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + UserpilotManager.shared.screen("auto capture") + } + + 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) + ]) + } + + static func newInstance() -> AutoCaptureHubViewController { + AutoCaptureHubViewController() + } +} + +extension AutoCaptureHubViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count + } + + 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 .controls: + FlowRoutingManager.shared.openViewController(AutoCaptureTestViewController()) + case .pickerView: + FlowRoutingManager.shared.openViewController(PickerViewTestViewController()) + case .tableView: + FlowRoutingManager.shared.openViewController(TableViewTestViewController()) + case .collectionView: + FlowRoutingManager.shared.openViewController(CollectionViewTestViewController()) + case .textConfig: + FlowRoutingManager.shared.openViewController(TextConfigDemoViewController()) + case .tabs: + FlowRoutingManager.shared.openViewController(AutoCaptureTabsTestViewController()) + case .ignoredScreen: + FlowRoutingManager.shared.openViewController(IgnoredScreenDemoViewController()) + } + } +} + +enum AutoCaptureContent { + case controls + case pickerView + case tableView + case collectionView + case textConfig + case tabs + case ignoredScreen + + var title: String { + switch self { + case .controls: + return "Controls test" + case .pickerView: + return "UIPickerView test" + case .tableView: + return "TableView test" + case .collectionView: + return "CollectionView test" + case .textConfig: + return "Text config demo" + case .tabs: + return "Tabs test" + case .ignoredScreen: + return "Ignored screen demo" + } + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTabsTestViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTabsTestViewController.swift index 10bd9fea..d9d27b7e 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTabsTestViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTabsTestViewController.swift @@ -14,7 +14,7 @@ final class AutoCaptureTabsTestViewController: UITabBarController { override func viewDidLoad() { super.viewDidLoad() title = "Tabs Test" - view.backgroundColor = .systemBackground + view.backgroundColor = SampleAppearance.screenBackground setupTabs() } @@ -65,7 +65,7 @@ private final class AutoCaptureTabContentViewController: UIViewController { } private func setupUI() { - view.backgroundColor = .systemBackground + view.backgroundColor = SampleAppearance.screenBackground let titleLabel = UILabel() titleLabel.text = "\(tabTitle) Tab" @@ -82,12 +82,9 @@ private final class AutoCaptureTabContentViewController: UIViewController { let actionButton = UIButton(type: .system) actionButton.setTitle("Tap in \(tabTitle)", for: .normal) - actionButton.backgroundColor = .systemBlue - actionButton.setTitleColor(.white, for: .normal) - actionButton.layer.cornerRadius = 10 - actionButton.titleLabel?.font = .boldSystemFont(ofSize: 16) + actionButton.applyLiquidGlassStyle(.prominent, title: "Tap in \(tabTitle)") actionButton.translatesAutoresizingMaskIntoConstraints = false - actionButton.heightAnchor.constraint(equalToConstant: 48).isActive = true + actionButton.heightAnchor.constraint(equalToConstant: SampleAppearance.buttonHeight).isActive = true actionButton.addTarget(self, action: #selector(onTapButton), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, actionButton]) diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTestViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTestViewController.swift index 7f94bf83..fdee23c9 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTestViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/AutoCaptureTestViewController.swift @@ -33,11 +33,7 @@ class AutoCaptureTestViewController: UIViewController { // Buttons private let normalButton = UIButton(type: .system) - private let tableViewButton = UIButton(type: .system) - private let collectionViewButton = UIButton(type: .system) private let menuButton = UIButton(type: .system) - private let pickerTestButton = UIButton(type: .system) - private let tabsTestButton = UIButton(type: .system) private let gestureTestView = UIView() // Labels for displaying values @@ -47,9 +43,8 @@ class AutoCaptureTestViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - title = "Auto Capture TITLE Test" - view.backgroundColor = .systemBackground - setupBackButton() + title = "Controls test" + view.backgroundColor = SampleAppearance.screenBackground setupUI() } @@ -61,32 +56,6 @@ class AutoCaptureTestViewController: UIViewController { "Demo title" } - private func setupBackButton() { - let backButton = UIButton(type: .system) - backButton.setTitle("< Back", for: .normal) - backButton.titleLabel?.font = .systemFont(ofSize: 17) - backButton.contentHorizontalAlignment = .leading - backButton.translatesAutoresizingMaskIntoConstraints = false - backButton.userpilotRedactText = true - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - view.addSubview(backButton) - - NSLayoutConstraint.activate([ - backButton.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), - backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - backButton.heightAnchor.constraint(equalToConstant: 32) - ]) - } - - @objc private func backTapped() { - if let nav = navigationController { - nav.popViewController(animated: true) - } else { - dismiss(animated: true) - } - } - // MARK: - Setup UI private func setupUI() { @@ -114,8 +83,7 @@ class AutoCaptureTestViewController: UIViewController { scrollView.addSubview(contentView) NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -152,12 +120,10 @@ class AutoCaptureTestViewController: UIViewController { addSectionHeader("1. UIButton (tap)") normalButton.setTitle("Tap Me!", for: .normal) normalButton.addTarget(self, action: #selector(onNormalButtonTapped), for: .touchUpInside) - normalButton.backgroundColor = .systemBlue - normalButton.setTitleColor(.white, for: .normal) normalButton.accessibilityLabel = "Test accessibilityLabel" normalButton.accessibilityIdentifier = "testAccessibilityIdentifier" - normalButton.layer.cornerRadius = 8 - normalButton.heightAnchor.constraint(equalToConstant: 50).isActive = true + normalButton.applyLiquidGlassStyle(.prominent, title: "Tap Me!") + normalButton.heightAnchor.constraint(equalToConstant: SampleAppearance.buttonHeight).isActive = true stackView.addArrangedSubview(normalButton) // UISwitch @@ -224,71 +190,16 @@ class AutoCaptureTestViewController: UIViewController { textView.font = .systemFont(ofSize: 16) stackView.addArrangedSubview(textView) - // UIPickerView (pushed test screen — exercises UIPickerView+AutoCapture) - addSectionHeader("10. UIPickerView (picker_view_changed)") - pickerTestButton.setTitle("Open UIPickerView Test", for: .normal) - pickerTestButton.addTarget( - self, action: #selector(onPickerTestButtonTapped), for: .touchUpInside) - pickerTestButton.backgroundColor = .systemTeal - pickerTestButton.setTitleColor(.white, for: .normal) - pickerTestButton.layer.cornerRadius = 8 - pickerTestButton.heightAnchor.constraint(equalToConstant: 50).isActive = true - stackView.addArrangedSubview(pickerTestButton) - - // TableView Button - addSectionHeader("11. UITableView Cell Selection") - tableViewButton.setTitle("Open TableView Test", for: .normal) - tableViewButton.addTarget( - self, action: #selector(onTableViewButtonTapped), for: .touchUpInside) - tableViewButton.backgroundColor = .systemGreen - tableViewButton.setTitleColor(.white, for: .normal) - tableViewButton.layer.cornerRadius = 8 - tableViewButton.heightAnchor.constraint(equalToConstant: 50).isActive = true - stackView.addArrangedSubview(tableViewButton) - - // CollectionView Button - addSectionHeader("12. UICollectionView Item Selection") - collectionViewButton.setTitle("Open CollectionView Test", for: .normal) - collectionViewButton.addTarget( - self, action: #selector(onCollectionViewButtonTapped), for: .touchUpInside) - collectionViewButton.backgroundColor = .systemOrange - collectionViewButton.setTitleColor(.white, for: .normal) - collectionViewButton.layer.cornerRadius = 8 - collectionViewButton.heightAnchor.constraint(equalToConstant: 50).isActive = true - stackView.addArrangedSubview(collectionViewButton) - // UIMenu Button - addSectionHeader("13. UIMenu (menu item tap)") + addSectionHeader("10. UIMenu (menu item tap)") setupMenuButton() stackView.addArrangedSubview(menuButton) // GestureRecognizer Demo (UIApplication.sendAction -> captureGestureAction) - addSectionHeader("14. Gesture Recognizers (tap / long press)") + addSectionHeader("11. Gesture Recognizers (tap / long press)") addLabel("Tap or long-press this view to test captureGestureAction.") setupGestureTestView() stackView.addArrangedSubview(gestureTestView) - - // Text Config Demo Button - addSectionHeader("15. Text Config Demo") - textConfigDemoButton.setTitle("Open Text Config Demo", for: .normal) - textConfigDemoButton.addTarget( - self, action: #selector(onTextConfigDemoButtonTapped), for: .touchUpInside) - textConfigDemoButton.backgroundColor = .systemIndigo - textConfigDemoButton.setTitleColor(.white, for: .normal) - textConfigDemoButton.layer.cornerRadius = 8 - textConfigDemoButton.heightAnchor.constraint(equalToConstant: 50).isActive = true - stackView.addArrangedSubview(textConfigDemoButton) - - // Tabs Demo Button - addSectionHeader("16. Tabs Demo") - tabsTestButton.setTitle("Open Tabs Test", for: .normal) - tabsTestButton.addTarget( - self, action: #selector(onTabsTestButtonTapped), for: .touchUpInside) - tabsTestButton.backgroundColor = .systemPink - tabsTestButton.setTitleColor(.white, for: .normal) - tabsTestButton.layer.cornerRadius = 8 - tabsTestButton.heightAnchor.constraint(equalToConstant: 50).isActive = true - stackView.addArrangedSubview(tabsTestButton) } private func setupGestureTestView() { @@ -321,10 +232,8 @@ class AutoCaptureTestViewController: UIViewController { private func setupMenuButton() { menuButton.setTitle("Show Menu ▼", for: .normal) - menuButton.backgroundColor = .systemPurple - menuButton.setTitleColor(.white, for: .normal) - menuButton.layer.cornerRadius = 8 - menuButton.heightAnchor.constraint(equalToConstant: 50).isActive = true + menuButton.applyLiquidGlassStyle(.prominent, title: "Show Menu ▼", tintColor: .systemPurple) + menuButton.heightAnchor.constraint(equalToConstant: SampleAppearance.buttonHeight).isActive = true let menuItems = UIMenu( title: "Options", @@ -438,21 +347,6 @@ class AutoCaptureTestViewController: UIViewController { print("Page: \(sender.currentPage)") } - @IBAction func onTableViewButtonTapped(_ sender: UIButton) { - let tableVC = TableViewTestViewController() - navigationController?.pushViewController(tableVC, animated: true) - } - - @IBAction func onCollectionViewButtonTapped(_ sender: UIButton) { - let collectionVC = CollectionViewTestViewController() - navigationController?.pushViewController(collectionVC, animated: true) - } - - @IBAction func onPickerTestButtonTapped(_ sender: UIButton) { - let pickerVC = PickerViewTestViewController() - navigationController?.pushViewController(pickerVC, animated: true) - } - @objc private func onGestureTap(_ sender: UITapGestureRecognizer) { showAlert("Gesture Tap", "Tap gesture action fired") } @@ -462,20 +356,6 @@ class AutoCaptureTestViewController: UIViewController { showAlert("Gesture Long Press", "Long press gesture action fired") } - // MARK: - Text Config Demo Button - - private let textConfigDemoButton = UIButton(type: .system) - - @IBAction func onTextConfigDemoButtonTapped(_ sender: UIButton) { - let textConfigVC = TextConfigDemoViewController() - navigationController?.pushViewController(textConfigVC, animated: true) - } - - @IBAction func onTabsTestButtonTapped(_ sender: UIButton) { - let tabsVC = AutoCaptureTabsTestViewController() - navigationController?.pushViewController(tabsVC, animated: true) - } - private func showAlert(_ title: String, _ message: String) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/CollectionViewTestViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/CollectionViewTestViewController.swift index e303a34c..41d2ace4 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/CollectionViewTestViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/CollectionViewTestViewController.swift @@ -41,35 +41,10 @@ class CollectionViewTestViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() title = "CollectionView Test" - view.backgroundColor = .systemBackground - setupBackButton() + view.backgroundColor = SampleAppearance.screenBackground setupCollectionView() } - private func setupBackButton() { - let backButton = UIButton(type: .system) - backButton.setTitle("< Back", for: .normal) - backButton.titleLabel?.font = .systemFont(ofSize: 17) - backButton.contentHorizontalAlignment = .leading - backButton.translatesAutoresizingMaskIntoConstraints = false - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - view.addSubview(backButton) - - NSLayoutConstraint.activate([ - backButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), - backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - backButton.heightAnchor.constraint(equalToConstant: 32) - ]) - } - - @objc private func backTapped() { - if let nav = navigationController { - nav.popViewController(animated: true) - } else { - dismiss(animated: true) - } - } - // MARK: - Setup private func setupCollectionView() { @@ -87,7 +62,7 @@ class CollectionViewTestViewController: UIViewController { collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.backgroundColor = .systemBackground + collectionView.backgroundColor = SampleAppearance.screenBackground collectionView.delegate = self collectionView.dataSource = self collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: cellIdentifier) @@ -95,7 +70,7 @@ class CollectionViewTestViewController: UIViewController { view.addSubview(collectionView) NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44), + collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) @@ -162,7 +137,7 @@ class CustomCollectionViewCell: UICollectionViewCell { // MARK: - Setup private func setupUI() { - contentView.backgroundColor = .secondarySystemBackground + contentView.backgroundColor = SampleAppearance.elevatedBackground contentView.layer.cornerRadius = 12 contentView.layer.masksToBounds = true @@ -222,7 +197,7 @@ class CustomCollectionViewCell: UICollectionViewCell { didSet { UIView.animate(withDuration: 0.2) { // swiftlint:disable:next line_length - self.contentView.backgroundColor = self.isSelected ? .systemBlue.withAlphaComponent(0.2) : .secondarySystemBackground + self.contentView.backgroundColor = self.isSelected ? .systemBlue.withAlphaComponent(0.2) : SampleAppearance.elevatedBackground self.transform = self.isSelected ? CGAffineTransform(scaleX: 0.95, y: 0.95) : .identity } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/DemoBackButtonViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/DemoBackButtonViewController.swift index ed971976..42ef4527 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/DemoBackButtonViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/DemoBackButtonViewController.swift @@ -7,40 +7,5 @@ import UIKit -class DemoBackButtonViewController: UIViewController { - - let demoBackButtonTopInset: CGFloat = 44 - - override func viewDidLoad() { - super.viewDidLoad() - setupBackButton() - } - - private func setupBackButton() { - let backButton = UIButton(type: .system) - backButton.setTitle("< Back", for: .normal) - backButton.titleLabel?.font = .systemFont(ofSize: 17) - backButton.contentHorizontalAlignment = .leading - backButton.translatesAutoresizingMaskIntoConstraints = false - backButton.userpilotRedactText = true - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - view.addSubview(backButton) - - NSLayoutConstraint.activate([ - backButton.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: 8 - ), - backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - backButton.heightAnchor.constraint(equalToConstant: 32) - ]) - } - - @objc private func backTapped() { - if let nav = navigationController { - nav.popViewController(animated: true) - } else { - dismiss(animated: true) - } - } -} +/// Base class for Auto Capture demo screens that share Userpilot screen overrides. +class DemoBackButtonViewController: BaseViewController {} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/IgnoredScreenDemoViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/IgnoredScreenDemoViewController.swift index ea30c7cb..f8d10b92 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/IgnoredScreenDemoViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/IgnoredScreenDemoViewController.swift @@ -17,7 +17,6 @@ class IgnoredScreenDemoViewController: DemoBackButtonViewController { override func viewDidLoad() { super.viewDidLoad() title = "Ignored Screen Demo" - view.backgroundColor = .systemBackground setupBody() } @@ -48,10 +47,7 @@ class IgnoredScreenDemoViewController: DemoBackButtonViewController { view.addSubview(button) NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: demoBackButtonTopInset + 20 - ), + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/PickerViewTestViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/PickerViewTestViewController.swift index 77566ec0..909ab015 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/PickerViewTestViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/PickerViewTestViewController.swift @@ -20,35 +20,10 @@ class PickerViewTestViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() title = "UIPickerView Test" - view.backgroundColor = .systemBackground - setupBackButton() + view.backgroundColor = SampleAppearance.screenBackground setupPicker() } - private func setupBackButton() { - let backButton = UIButton(type: .system) - backButton.setTitle("< Back", for: .normal) - backButton.titleLabel?.font = .systemFont(ofSize: 17) - backButton.contentHorizontalAlignment = .leading - backButton.translatesAutoresizingMaskIntoConstraints = false - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - view.addSubview(backButton) - - NSLayoutConstraint.activate([ - backButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), - backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - backButton.heightAnchor.constraint(equalToConstant: 32) - ]) - } - - @objc private func backTapped() { - if let nav = navigationController { - nav.popViewController(animated: true) - } else { - dismiss(animated: true) - } - } - private func setupPicker() { selectionLabel.translatesAutoresizingMaskIntoConstraints = false selectionLabel.font = .systemFont(ofSize: 15) @@ -65,7 +40,7 @@ class PickerViewTestViewController: UIViewController { view.addSubview(pickerView) NSLayoutConstraint.activate([ - selectionLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44), + selectionLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), selectionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), selectionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TableViewTestViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TableViewTestViewController.swift index 173928c5..c631d220 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TableViewTestViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TableViewTestViewController.swift @@ -33,35 +33,10 @@ class TableViewTestViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() title = "TableView Test" - view.backgroundColor = .systemBackground - setupBackButton() + view.backgroundColor = SampleAppearance.screenBackground setupTableView() } - private func setupBackButton() { - let backButton = UIButton(type: .system) - backButton.setTitle("< Back", for: .normal) - backButton.titleLabel?.font = .systemFont(ofSize: 17) - backButton.contentHorizontalAlignment = .leading - backButton.translatesAutoresizingMaskIntoConstraints = false - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - view.addSubview(backButton) - - NSLayoutConstraint.activate([ - backButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), - backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - backButton.heightAnchor.constraint(equalToConstant: 32) - ]) - } - - @objc private func backTapped() { - if let nav = navigationController { - nav.popViewController(animated: true) - } else { - dismiss(animated: true) - } - } - // MARK: - Setup private func setupTableView() { @@ -70,11 +45,12 @@ class TableViewTestViewController: UIViewController { tableView.dataSource = self tableView.register(CustomTableViewCell.self, forCellReuseIdentifier: cellIdentifier) tableView.rowHeight = 70 + tableView.backgroundColor = SampleAppearance.screenBackground view.addSubview(tableView) NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44), + 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) diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TextConfigDemoViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TextConfigDemoViewController.swift index 7a26ecbc..9957196c 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TextConfigDemoViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/AutoCapture/TextConfigDemoViewController.swift @@ -51,7 +51,6 @@ class TextConfigDemoViewController: DemoBackButtonViewController { override func viewDidLoad() { super.viewDidLoad() title = "Text Config Demo" - view.backgroundColor = .systemBackground setupUI() } @@ -69,10 +68,7 @@ class TextConfigDemoViewController: DemoBackButtonViewController { scrollView.addSubview(contentView) NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: demoBackButtonTopInset - ), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -517,7 +513,6 @@ private final class ScreenAPIContainerDemoViewController: DemoBackButtonViewCont override func viewDidLoad() { super.viewDidLoad() title = "Container Screen Demo" - view.backgroundColor = .systemBackground setupBody() embedChild() } @@ -547,10 +542,7 @@ private final class ScreenAPIContainerDemoViewController: DemoBackButtonViewCont view.addSubview(childHostView) NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: demoBackButtonTopInset + 20 - ), + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), @@ -592,10 +584,8 @@ internal final class DefaultIgnoreInnerHierarchyContainerView: UIView { internal extension UIButton { func applyDemoStyle(backgroundColor: UIColor) { - self.backgroundColor = backgroundColor - setTitleColor(.white, for: .normal) - layer.cornerRadius = 8 - heightAnchor.constraint(equalToConstant: 50).isActive = true + applyLiquidGlassStyle(.prominent, tintColor: backgroundColor) + heightAnchor.constraint(equalToConstant: SampleAppearance.buttonHeight).isActive = true } } // swiftlint:enable all 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..380d38c5 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/Cells/ConfigFlagTableViewCell.swift @@ -0,0 +1,113 @@ +// +// 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 + backgroundColor = .clear + contentView.backgroundColor = .clear + 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..2ef6b93c --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Config/ConfigViewController.swift @@ -0,0 +1,250 @@ +// +// 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 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.backgroundColor = SampleAppearance.screenBackground + 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.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + title = "Configuration" + saveButton.applyLiquidGlassStyle(.prominent, title: "Save & Restart") + setupViews() + setupTableView() + setupActions() + tokenTextField.text = StorageManager.shared.get(forKey: StorageManager.Keys.appToken) ?? "" + } + + // MARK: - Setup + + private func setupViews() { + view.addSubview(tableView) + view.addSubview(restartNoteLabel) + view.addSubview(saveButton) + + let safeArea = view.safeAreaLayoutGuide + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: safeArea.topAnchor), + 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: SampleAppearance.buttonHeight) + ]) + } + + 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() { + saveButton.addTarget(self, action: #selector(onSaveAndRestartTapped), for: .touchUpInside) + tokenTextField.delegate = self + } + + // MARK: - Actions + + @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/Core/BaseNavigationController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseNavigationController.swift index 8188b77a..6e431b37 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseNavigationController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseNavigationController.swift @@ -16,10 +16,18 @@ class BaseNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() - setNavigationBarHidden(true, animated: false) - self.delegate = self - self.interactivePopGestureRecognizer?.delegate = self - navigationBar.isTranslucent = false + delegate = self + interactivePopGestureRecognizer?.delegate = self + configureNavigationBarAppearance() + } + + private func configureNavigationBarAppearance() { + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.prefersLargeTitles = false } override var preferredStatusBarStyle: UIStatusBarStyle { diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseViewController.swift index c41db85b..65b95691 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Core/BaseViewController.swift @@ -10,6 +10,11 @@ import UIKit class BaseViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = SampleAppearance.screenBackground + } + // MARK: - Helper methods func close() { self.navigationController?.popViewController(animated: true) diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/DebugViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/DebugViewController.swift new file mode 100644 index 00000000..f3ac30b8 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/DebugViewController.swift @@ -0,0 +1,85 @@ +// +// DebugViewController.swift +// UserpilotSample +// +// Hub for QA / debug harness screens. +// + +import UIKit + +final class DebugViewController: BaseViewController { + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let items: [DebugContent] = [ + .onlineQueue, + .offlineEvents + ] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Debug" + setupTable() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + UserpilotManager.shared.screen("debug") + } + + 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) + ]) + } + + static func newInstance() -> DebugViewController { + DebugViewController() + } +} + +extension DebugViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count + } + + 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 .onlineQueue: + FlowRoutingManager.shared.openViewController(OnlineQueueViewController()) + case .offlineEvents: + FlowRoutingManager.shared.openViewController(OfflineEventsViewController()) + } + } +} + +enum DebugContent { + case onlineQueue + case offlineEvents + + var title: String { + switch self { + case .onlineQueue: + return "Online queue" + case .offlineEvents: + return "Offline events" + } + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/Offline/OfflineEventsViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/Offline/OfflineEventsViewController.swift new file mode 100644 index 00000000..f150c51d --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/Offline/OfflineEventsViewController.swift @@ -0,0 +1,507 @@ +// +// OfflineEventsViewController.swift +// UserpilotSample +// +// Stress-test screen for offline event storage and delivery. +// Ported from the Android sample OfflineEventsActivity. +// + +import UIKit + +// swiftlint:disable all + +final class OfflineEventsViewController: UIViewController { + + private enum BurstKind { case track, screen, auto, mixed } + + private final class BurstState { + let kind: BurstKind + let total: Int + let batchId: String + var nextIndex = 0 + var trackCount = 0 + var screenCount = 0 + var autoCount = 0 + + init(kind: BurstKind, total: Int, batchId: String) { + self.kind = kind + self.total = total + self.batchId = batchId + } + } + + private let screenTitle = "offline events" + private let chipTargetCount = 20 + private let toggleTargetCount = 2 + private let checkboxTargetCount = 2 + private let trackSlice = 40 + private let mixedCycle = 3 + private let maxBurst = 10_000 + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let statusLabel = UILabel() + private let countField = UITextField() + private var countButtons: [UIButton] = [] + private var paddingButtons: [UIButton] = [] + private var selectedCount = 500 + private var selectedPaddingBytes = 0 + private var paddingPayload = "" + private var autoClickTargets: [UIControl] = [] + private var burst: BurstState? + private var sessionTrack = 0 + private var sessionScreen = 0 + private var sessionAuto = 0 + + private let fireTrackButton = UIButton(type: .system) + private let fireScreenButton = UIButton(type: .system) + private let fireAutoButton = UIButton(type: .system) + private let fireMixedButton = UIButton(type: .system) + private let stopButton = UIButton(type: .system) + + override func viewDidLoad() { + super.viewDidLoad() + title = "Offline events" + view.backgroundColor = SampleAppearance.screenBackground + setupUI() + setupAutoTargets() + renderStatus(idleMessage()) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + UserpilotManager.shared.screen(screenTitle) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopBurst() + } + + private func setupUI() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + stopButton.setTitle("Stop", for: .normal) + stopButton.isEnabled = false + stopButton.addTarget(self, action: #selector(stopTapped), for: .touchUpInside) + stopButton.applyLiquidGlassStyle(.regular, title: "Stop", tintColor: .systemRed) + + let logsButton = makeButton("Events log / Logs", action: #selector(openLogs)) + let bottomRow = UIStackView(arrangedSubviews: [stopButton, logsButton]) + bottomRow.axis = .horizontal + bottomRow.spacing = 8 + bottomRow.distribution = .fillEqually + bottomRow.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomRow) + + NSLayoutConstraint.activate([ + bottomRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + bottomRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + bottomRow.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + bottomRow.heightAnchor.constraint(equalToConstant: 44), + + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomRow.topAnchor, constant: -8) + ]) + + stackView.axis = .vertical + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16), + stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -16), + stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32) + ]) + + statusLabel.numberOfLines = 0 + statusLabel.font = .systemFont(ofSize: 12) + statusLabel.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.08) + statusLabel.layer.cornerRadius = 8 + statusLabel.clipsToBounds = true + let statusContainer = UIView() + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusContainer.addSubview(statusLabel) + NSLayoutConstraint.activate([ + statusLabel.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 10), + statusLabel.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 10), + statusLabel.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -10), + statusLabel.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -10) + ]) + stackView.addArrangedSubview(statusContainer) + + stackView.addArrangedSubview(makeLabel( + "Offline buffer: 5,000 events or 3 MB. Identify → Airplane Mode → fire burst → go online → verify Events log." + )) + + stackView.addArrangedSubview(makeSection("Burst size")) + let countRow = UIStackView() + countRow.axis = .horizontal + countRow.spacing = 6 + countRow.distribution = .fillEqually + for value in [100, 500, 1000, 5000, 5500] { + let button = UIButton(type: .system) + button.setTitle("\(value)", for: .normal) + button.tag = value + button.addTarget(self, action: #selector(countChipTapped(_:)), for: .touchUpInside) + styleChip(button, selected: value == selectedCount) + countButtons.append(button) + countRow.addArrangedSubview(button) + } + stackView.addArrangedSubview(countRow) + + countField.placeholder = "Custom count" + countField.text = "\(selectedCount)" + countField.keyboardType = .numberPad + countField.borderStyle = .roundedRect + stackView.addArrangedSubview(countField) + + stackView.addArrangedSubview(makeSection("Track payload padding")) + let paddingRow = UIStackView() + paddingRow.axis = .horizontal + paddingRow.spacing = 6 + paddingRow.distribution = .fillEqually + for (title, bytes) in [("None", 0), ("1 KB", 1024), ("4 KB", 4096), ("16 KB", 16384)] { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.tag = bytes + button.addTarget(self, action: #selector(paddingChipTapped(_:)), for: .touchUpInside) + styleChip(button, selected: bytes == selectedPaddingBytes) + paddingButtons.append(button) + paddingRow.addArrangedSubview(button) + } + stackView.addArrangedSubview(paddingRow) + + stackView.addArrangedSubview(makeSection("Manual events")) + configureActionButton(fireTrackButton, title: "Fire track events", action: #selector(fireTrackTapped), filled: true) + configureActionButton(fireScreenButton, title: "Fire screen events", action: #selector(fireScreenTapped), filled: false) + stackView.addArrangedSubview(fireTrackButton) + stackView.addArrangedSubview(fireScreenButton) + + stackView.addArrangedSubview(makeSection("Auto-capture events")) + configureActionButton(fireAutoButton, title: "Fire auto-capture clicks", action: #selector(fireAutoTapped), filled: false) + stackView.addArrangedSubview(fireAutoButton) + + let chipsContainer = UIStackView() + chipsContainer.axis = .vertical + chipsContainer.spacing = 6 + chipsContainer.tag = 9001 + stackView.addArrangedSubview(chipsContainer) + + let togglesContainer = UIStackView() + togglesContainer.axis = .vertical + togglesContainer.spacing = 6 + togglesContainer.tag = 9002 + stackView.addArrangedSubview(togglesContainer) + + stackView.addArrangedSubview(makeSection("Mixed burst")) + configureActionButton(fireMixedButton, title: "Fire mixed (track + screen + auto)", action: #selector(fireMixedTapped), filled: true) + stackView.addArrangedSubview(fireMixedButton) + } + + private func setupAutoTargets() { + guard + let chipsContainer = stackView.arrangedSubviews.first(where: { $0.tag == 9001 }) as? UIStackView, + let togglesContainer = stackView.arrangedSubviews.first(where: { $0.tag == 9002 }) as? UIStackView + else { return } + + for index in 1...chipTargetCount { + let button = UIButton(type: .system) + button.setTitle("Tap \(index)", for: .normal) + button.accessibilityLabel = "offline-auto-chip-\(index)" + styleChip(button, selected: false) + installAutoTarget(button) + chipsContainer.addArrangedSubview(button) + } + + for index in 1...toggleTargetCount { + let toggle = UISwitch() + toggle.accessibilityLabel = "offline-auto-switch-\(index)" + let row = labeledControl(title: "Switch \(index)", control: toggle) + installAutoTarget(toggle) + togglesContainer.addArrangedSubview(row) + } + + for index in 1...checkboxTargetCount { + let checkbox = UIButton(type: .system) + checkbox.setTitle("☐ Box \(index)", for: .normal) + checkbox.setTitle("☑ Box \(index)", for: .selected) + checkbox.contentHorizontalAlignment = .leading + checkbox.accessibilityLabel = "offline-auto-checkbox-\(index)" + checkbox.addTarget(self, action: #selector(checkboxTapped(_:)), for: .touchUpInside) + installAutoTarget(checkbox) + togglesContainer.addArrangedSubview(checkbox) + } + } + + private func installAutoTarget(_ target: UIControl) { + target.addTarget(self, action: #selector(autoTargetTapped), for: .touchUpInside) + if target is UISwitch { + target.addTarget(self, action: #selector(autoTargetTapped), for: .valueChanged) + } + autoClickTargets.append(target) + } + + @objc private func autoTargetTapped() { + if burst == nil { + sessionAuto += 1 + renderStatus(idleMessage()) + } + } + + @objc private func checkboxTapped(_ sender: UIButton) { + sender.isSelected.toggle() + } + + @objc private func countChipTapped(_ sender: UIButton) { + selectedCount = sender.tag + countField.text = "\(selectedCount)" + countButtons.forEach { styleChip($0, selected: $0.tag == selectedCount) } + } + + @objc private func paddingChipTapped(_ sender: UIButton) { + selectedPaddingBytes = sender.tag + paddingButtons.forEach { styleChip($0, selected: $0.tag == selectedPaddingBytes) } + } + + @objc private func fireTrackTapped() { startBurst(.track) } + @objc private func fireScreenTapped() { startBurst(.screen) } + @objc private func fireAutoTapped() { startBurst(.auto) } + @objc private func fireMixedTapped() { startBurst(.mixed) } + + @objc private func stopTapped() { + stopBurst(cancelled: true) + } + + @objc private func openLogs() { + FlowRoutingManager.shared.openViewController(SDKEventsViewController.newInstance()) + } + + private func startBurst(_ kind: BurstKind) { + guard burst == nil else { return } + guard let count = parseCount() else { return } + let batchId = String(Int(Date().timeIntervalSince1970 * 1000), radix: 36) + paddingPayload = String(repeating: "x", count: selectedPaddingBytes) + burst = BurstState(kind: kind, total: count, batchId: batchId) + setControlsEnabled(false) + DispatchQueue.main.async { [weak self] in self?.runBurstSlice() } + } + + private func runBurstSlice() { + guard let state = burst else { return } + if state.nextIndex >= state.total { + finishBurst() + return + } + switch state.kind { + case .track: fireTrackSlice(state) + case .screen: fireScreenSlice(state) + case .auto: fireAutoSlice(state) + case .mixed: fireMixedSlice(state) + } + renderStatus(runningMessage(state)) + if burst != nil, state.nextIndex < state.total { + DispatchQueue.main.async { [weak self] in self?.runBurstSlice() } + } else if burst != nil { + finishBurst() + } + } + + private func fireTrackSlice(_ state: BurstState) { + let end = min(state.nextIndex + trackSlice, state.total) + while state.nextIndex < end { fireTrack(state) } + } + + private func fireScreenSlice(_ state: BurstState) { + let end = min(state.nextIndex + trackSlice, state.total) + while state.nextIndex < end { fireScreen(state) } + } + + private func fireAutoSlice(_ state: BurstState) { + var used = Set() + while state.nextIndex < state.total { + guard let target = nextUnusedTarget(used) else { break } + fireAuto(state, target: target) + used.insert(ObjectIdentifier(target)) + } + } + + private func fireMixedSlice(_ state: BurstState) { + var used = Set() + var processed = 0 + while state.nextIndex < state.total, processed < trackSlice { + switch state.nextIndex % mixedCycle { + case 0: fireTrack(state) + case 1: fireScreen(state) + default: + guard let target = nextUnusedTarget(used) else { return } + fireAuto(state, target: target) + used.insert(ObjectIdentifier(target)) + } + processed += 1 + } + } + + private func fireTrack(_ state: BurstState) { + let index = state.nextIndex + var properties: [String: Any] = [ + "batch_id": state.batchId, + "index": index, + "kind": "track", + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + if !paddingPayload.isEmpty { + properties["padding"] = paddingPayload + } + UserpilotManager.shared.track( + eventName: "offline_track_\(state.batchId)_\(index)", + properties: properties + ) + state.nextIndex += 1 + state.trackCount += 1 + sessionTrack += 1 + } + + private func fireScreen(_ state: BurstState) { + let index = state.nextIndex + UserpilotManager.shared.screen("offline_screen_\(state.batchId)_\(index)") + state.nextIndex += 1 + state.screenCount += 1 + sessionScreen += 1 + } + + private func fireAuto(_ state: BurstState, target: UIControl) { + let index = state.nextIndex + let label = "auto_\(state.batchId)_\(index)" + if let button = target as? UIButton { + button.setTitle(label, for: .normal) + } + target.accessibilityLabel = label + target.sendActions(for: .touchUpInside) + if target is UISwitch { + target.sendActions(for: .valueChanged) + } + state.nextIndex += 1 + state.autoCount += 1 + sessionAuto += 1 + } + + private func nextUnusedTarget(_ used: Set) -> UIControl? { + autoClickTargets.first { !used.contains(ObjectIdentifier($0)) } + } + + private func finishBurst() { + let state = burst + stopBurst() + if let state { + renderStatus(completedMessage(state)) + } + } + + private func stopBurst(cancelled: Bool = false) { + let state = burst + burst = nil + setControlsEnabled(true) + if cancelled, let state { + renderStatus(cancelledMessage(state)) + } + } + + private func parseCount() -> Int? { + let raw = countField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let count = Int(raw), count > 0 else { + renderStatus("Enter a count greater than 0") + return nil + } + return min(count, maxBurst) + } + + private func setControlsEnabled(_ enabled: Bool) { + fireTrackButton.isEnabled = enabled + fireScreenButton.isEnabled = enabled + fireAutoButton.isEnabled = enabled + fireMixedButton.isEnabled = enabled + countField.isEnabled = enabled + countButtons.forEach { $0.isEnabled = enabled } + paddingButtons.forEach { $0.isEnabled = enabled } + stopButton.isEnabled = !enabled + } + + private func listenerCount() -> Int { + UserpilotManager.shared.userpilotSDKEvents.count + } + + private func idleMessage() -> String { + "Idle. Session track=\(sessionTrack), screen=\(sessionScreen), auto=\(sessionAuto).\nSDK listener=\(listenerCount())" + } + + private func runningMessage(_ state: BurstState) -> String { + "Firing \(state.nextIndex) / \(state.total)\nThis burst track=\(state.trackCount), screen=\(state.screenCount), auto=\(state.autoCount)\nSDK listener=\(listenerCount())" + } + + private func completedMessage(_ state: BurstState) -> String { + "Done. Requested=\(state.total) (track=\(state.trackCount), screen=\(state.screenCount), auto=\(state.autoCount)).\nSession track=\(sessionTrack), screen=\(sessionScreen), auto=\(sessionAuto).\nSDK listener=\(listenerCount())" + } + + private func cancelledMessage(_ state: BurstState) -> String { + "Stopped \(state.nextIndex) / \(state.total) (track=\(state.trackCount), screen=\(state.screenCount), auto=\(state.autoCount)).\nSDK listener=\(listenerCount())" + } + + private func renderStatus(_ text: String) { + statusLabel.text = text + } + + private func makeSection(_ title: String) -> UILabel { + let label = makeLabel(title) + label.font = .boldSystemFont(ofSize: 15) + return label + } + + private func makeLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 13) + label.textColor = .secondaryLabel + return label + } + + private func makeButton(_ title: String, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.addTarget(self, action: action, for: .touchUpInside) + button.applyLiquidGlassStyle(.regular, title: title, unifiedHeight: false) + return button + } + + private func configureActionButton(_ button: UIButton, title: String, action: Selector, filled: Bool) { + button.setTitle(title, for: .normal) + button.addTarget(self, action: action, for: .touchUpInside) + button.applyLiquidGlassStyle(filled ? .prominent : .regular, title: title) + } + + private func styleChip(_ button: UIButton, selected: Bool) { + button.applyLiquidGlassStyle( + selected ? .prominent : .regular, + title: button.title(for: .normal), + unifiedHeight: false + ) + } + + private func labeledControl(title: String, control: UIView) -> UIStackView { + let label = UILabel() + label.text = title + let row = UIStackView(arrangedSubviews: [label, control]) + row.axis = .horizontal + row.alignment = .center + row.distribution = .equalSpacing + return row + } +} + +// swiftlint:enable all diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/OnlineQueue/OnlineQueueViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/OnlineQueue/OnlineQueueViewController.swift new file mode 100644 index 00000000..b5c77322 --- /dev/null +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Debug/OnlineQueue/OnlineQueueViewController.swift @@ -0,0 +1,435 @@ +// +// OnlineQueueViewController.swift +// UserpilotSample +// +// Manual harness for online analytics-queue / onSocketEventSent scenarios. +// Stay ONLINE. Verify fake_reload / start_session via Logs + Xcode console. +// + +// Each scenario is a self-contained QA script with its own expected-result text; keeping them +// in one harness is the point of the screen. +// swiftlint:disable file_length + +import UIKit + +final class OnlineQueueViewController: UIViewController { + + // MARK: - Constants + + private let screenTitle = "online queue" + private let screenS1 = "queue_s1_home" + private let screenS2 = "queue_s2_settings" + private let screenS3 = "queue_s3_profile" + private let settleMs: TimeInterval = 0.4 + private let switchGapMs: TimeInterval = 0.7 + private let contentGapMs: TimeInterval = 0.6 + + // MARK: - UI + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let statusLabel = UILabel() + private let reportScreenSwitch = UISwitch() + private let userAField = UITextField() + private let userBField = UITextField() + + private var skipNextAutoScreen = false + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + title = "Online queue" + view.backgroundColor = SampleAppearance.screenBackground + setupUI() + statusLabel.text = "Ready. Stay online. Identify A + establish screen, then run a scenario." + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) +// if skipNextAutoScreen { +// skipNextAutoScreen = false +// return +// } + // if reportScreenSwitch.isOn { + UserpilotManager.shared.screen(screenTitle) + // } + } + + // MARK: - Setup + + private func setupUI() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + let logsButton = makeButton("Events log / Logs", action: #selector(openLogs)) + logsButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(logsButton) + + NSLayoutConstraint.activate([ + logsButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + logsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + logsButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: logsButton.topAnchor, constant: -8) + ]) + + stackView.axis = .vertical + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16), + stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -16), + stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32) + ]) + + addStatusHeader() + addUserInputs() + addScenarioButtons() + } + + /// Status readout plus the usage hint at the top of the scroll view. + private func addStatusHeader() { + statusLabel.numberOfLines = 0 + statusLabel.font = .systemFont(ofSize: 12) + statusLabel.textColor = .label + statusLabel.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.08) + statusLabel.layer.cornerRadius = 8 + statusLabel.clipsToBounds = true + // padding via container + let statusContainer = UIView() + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusContainer.addSubview(statusLabel) + NSLayoutConstraint.activate([ + statusLabel.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 10), + statusLabel.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 10), + statusLabel.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -10), + statusLabel.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -10) + ]) + stackView.addArrangedSubview(statusContainer) + + let hint = makeLabel( + """ + Stay ONLINE. Logging=true. Scenario buttons fire identify/screen/track \ + for onSocketEventSent fake_reload + start_session checks. + """ + ) + hint.textColor = .secondaryLabel + stackView.addArrangedSubview(hint) + } + + /// The "report screen on appear" toggle and the two user-id fields. + private func addUserInputs() { + let switchRow = UIStackView() + switchRow.axis = .horizontal + switchRow.alignment = .center + switchRow.spacing = 12 + switchRow.addArrangedSubview(makeLabel("Report screen(\"online queue\") on appear")) + reportScreenSwitch.isOn = true + switchRow.addArrangedSubview(reportScreenSwitch) + stackView.addArrangedSubview(switchRow) + + userAField.placeholder = "User A id" + userAField.text = "queue_user_a" + userAField.borderStyle = .roundedRect + userBField.placeholder = "User B id (new user)" + userBField.text = "queue_user_b" + userBField.borderStyle = .roundedRect + stackView.addArrangedSubview(userAField) + stackView.addArrangedSubview(userBField) + } + + /// The scenario / manual-API button list. Titles double as the QA checklist. + private func addScenarioButtons() { + let sections: [(String, [(String, Selector)])] = [ + ("Setup", [ + ("1. Identify User A", #selector(setupIdentifyA)), + ("2. Establish current screen", #selector(setupScreen)), + ("Logout (clear session)", #selector(logoutTapped)) + ]), + ("Scenarios (ONQ)", [ + ("S1 Identify only → fake_reload=true, start_session=false", #selector(runIdentifyOnly)), + ("S2 Identify NEW user → fake_reload=false, start_session=true", #selector(runNewUser)), + ("S3 Identify + screen in queue → NO fake screen", #selector(runIdentifyPlusScreen)), + ("S4 Identify + track + screen → NO fake screen", #selector(runIdentifyTrackScreen)), + ("S5 Identify A then Identify B", #selector(runIdentifyThenNewUser)), + ("S6 Identify + 2 screens (content)", #selector(runTwoScreens)), + ("S7 Identify + 3 screens (fake reload title)", #selector(runThreeScreens)), + ("S8 Identify with NO current screen", #selector(runNoScreen)), + ("S9 Failed ACK (manual network drop)", #selector(runFailedAck)), + ("S10 Teo multi-app content check", #selector(runTeo)) + ]), + ("Manual APIs", [ + ("screen(queue_s1_home)", #selector(manualS1)), + ("screen(queue_s2_settings)", #selector(manualS2)), + ("screen(queue_s3_profile)", #selector(manualS3)), + ("track(unique)", #selector(manualTrack)), + ("End experience (trigger fake reload)", #selector(endExperience)) + ]) + ] + + for (title, buttons) in sections { + stackView.addArrangedSubview(makeSection(title)) + for (buttonTitle, action) in buttons { + stackView.addArrangedSubview(makeButton(buttonTitle, action: action)) + } + } + } + + private func makeSection(_ title: String) -> UILabel { + let label = makeLabel(title) + label.font = .boldSystemFont(ofSize: 15) + return label + } + + private func makeLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 13) + return label + } + + private func makeButton(_ title: String, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.applyLiquidGlassStyle(.regular, title: title, unifiedHeight: false) + button.addTarget(self, action: action, for: .touchUpInside) + return button + } + +} + +// MARK: - Actions + +private extension OnlineQueueViewController { + + @objc func openLogs() { + FlowRoutingManager.shared.openViewController(SDKEventsViewController.newInstance()) + } + + @objc private func setupIdentifyA() { + identify(userA(), ["source": "online_queue_setup"]) + setStatus("Identified \(userA()). Wait for Identify in Logs, then run a scenario.") + } + + @objc private func setupScreen() { + UserpilotManager.shared.screen(screenTitle) + setStatus("Manual screen('\(screenTitle)') sent.") + } + + @objc private func logoutTapped() { + UserpilotManager.shared.logout() + setStatus("logout() called.") + } + + @objc private func runIdentifyOnly() { + UserpilotManager.shared.screen(screenTitle) + DispatchQueue.main.asyncAfter(deadline: .now() + settleMs) { [weak self] in + guard let self else { return } + self.identify(self.userA(), [ + "scenario": "identify_only", + "ts": Int(Date().timeIntervalSince1970 * 1000) + ]) + self.setStatus( + """ + S1 Identify-only (same user) + Expected after Identify ACK: synthetic screen title='\(self.screenTitle)', \ + fake_reload=true, start_session=false. + """ + ) + } + } + + @objc private func runNewUser() { + UserpilotManager.shared.screen(screenTitle) + DispatchQueue.main.asyncAfter(deadline: .now() + settleMs) { [weak self] in + guard let self else { return } + self.identify(self.userB(), ["scenario": "new_user"]) + self.setStatus( + """ + S2 Identify new user (\(self.userB())) + Expected after B Identify ACK: synthetic screen, fake_reload=false, start_session=true. + """ + ) + } + } + + @objc private func runIdentifyPlusScreen() { + identify(userA(), ["scenario": "identify_plus_screen"]) + UserpilotManager.shared.screen(screenS1) + setStatus( + """ + S3 Identify + screen in queue + Fired identify then screen('\(screenS1)') immediately. + Expected: NO synthetic post-identify screen. Order: Identify → Screen('\(screenS1)'). + """ + ) + } + + @objc private func runIdentifyTrackScreen() { + let trackName = "queue_btn_\(Int(Date().timeIntervalSince1970 * 1000) % 100000)" + identify(userA(), ["scenario": "identify_track_screen"]) + UserpilotManager.shared.track(eventName: trackName, properties: ["scenario": "identify_track_screen"]) + UserpilotManager.shared.screen(screenS1) + setStatus( + """ + S4 Identify + track + screen + Expected: NO synthetic fake screen. Order: Identify → track('\(trackName)') → screen('\(screenS1)'). + """ + ) + } + + @objc private func runIdentifyThenNewUser() { + UserpilotManager.shared.screen(screenTitle) + DispatchQueue.main.asyncAfter(deadline: .now() + settleMs) { [weak self] in + guard let self else { return } + self.identify(self.userA(), ["scenario": "identify_then_switch_step1"]) + DispatchQueue.main.asyncAfter(deadline: .now() + self.switchGapMs) { + self.identify(self.userB(), ["scenario": "identify_then_switch_step2"]) + self.setStatus( + """ + S5 Identify A then Identify B + Expected after B ACK (empty queue + screen): fake_reload=false, \ + start_session=true for \(self.userB()). + """ + ) + } + } + } + + @objc private func runTwoScreens() { + identify(userA(), ["scenario": "two_screens"]) + UserpilotManager.shared.screen(screenS1) + DispatchQueue.main.asyncAfter(deadline: .now() + contentGapMs) { [weak self] in + guard let self else { return } + UserpilotManager.shared.screen(self.screenS2) + self.setStatus( + """ + S6 Identify + screen + screen (content) + Expected: content for each screen title; no extra fake reload between them. + """ + ) + } + } + + @objc private func runThreeScreens() { + identify(userA(), ["scenario": "three_screens"]) + UserpilotManager.shared.screen(screenS1) + DispatchQueue.main.asyncAfter(deadline: .now() + contentGapMs) { [weak self] in + guard let self else { return } + UserpilotManager.shared.screen(self.screenS2) + } + DispatchQueue.main.asyncAfter(deadline: .now() + contentGapMs * 2) { [weak self] in + guard let self else { return } + UserpilotManager.shared.screen(self.screenS3) + self.setStatus( + """ + S7 Identify + 3 screens + Screens: \(self.screenS1) → \(self.screenS2) → \(self.screenS3). + Show content, then End experience on the CURRENT screen. + Expected fake_reload title = CURRENT screen title. + """ + ) + } + } + + @objc private func runNoScreen() { + reportScreenSwitch.isOn = false + skipNextAutoScreen = true + UserpilotManager.shared.logout() + DispatchQueue.main.asyncAfter(deadline: .now() + settleMs) { [weak self] in + guard let self else { return } + self.identify(self.userA(), ["scenario": "no_screen_yet"]) + self.setStatus( + """ + S8 Identify with no current screen + logout + identify without screen(). + Expected: NO synthetic screen until a real screen() happens. + """ + ) + } + } + + @objc private func runFailedAck() { + UserpilotManager.shared.track( + eventName: "queue_pre_fail_\(Int(Date().timeIntervalSince1970 * 1000) % 100000)", + properties: ["scenario": "failed_ack_prep"] + ) + setStatus( + """ + S9 Failed ACK + 1. Start track/identify while ONLINE. + 2. Enable Airplane Mode before ACK. + 3. Restore network and fire another track. + Expected: drop head, continue queue. + """ + ) + } + + @objc private func runTeo() { + UserpilotManager.shared.screen(screenS1) + identify(userA(), ["scenario": "teo_app_screen", "screen": screenS1]) + setStatus( + """ + S10 Teo multi-app + Identify on every screen for THIS token/app and verify content. + Switch token in Configurations, repeat on App2. + Expected: no cross-app content bleed. + """ + ) + } + + @objc private func manualS1() { + UserpilotManager.shared.screen(screenS1) + setStatus("screen('\(screenS1)')") + } + + @objc private func manualS2() { + UserpilotManager.shared.screen(screenS2) + setStatus("screen('\(screenS2)')") + } + + @objc private func manualS3() { + UserpilotManager.shared.screen(screenS3) + setStatus("screen('\(screenS3)')") + } + + @objc private func manualTrack() { + let name = "queue_track_\(Int(Date().timeIntervalSince1970 * 1000) % 100000)" + UserpilotManager.shared.track(eventName: name, properties: ["harness": "online_queue"]) + setStatus("track('\(name)')") + } + + @objc private func endExperience() { + UserpilotManager.shared.endExperience() + setStatus("endExperience() — fake_reload only when analytics queue is empty; title = current screen.") + } + + // MARK: - Helpers + + private func identify(_ userId: String, _ properties: [String: Any]) { + UserpilotManager.shared.identify(userId: userId, properties: properties) + } + + private func userA() -> String { + let value = userAField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? "queue_user_a" : value + } + + private func userB() -> String { + let value = userBField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? "queue_user_b" : value + } + + private func setStatus(_ text: String) { + statusLabel.text = text + } +} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/Base.lproj/DeepLinkViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/Base.lproj/DeepLinkViewController.xib index e284a0b7..50e91712 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/Base.lproj/DeepLinkViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/Base.lproj/DeepLinkViewController.xib @@ -20,25 +20,6 @@ - - @@ -286,15 +267,15 @@ - + - - + @@ -312,19 +293,14 @@ - - + - - - - @@ -333,6 +309,9 @@ + + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/DeepLinkViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/DeepLinkViewController.swift index 040e278b..a610db5d 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/DeepLinkViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/DeepLinkViewController.swift @@ -10,8 +10,9 @@ import UIKit class DeepLinkViewController: BaseViewController { - @IBAction func onBackButtonClicked(_ sender: UIButton) { - close() + override func viewDidLoad() { + super.viewDidLoad() + title = "Movies Gallery" } } diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/mul.lproj/DeepLinkViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/mul.lproj/DeepLinkViewController.xcstrings index b167efb9..3d49fda2 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/mul.lproj/DeepLinkViewController.xcstrings +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/DeepLink/mul.lproj/DeepLinkViewController.xcstrings @@ -247,30 +247,6 @@ } } }, - "HsP-Oi-Qbw.normalTitle" : { - "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"HsP-Oi-Qbw\";", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Button" - } - } - } - }, - "ipL-FR-aAC.text" : { - "comment" : "Class = \"UILabel\"; text = \"Movies Gallery\"; ObjectID = \"ipL-FR-aAC\";", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Movies Gallery" - } - } - } - }, "iyX-CP-MPO.text" : { "comment" : "Class = \"UILabel\"; text = \"Conan\"; ObjectID = \"iyX-CP-MPO\";", "extractionState" : "stale", diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/Base.lproj/CustomEventViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/Base.lproj/CustomEventViewController.xib index d4dd63e3..d7aa4646 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/Base.lproj/CustomEventViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/Base.lproj/CustomEventViewController.xib @@ -14,6 +14,7 @@ + @@ -22,24 +23,6 @@ - - @@ -105,10 +88,10 @@ - + - + @@ -120,11 +103,10 @@ - - - + - - - - - - + + - - - - - @@ -182,8 +144,8 @@ - - + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/CustomEventViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/CustomEventViewController.swift index 0b4633ac..ee1b3c3e 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/CustomEventViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/CustomEventViewController.swift @@ -18,11 +18,28 @@ class CustomEventViewController: BaseViewController { } } @IBOutlet weak var eventPropertiesStackView: UIStackView! + @IBOutlet weak var trackEventButton: UIButton! internal var eventPropertiesViews: [String: PropertyView] = [:] + private lazy var advanceEventButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Advance event", for: .normal) + button.applyLiquidGlassStyle(.regular, title: "Advance event") + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(onAdvanceEventTapped), for: .touchUpInside) + return button + }() + // MARK: - Override + override func viewDidLoad() { + super.viewDidLoad() + title = "Track event" + wrapPrimaryScrollContentInCard() + installAdvanceEventButton() + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) UserpilotManager.shared.screen("custom events") @@ -34,18 +51,37 @@ class CustomEventViewController: BaseViewController { trackEvent() } - @IBAction func onBackButtonClicked(_ sender: UIButton) { - close() - } - @IBAction func onAddEventProperty(_ sender: UIButton) { showAddEventPropertyDiaog() } - @IBAction func onAdvanceEventButtonClicked(_ sender: UIButton) { + @objc private func onAdvanceEventTapped() { FlowRoutingManager.shared.openViewController(EventsViewController.newInstance()) } + private func installAdvanceEventButton() { + let trackButton = trackEventButton + ?? view.subviews.compactMap({ $0 as? UIButton }).first + guard let trackButton else { return } + + view.addSubview(advanceEventButton) + + // Move the track button's bottom constraint onto the advance button. + let trackBottomConstraints = view.constraints.filter { + ($0.firstItem as? UIView) == trackButton && $0.firstAttribute == .bottom + || ($0.secondItem as? UIView) == trackButton && $0.secondAttribute == .bottom + } + NSLayoutConstraint.deactivate(trackBottomConstraints) + + NSLayoutConstraint.activate([ + advanceEventButton.leadingAnchor.constraint(equalTo: trackButton.leadingAnchor), + advanceEventButton.trailingAnchor.constraint(equalTo: trackButton.trailingAnchor), + advanceEventButton.topAnchor.constraint(equalTo: trackButton.bottomAnchor, constant: 8), + advanceEventButton.bottomAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16) + ]) + } + } // MARK: - Helper methods diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/mul.lproj/CustomEventViewController.xcstrings b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/mul.lproj/CustomEventViewController.xcstrings index 3c532bb9..72747495 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/mul.lproj/CustomEventViewController.xcstrings +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/CustomEvent/mul.lproj/CustomEventViewController.xcstrings @@ -21,7 +21,7 @@ }, "Cit-w0-0Ta.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"Cit-w0-0Ta\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -109,21 +109,9 @@ } } }, - "mWB-sB-Srg.normalTitle" : { - "comment" : "Class = \"UIButton\"; normalTitle = \"Advance event\"; ObjectID = \"mWB-sB-Srg\";", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Advance event" - } - } - } - }, "UcY-5a-lOY.text" : { "comment" : "Class = \"UILabel\"; text = \"Track event\"; ObjectID = \"UcY-5a-lOY\";", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Base.lproj/EventsViewController.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Base.lproj/EventsViewController.xib index 1f3b8275..107a5f5b 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Base.lproj/EventsViewController.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Base.lproj/EventsViewController.xib @@ -20,27 +20,9 @@ - - - + @@ -48,25 +30,19 @@ - + - - - - - - + - - - + + diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Cells/Base.lproj/EventTableViewCell.xib b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Cells/Base.lproj/EventTableViewCell.xib index 67950933..30be0f80 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Cells/Base.lproj/EventTableViewCell.xib +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Events/Eventslist/Cells/Base.lproj/EventTableViewCell.xib @@ -49,9 +49,9 @@ - - @@ -163,10 +135,10 @@ - + - + @@ -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 49705a5b..00000000 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Extensions/MainViewController+TableView.swift +++ /dev/null @@ -1,44 +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: - showConfigurationDialog() - case .eventsLog: - FlowRoutingManager.shared.openViewController(SDKEventsViewController.newInstance()) - case .autoCapture: - FlowRoutingManager.shared.openViewController(AutoCaptureTestViewController()) - } - } - -} diff --git a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift index 0f375706..d0faf1ac 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/MainViewController.swift @@ -8,24 +8,33 @@ 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] = [.identify, .screens, .events, .eventsLog, .configurations, .autoCapture, .configurations] + required init?(coder: NSCoder) { + super.init(coder: coder) + } - // MARK: - Override override func viewDidLoad() { super.viewDidLoad() + title = "Userpilot" + setupTable() UserpilotManager.shared.settings() + presentConfigIfNeeded() } override func viewWillAppear(_ animated: Bool) { @@ -33,41 +42,72 @@ class MainViewController: BaseViewController { UserpilotManager.shared.screen("main") } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - if let appToken: String? = StorageManager.shared.get(forKey: StorageManager.Keys.appToken), - appToken == nil { - showConfigurationDialog() - } + 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) + ]) } - internal func showConfigurationDialog() { - DialogManager.shared().showConfigurationDialog { [weak self] in - guard self != nil else { return } - self?.showAlertWithAction() + /// 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 } + DispatchQueue.main.async { [weak self] in + self?.openConfigScreen() } } - private func showAlertWithAction() { - let alert = UIAlertController( - title: "Confirm", - message: "Restart the App to take the new configuration", - preferredStyle: .alert - ) - let okAction = UIAlertAction(title: "OK", style: .default) { _ in - exit(0) + private func openConfigScreen() { + if FlowRoutingManager.shared.visibleViewController is ConfigViewController { + return } - alert.addAction(okAction) - present(alert, animated: true) + 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/Models/Content.swift b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Models/Content.swift index 0cf8e579..2630cd87 100644 --- a/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Models/Content.swift +++ b/Sample/UserpilotSwiftSample/UserpilotSample/Featues/Main/Models/Content.swift @@ -13,6 +13,7 @@ enum Content { case events case configurations case eventsLog + case debug case autoCapture var title: String { @@ -27,8 +28,10 @@ enum Content { return "configurations_title".localized case .eventsLog: return "logs".localized + case .debug: + return "Debug" case .autoCapture: - return "Auto Capture Test" + return "Auto Capture" } } } 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/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/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 5b9810bf..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 @@ -37,13 +41,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 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) + } +} 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/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index 8e255f41..f2e03221 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,26 +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 + /// - Returns: true when a screen refresh was accepted into the queue + @discardableResult func publishFakeReloadScreenEvent( - _ experienceType: ExperienceType, + _ experienceType: ExperienceType?, _ experienceId: Int?, - _ markExperienceAsSeen: Bool - ) + isFakeReload: Bool + ) -> Bool /// update seen experiences func experiencePublished( @@ -61,10 +59,10 @@ internal protocol AnalyticsPublishing: AnyObject { ) /** - * Returns whether the experience was already displayed on the current screen entity. + * 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 entity + * 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. */ @@ -73,54 +71,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 @@ -148,41 +127,73 @@ internal class AnalyticsPublisher { return container?.resolve(SessionMonitoring.self) } - /// Decorator used to modify event properties before sending. - private let autoPropertyDecorator: AutoPropertyDecoratoring + /// 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) + } /// 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 + + /// The user session state machine. + private let userSessionStateMachine: UserSessionStateManaging - /// Queue to hold events waiting to be sent - private lazy var eventsToFlush = [Event]() + // MARK: - Queues & State - /// Read-write lock for thread-safe event queue operations - private lazy var readWriteLock = ReadWriteLock() + /// 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. + /// + /// `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) /** - * 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 /** @@ -196,21 +207,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) + ) } } @@ -224,34 +242,50 @@ 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() { - socketManager.updateSocketState(.shuttingDown, forceUpdateState: true) - flushQueue(shouldCloseSocket: true) + tryCatch { + let events = eventsQueue.getAndClear() + // 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 { + 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) ) } } @@ -259,15 +293,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 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() // Close socket connection - socketManager.close() + closeSocket() } /** @@ -276,12 +310,15 @@ 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() { + // Preserve the switch before the socket adopts the queued user's channel. + if storage.userId.isNotEmpty, userId != storage.userId { + userSessionStateMachine.markUserSwitch() + } + updateUserId(userId) } + // connect() gates itself on the socket state - always safe to call + if storage.userId.isNotEmpty { openSocket() } } /** @@ -301,86 +338,183 @@ 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 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. + * 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) + var event = event + + // 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 } + + // Drop identify events that carry nothing new. Non-identify events skip this. + if event.isIdentifyEvent, didHandleIdentifyEvent(event) { return } + + // Hold events while network monitor is still resolving initial state + if !networkMonitor.isReady { + initialQueue.enqueue(event, isInternalEvent: isInternalEvent) return } - guard !didHandleIdentifyEvent(event) else { return } + // 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 + // is throttled inside the monitor, so a burst of events is still one probe. + networkMonitor.recheckIfOffline() + 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 } - - // Socket is opened, process the event immediately - processEvent(event) } } /** - * Handles identify events by checking for duplicate users and caching the event. + * Records an offline screen event in the screen session and reports whether it is worth + * persisting. * - * - Parameter event: The identify event to handle - * - Returns: true if the event should be ignored (same user), false if processing should continue + * 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 didHandleIdentifyEvent(_ event: Event) -> Bool { - guard event.isIdentifyEvent else { return false } + private func shouldStoreScreenEventOffline(_ event: Event) -> Bool { + let isNewScreen = setupScreenEvent(event) + return isNewScreen || experiencesPublisher?.canRequestScreenEvent() == true + } - // When same user, return true to stop and ignore event - guard !(storage.user.isNotEmpty && User.fromJson(storage.user).isSameIdentifyEvent(event: event)) else { - return 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, + * because batch items carry no user id. + */ + private func handleOfflineEvent(_ event: Event) { + let isOfflineUserSwitch = isUserSwitchIdentifyEvent(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 { + updateUserId(userId) } - // Update cached user - storage.temporaryUser = event.toUser().toJson() - cachedIdentifyEvent = event + offlineEventsHandler.saveEventToLocalStorage( + event: event, + clearStoredEventsFirst: isOfflineUserSwitch + ) + } - return false + /** + * Drops identify events that carry nothing new, and caches the pending user for the rest. + * + * - Parameter event: The identify event to handle + * - Returns: true if the event should be ignored, false if processing should continue + */ + private func didHandleIdentifyEvent(_ event: Event) -> Bool { + // A pending identify has not reached the backend yet, so a closed socket means + // `onSocketClosed` is replaying it, not a fresh host-app call. A replay must go through or + // the reconnect never re-identifies the user. + let isPendingReplay = storage.temporaryUser != nil && !socketManager.isSocketOpened + let carriesNoNewData = storage.user.isNotEmpty + && User.fromJson(storage.user).isSameIdentifyEvent(event: event) + + let shouldIgnore = carriesNoNewData && !isPendingReplay + // Update temporary cached user in storage + if !shouldIgnore { storage.temporaryUser = event.toUser().toJson() } + return shouldIgnore } /** - * Handles events when socket is closed by 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() } @@ -391,99 +525,280 @@ 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 { - storage.userId = cachedUserId + updateUserId(userId) + } else if let cachedUserId = getUserIdFromQueue() { + 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 + && 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 + } + + // 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 { + releaseAfterEmptyQueue() + return + } + + userSessionStateMachine.markNormal() + // 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 + /// 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 !cachedSDKEvents.isEmpty() else { return } + processEvent() + } + + /// 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) { + // An empty user id means the queue is orphaned (app-level logout) and must not leak into + // the next user's session. A user switch is the exception: `clean()` blanks the id while + // the new user's identify is still pending, and the queue already holds that user's + // events — clearing here would drop them (e.g. a screen tracked right after `identify`). + if storage.userId.isEmpty, storage.temporaryUser.orEmpty().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) - } + socketManager.publish(event.eventName, payload: identifyPayload(for: event)) + + // Socket is connected with the same user id — request post-identify screen + userSessionStateMachine.markAwaitingInitialScreen() + return true + } + + /// Builds the `user_identify` socket payload from an event's properties and company. + private func identifyPayload(for event: Event) -> [String: Any] { + var payload: [String: Any] = [ + Constants.Analytics.metaDataProperty: event.properties ?? [:] + ] + if let company = event.company, !company.isEmpty { + payload[Constants.Analytics.identifyCompanyProperty] = company } + return payload + } + + /** + * 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. Called once the identify is on the backend so it sees the user first. + */ + private func syncPushToken() { + guard canRequestEvent else { return } + pushNotificationMonitor?.resyncPushToken() } /** * 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 { + 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 isNewScreen { + 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 rejectsAutoCaptureWithoutScreen(event) { 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 - * the title is empty; autocapture joins every available discriminator - * into a fixed-arity key. Missing discriminators degrade to empty - * segments instead of an early return to a coarser key, so events - * never collapse back onto the shared autocapture title. + * the title is empty; autocapture uses screen + interaction/tab context. */ private func trackEventThrottleKey(_ event: Event) -> String { guard case .autoCaptureEvent = event.type else { @@ -496,32 +811,32 @@ extension AnalyticsPublisher: AnalyticsPublishing { trackEventThrottleString(from: properties[key]) } - let rawInteraction = prop(AutoCaptureConstants.rawInteractionType) + let rawInteraction = prop(Constants.AutoCapture.rawInteractionType) return [ trackEventThrottleScreenName(from: event.screen), event.eventName, rawInteraction.isEmpty ? (event.interactionEventName ?? "") : rawInteraction, - prop(AutoCaptureConstants.tabName), - prop(AutoCaptureConstants.tabIndex), - prop(AutoCaptureConstants.hierarchy), - prop(AutoCaptureConstants.accessibilityIdentifier), - prop(AutoCaptureConstants.dialogTitle), - prop(AutoCaptureConstants.targetText), - prop(AutoCaptureConstants.section), - prop(AutoCaptureConstants.selectedIndex), - prop(AutoCaptureConstants.selectedValue), - prop(AutoCaptureConstants.placeholder), - prop(AutoCaptureConstants.accessibilityLabel) + 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`). 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 "" } @@ -532,37 +847,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 @@ -572,29 +867,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() ) } } @@ -603,154 +898,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 shouldSyncManualScreenForInteractionPayload() { - 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() } - } - private func shouldSyncManualScreenForInteractionPayload() -> Bool { - if config.isWrapperSDK { - return !config.isWrapperScreenAutoCaptureEnabled && - config.isWrapperInteractionAutoCaptureEnabled - } - return !config.enableScreenAutoCapture && config.enableInteractionAutoCapture + processingWatchdog = watchdog + watchdogQueue.asyncAfter( + deadline: .now() + Constants.Analytics.stuckProcessingWatchdog, + execute: watchdog + ) } - /* - * 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?.isEmpty != false { - 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) - } - } - } - } - - /** - * 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 { @@ -769,39 +961,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, @@ -810,17 +1005,70 @@ 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 } - flushQueue() + + // 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) + // The token senders are value-guarded, so a returning user whose token is unchanged + // would otherwise never re-pair token ↔ user. Once the identify is on the backend, + // re-assert it so the pairing is always restated alongside the user. + syncPushToken() + } + + 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) { + enqueueScreenRefresh( + isFakeReload: userSessionStateMachine.getPostIdentificationFakeReloadConfig()) + } + resetProcessingEventStatus() + processEvent() + } + } + +} + +// 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) } } @@ -831,51 +1079,25 @@ extension AnalyticsPublisher: SocketSubscription { private extension AnalyticsPublisher { /** - * Clears all cached properties when receiving closed callback from socket. + * Clears cached SDK requests on every logout; user switches retain queued analytics. * - * - 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) { + cachedSDKEvents.clear() + guard clearCachedIdentifyEvent else { return } + eventsQueue.clear() + initialQueue.clear() + 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 { @@ -893,87 +1115,124 @@ extension AnalyticsPublisher { startSession } - /// To return current screen entity - var screenEntity: ScreenViewEntity? { - screenViewEntity + /** + * 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. * + * 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 } - socketManager.publish( - sdkEvent.eventName, - payload: sdkEvent.eventPayload, - socketSubscription: socketSubscription - ) + // 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 + // "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.enqueue(sdkEvent) + + guard canRequestEvent else { + // The cache is drained from `onSocketOpened` once the channel joins. + openSocket() + return + } + 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 { + // 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) + } } } /** - * 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. * + * 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 + * - Parameter isFakeReload: false when this is a real screen refresh (back from background) */ + @discardableResult func publishFakeReloadScreenEvent( - _ experienceType: ExperienceType, + _ experienceType: ExperienceType?, _ experienceId: Int?, - _ markExperienceAsSeen: Bool - ) { + isFakeReload: Bool + ) -> Bool { + var enqueued = false tryCatch { - guard canRequestEvent else { return } - if let screenViewEntity { - // update the seen content to make sure it contains the dismissed content that - // trigger this fake reload - if markExperienceAsSeen { - guard let experienceId else { return } - 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 } + enqueueScreenRefresh(isFakeReload: isFakeReload) + enqueued = true + processEvent() } - } - - /// Suppresses automatic screen capture after sending a fake reload screen event. - /// - /// Fake reload is an SDK-generated screen event used to send `seen_contents` / `seen_surveys` - /// without treating the close of SDK UI as real client navigation. After the fake reload is - /// published, UIKit/SwiftUI may re-fire `viewWillAppear` for the underlying app screen hierarchy. - /// This hook asks the autocapture coordinator to ignore that short lifecycle burst. - private func suppressScreenAutocaptureAfterFakeReload() { - guard config.enableScreenAutoCapture, - config.appFramework == .SwiftUI - else { return } - // Dismissing this instance's SDK content can re-fire `viewWillAppear` on - // every other registered instance's underlying UI as well. Route through - // the resolver so all instances briefly suppress autocapture together. - InstanceResolver.shared.suppressScreenAutoCaptureAfterSDKContent() + return enqueued } /** @@ -987,30 +1246,139 @@ extension AnalyticsPublisher { _ experienceId: Int ) { if experienceType == .flow { - screenViewEntity?.updateSeenFlowExperiences(experienceId) + screenSessionStateMachine?.updateSeenFlowExperiences(experienceId) } else { - screenViewEntity?.updateSeenSurveyExperiences(experienceId) + screenSessionStateMachine?.updateSeenSurveyExperiences(experienceId) } } +} + +// MARK: - Screen Event Publishing + +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) + } + /** - * Checks the current screen's type-specific seen set for the supplied experience. + * Publishes the current screen session state as a screen event, carrying + * session-start state, fake-reload flag, and seen experiences/surveys. * - * 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. + * Cached internal SDK events are flushed before the screen message. + * - Returns: true when a screen push went out */ - 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 + @discardableResult + private func publishScreenEvent( + isFakeReload: Bool = false + ) -> Bool { + ensureScreenSessionStateMachine() + guard let screenSessionStateMachine else { return false } + + // 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 + if let screenTitle = screenEvent.screenTitle { + if shouldSyncManualScreenForInteractionPayload() { + 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 + } + + /// 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 + 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. + /// + /// Fake reload is an SDK-generated screen event used to send `seen_contents` / `seen_surveys` + /// without treating the close of SDK UI as real client navigation. After the fake reload is + /// published, UIKit/SwiftUI may re-fire `viewWillAppear` for the underlying app screen hierarchy. + /// This hook asks the autocapture coordinator to ignore that short lifecycle burst. + private func suppressScreenAutocaptureAfterFakeReload() { + guard config.enableScreenAutoCapture, + config.appFramework == .SwiftUI + else { return } + // Dismissing this instance's SDK content can re-fire `viewWillAppear` on + // every other registered instance's underlying UI as well. Route through + // the resolver so all instances briefly suppress autocapture together. + InstanceResolver.shared.suppressScreenAutoCaptureAfterSDKContent() + } + + /// Acts as the cached identify event: the pending identify still in the queue. + func getUserIdFromQueue() -> String? { + eventsQueue.find(where: { $0.isIdentifyEvent })?.userId } } @@ -1040,4 +1408,17 @@ 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 9b37828b..6a84025f 100644 --- a/Sources/Userpilot/Analytics/Models/Event.swift +++ b/Sources/Userpilot/Analytics/Models/Event.swift @@ -5,56 +5,52 @@ // 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` + /// Queue-only reload metadata for SDK-generated screens; nil for app screen events. + var isFakeReload: Bool? + + // 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? { @@ -77,6 +73,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..883d678c --- /dev/null +++ b/Sources/Userpilot/Analytics/OfflineEventsHandler.swift @@ -0,0 +1,497 @@ +// +// 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) + + /// 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)?) + + /// 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: Constants.DispatchQueues.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 + persistOfflineEvent( + eventStorage, + 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") + } + } + + /* + * 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]] = [] + + // 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 + } + + var eventData: [String: Any]? + + // 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 { + 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 + + /** + * 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. + * + * - 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 ?? "" + var metadata = event.properties ?? [:] + metadata[Constants.Analytics.fakeReload] = false + eventData[Constants.Analytics.metaDataProperty] = metadata + 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 + } + + /** + * 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. + * + * - 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/SessionMonitor.swift b/Sources/Userpilot/Analytics/SessionMonitor.swift deleted file mode 100644 index b73b7d46..00000000 --- a/Sources/Userpilot/Analytics/SessionMonitor.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// AutoPropertyDecorator.swift -// Userpilot SDK -// -// Created by Motasem Hamed on 10/09/2024. -// Copyright © 2024 Userpilot. All rights reserved. -// -// [Brief Description] -// The `SessionMonitor` class provides automatic app lifecycle tracking to handle flushing events. -// - -import Foundation -import UIKit - -/** -Protocol defining session monitoring behavior. -Objects that conform to `SessionMonitoring` are expected to handle lifecycle events such as -app entering the background or foreground. - */ -internal protocol SessionMonitoring: AnyObject { - /// Starts the session monitoring process, setting up observers for app lifecycle events. - func reset() - - /// A flag to mintor app status - var isAppActive: Bool { get } -} - -internal class SessionMonitor: SessionMonitoring { - - /// The analytics publisher responsible for flushing and resuming events. - private let analyticsPublisher: AnalyticsPublishing - - /// 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 - - /// 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) { - self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) - self.storage = container.resolve(DataStoring.self) - - // Add observer for when the app enters the background. - NotificationCenter.default.addObserver( - self, - selector: #selector(didEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - // Add observer for when the app enters the foreground. - NotificationCenter.default.addObserver( - self, - selector: #selector(didEnterForeground), - name: UIApplication.willEnterForegroundNotification, - 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. - 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() - } - } - } - - /// remove notification observer - deinit { - reset() - } - - /// Logic to check if the socket is currently open - var isAppActive: Bool { - _isAppActive - } - - func reset() { - hasInitializedForeground = false - - // Stop listening for further lifecycle callbacks - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didEnterBackgroundNotification, - object: nil) - NotificationCenter.default.removeObserver( - self, - name: UIApplication.willEnterForegroundNotification, - object: nil) - - // Clean up state we previously persisted - storage.sessionDate = nil - } - - /// Called when the app enters the background. - /// This method flushes any pending analytics events. - /// - Parameter notification: The notification object containing information about the event. - @objc - func didEnterBackground(notification: Notification) { - _isAppActive = false - storage.sessionDate = Date() - analyticsPublisher.flush() - } - - /// Called when the app enters the foreground. - /// This method resumes analytics socket connection and event publishing. - /// - Parameter notification: The notification object containing information about the event. - @objc - func didEnterForeground(notification: Notification) { - _isAppActive = true - hasInitializedForeground = true - analyticsPublisher.resume() - } -} 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 1daee493..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 @@ -247,7 +246,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) } @@ -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 } } @@ -454,7 +447,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 @@ -516,9 +509,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 } @@ -531,13 +524,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 } @@ -555,23 +548,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 } @@ -584,18 +577,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 } @@ -604,7 +597,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` @@ -650,7 +643,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 @@ -663,7 +656,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\"" @@ -675,8 +668,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 } @@ -685,10 +678,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 } @@ -736,7 +729,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/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 f566f680..cf3ed408 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UICollectionView+AutoCapture.swift @@ -52,9 +52,9 @@ internal extension UICollectionViewCell { payload.elementText = (touchedView ?? self).resolvedInteractionText(userpilotLabel) } else if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = ignoreInnerHierarchyTextPlaceholder() + payload.elementText = effectiveView.ignoreInnerHierarchyTextPlaceholder() } else { - payload.elementText = touchedView?.getTextContent() + payload.elementText = userpilotResolvedCellText(touchedView: touchedView) payload.accessibilityIdentifier = accessibilityIdentifier payload.accessibilityLabel = touchedView?.getAccessibilityLabelContent() } @@ -63,6 +63,32 @@ internal extension UICollectionViewCell { owningInstance.autoCaptureCoordinator.handleInteractionEvent(payload) } + /// Resolves `target_text` for an item selection, in priority order. + /// + /// The interacted element is the **item**, not the leaf view under the finger, so the item's + /// own text wins: two taps on the same item publish the same text no matter where they land, + /// and an item that renders a large text blob (a JSON preview, a long description) can no + /// longer push that blob into the payload ahead of its title. + /// + /// 1. `UIListContentConfiguration.text` (iOS 14+ list cells) + /// 2. First visible, non-redacted text inside `contentView` (custom cells) + /// 3. The touched view itself (items whose only text lives outside `contentView`) + /// + /// Text-capture policy (config flag, `userpilotRedactText`) and bounding are applied by + /// ``UIView/resolvedInteractionText(_:)`` / ``UIView/getTextContent()``. + /// + /// - Parameter touchedView: The specific view that was touched + /// - Returns: The resolved text content or nil + func userpilotResolvedCellText(touchedView: UIView?) -> String? { + if let title = userpilotCellTitle() { + return resolvedInteractionText(title) + } + if let itemText = contentView.userpilotFirstTextInSubtree() { + return resolvedInteractionText(itemText) + } + return touchedView?.getTextContent() + } + // MARK: - Private Helpers /// Finds the parent UICollectionView @@ -77,49 +103,30 @@ internal extension UICollectionViewCell { return nil } - /// Resolves text content from the cell with priority order - /// - Parameter touchedView: The specific view that was touched - /// - Returns: The resolved text content or nil - private func resolveTextContent(touchedView: UIView?) -> String? { - // 1. Try the specific touched view if it has text - if let touchedView = touchedView { - if let text = extractText(from: touchedView), !text.isEmpty { - return text - } - } - - // 2. Search contentView for any text - return findTextInView(contentView) + /// The item's own title: the iOS 14+ content configuration's text (list cells). + private func userpilotCellTitle() -> String? { + guard #available(iOS 14.0, *) else { return nil } + return UIKitViewResolver.listConfigurationText(contentConfiguration) } +} - /// Extracts text from a specific view - private func extractText(from view: UIView) -> String? { - if let label = view as? UILabel { - return label.text - } - if let button = view as? UIButton { - return button.currentTitle ?? button.titleLabel?.text - } - if let textField = view as? UITextField { - return textField.placeholder // Don't capture actual text for privacy - } - return nil - } +// MARK: - UICollectionReusableView Auto Capture - /// Recursively searches for text content in a view hierarchy - private func findTextInView(_ view: UIView) -> String? { - // Check current view - if let text = extractText(from: view), !text.isEmpty { - return text - } +internal extension UICollectionReusableView { - // Search subviews - for subview in view.subviews { - if let text = findTextInView(subview) { - return text - } + /// Resolves `target_text` for a tap that landed inside a supplementary view (section header, + /// footer, decoration) rather than an item. + /// + /// Same rule as an item: the element is the supplementary view, so its own text wins over + /// whichever leaf the finger hit. Supplementary views have no title API of their own, so + /// resolution starts at the first text in the subtree. + /// + /// - Parameter touchedView: The specific view that was touched + /// - Returns: The resolved text content or nil + func userpilotResolvedSupplementaryText(touchedView: UIView?) -> String? { + if let headerText = userpilotFirstTextInSubtree() { + return resolvedInteractionText(headerText) } - - return nil + return touchedView?.getTextContent() } } diff --git a/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UIControl+AutoCapture.swift index de71f378..ebc50b17 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,10 +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) - if let title = resolvedInteractionText(raw) { - payload.sourceProperties[AutoCaptureConstants.selectedValue] = title + let title = resolvedInteractionText(raw) + if let title { + payload.sourceProperties[Constants.AutoCapture.selectedValue] = title } } @@ -127,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: @@ -136,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) } @@ -146,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: @@ -157,7 +158,7 @@ internal extension UIControl { payload.elementText = getTextContent() } - // Add common properties (getters omit / redact per capture policy) + // Add common properties (getters return "****" when redaction/config disables capture) payload.accessibilityIdentifier = accessibilityIdentifier payload.accessibilityLabel = getAccessibilityLabelContent() diff --git a/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift b/Sources/Userpilot/AutoCapture/Extensions/UIPickerView+AutoCapture.swift index 1f7a726a..9a9b766e 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), - let sanitized = resolvedInteractionText(title) { - payload.sourceProperties[AutoCaptureConstants.selectedValue] = sanitized + 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 fa7b8d92..2081a536 100644 --- a/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift +++ b/Sources/Userpilot/AutoCapture/Extensions/UITableView+AutoCapture.swift @@ -52,9 +52,9 @@ internal extension UITableViewCell { payload.elementText = (touchedView ?? self).resolvedInteractionText(userpilotLabel) } else if effectiveView !== self { payload.targetClass = String(describing: type(of: effectiveView)) - payload.elementText = ignoreInnerHierarchyTextPlaceholder() + payload.elementText = effectiveView.ignoreInnerHierarchyTextPlaceholder() } else { - payload.elementText = touchedView?.getTextContent() + payload.elementText = userpilotResolvedCellText(touchedView: touchedView) payload.accessibilityIdentifier = accessibilityIdentifier payload.accessibilityLabel = touchedView?.getAccessibilityLabelContent() } @@ -63,6 +63,32 @@ internal extension UITableViewCell { owningInstance.autoCaptureCoordinator.handleInteractionEvent(payload) } + /// Resolves `target_text` for a row selection, in priority order. + /// + /// The interacted element is the **row**, not the leaf view under the finger, so the row's own + /// title wins: two taps on the same row publish the same text no matter where they land, and a + /// cell that renders a large text blob (a JSON preview, a long description) can no longer push + /// that blob into the payload ahead of its title. + /// + /// 1. `textLabel` (standard cells) / `UIListContentConfiguration.text` (iOS 14+ cells) + /// 2. First visible, non-redacted text inside `contentView` (custom cells) + /// 3. The touched view itself (rows whose only text lives outside `contentView`) + /// + /// Text-capture policy (config flag, `userpilotRedactText`) and bounding are applied by + /// ``UIView/resolvedInteractionText(_:)`` / ``UIView/getTextContent()``. + /// + /// - Parameter touchedView: The specific view that was touched + /// - Returns: The resolved text content or nil + func userpilotResolvedCellText(touchedView: UIView?) -> String? { + if let title = userpilotCellTitle() { + return title.owner.resolvedInteractionText(title.text) + } + if let rowText = contentView.userpilotFirstTextInSubtree() { + return resolvedInteractionText(rowText) + } + return touchedView?.getTextContent() + } + // MARK: - Private Helpers /// Finds the parent UITableView @@ -77,54 +103,59 @@ internal extension UITableViewCell { return nil } - /// Resolves text content from the cell with priority order - /// - Parameter touchedView: The specific view that was touched - /// - Returns: The resolved text content or nil - private func resolveTextContent(touchedView: UIView?) -> String? { - // 1. Try cell's textLabel first (standard cells) - 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) } - - // 2. Try the specific touched view if it has text - if let touchedView = touchedView { - if let text = extractText(from: touchedView), !text.isEmpty { - return text - } + 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) } - - // 3. Search contentView for any text - return findTextInView(contentView) + return nil } +} - /// Extracts text from a specific view - private func extractText(from view: UIView) -> String? { - if let label = view as? UILabel { - return label.text - } - if let button = view as? UIButton { - return button.currentTitle ?? button.titleLabel?.text +// MARK: - UITableViewHeaderFooterView Auto Capture + +internal extension UITableViewHeaderFooterView { + + /// Resolves `target_text` for a tap that landed inside a section header or footer. + /// + /// Same rule as a row: the element is the header, so its own title wins over whichever leaf + /// view the finger hit. See ``UITableViewCell/userpilotResolvedCellText(touchedView:)``. + /// + /// - Parameter touchedView: The specific view that was touched + /// - Returns: The resolved text content or nil + func userpilotResolvedHeaderFooterText(touchedView: UIView?) -> String? { + if let title = userpilotHeaderFooterTitle() { + return title.owner.resolvedInteractionText(title.text) } - if let textField = view as? UITextField { - return textField.placeholder // Don't capture actual text for privacy + if let headerText = contentView.userpilotFirstTextInSubtree() { + return resolvedInteractionText(headerText) } - return nil + return touchedView?.getTextContent() } - /// Recursively searches for text content in a view hierarchy - private func findTextInView(_ view: UIView) -> String? { - // Check current view - if let text = extractText(from: view), !text.isEmpty { - return text - } + // MARK: - Private Helpers - // Search subviews - for subview in view.subviews { - if let text = findTextInView(subview) { - 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, *), + let text = UIKitViewResolver.listConfigurationText(contentConfiguration) { + return (text, self) } - return nil } } 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/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/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 57e9b54e..2ca19dea 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 0607d3c8..16221b06 100644 --- a/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift +++ b/Sources/Userpilot/AutoCapture/ScreenNameTracker.swift @@ -88,11 +88,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 @@ -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/InteractionEventCache.swift b/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift index d514d82c..fe6efd5e 100644 --- a/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift +++ b/Sources/Userpilot/AutoCapture/Utils/InteractionEventCache.swift @@ -44,15 +44,15 @@ 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 } // 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. diff --git a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift index f53378b9..94fa5293 100644 --- a/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift +++ b/Sources/Userpilot/AutoCapture/Utils/UIKitViewResolver.swift @@ -20,6 +20,19 @@ import UIKit /// (see extension below), which apply Config flags and `userpilotRedact*` on the responder chain. internal enum UIKitViewResolver { + /// The title a modern cell / header-footer view renders through its content configuration. + /// + /// Used for row-level `target_text`, where the element is the row or section header rather + /// than the leaf view under the finger. Cells that still use the legacy `textLabel` read that + /// first; this covers the `UIListContentConfiguration` replacement. + @available(iOS 14.0, *) + static func listConfigurationText(_ configuration: UIContentConfiguration?) -> String? { + guard let text = (configuration as? UIListContentConfiguration)?.text, !text.isEmpty else { + return nil + } + return text + } + /// Resolves the hierarchy path of a view from leaf to root, joined by `;`. /// /// Each segment follows the pattern: @@ -97,7 +110,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 +118,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: "\\\""))\"" } @@ -343,12 +356,12 @@ internal extension UIView { /// 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` + /// - otherwise → `raw`, collapsed to one line and bounded (see ``String/userpilotBoundedText()``) func resolvedInteractionText(_ raw: String?) -> String? { guard let raw, !raw.isEmpty else { return nil } if isInteractionTextCaptureDisabled() { return nil } - if hasUserpilotRedactTextOptIn() { return AutoCaptureConstants.reductText } - return raw + if hasUserpilotRedactTextOptIn() { return Constants.AutoCapture.reductText } + return raw.userpilotBoundedText() } /// Text published when capture resolves to an ignore-inner-hierarchy ancestor. @@ -358,7 +371,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,26 +453,27 @@ internal extension UIView { return nil } if hasUserpilotRedactTextOptIn() { - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } if let direct = userpilotRawDirectText( textFieldPreferPlaceholder: true, includeAccessibilityFallback: false ) { - return direct + return direct.userpilotBoundedText() } if let nested = findLabelText(in: self) { - return nested + return nested.userpilotBoundedText() } return nil } - /// Recursively searches subviews for the first UILabel with non-empty text + /// Recursively searches subviews for the first UILabel with non-empty text. + /// Hidden and `userpilotRedactText` subtrees are skipped so their content is never published. private func findLabelText(in view: UIView) -> String? { - for subview in view.subviews { + for subview in view.subviews where subview.isUserpilotTextReadable { if let label = subview as? UILabel, let text = label.text, !text.isEmpty { return text } @@ -470,6 +484,35 @@ internal extension UIView { return nil } + /// First non-empty text rendered anywhere inside this view's subtree, in front-to-back + /// subview order, ignoring text-capture policy (callers apply it via + /// ``resolvedInteractionText(_:)``). + /// + /// Used for row-level capture (`table_view_cell_selected` / `collection_view_item_selected`), + /// where the interacted element is the whole row rather than the touched leaf. Hidden and + /// `userpilotRedactText` subtrees are skipped. + func userpilotFirstTextInSubtree() -> String? { + for subview in subviews where subview.isUserpilotTextReadable { + if let text = subview.userpilotRawDirectText( + textFieldPreferPlaceholder: true, + includeAccessibilityFallback: false + ) { + return text + } + if let nested = subview.userpilotFirstTextInSubtree() { + return nested + } + } + return nil + } + + /// `false` for views whose text must never be harvested by a subtree crawl: invisible views + /// and views the host opted out of with `userpilotRedactText`. + private var isUserpilotTextReadable: Bool { + guard !isHidden, alpha > 0.01 else { return false } + return !userpilotRedactText + } + /// 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? { @@ -477,7 +520,7 @@ internal extension UIView { return nil } if hasUserpilotRedactAccessibilityLabelOptIn() { - return AutoCaptureConstants.reductText + return Constants.AutoCapture.reductText } guard let label = accessibilityLabel, !label.isEmpty else { @@ -488,4 +531,23 @@ internal extension UIView { } } +// MARK: - Captured text bounding + +internal extension String { + + /// Normalizes captured host text for publishing: every run of whitespace/newlines becomes a + /// single space, and anything past `Constants.AutoCapture.maxTargetTextLength` is cut and + /// suffixed with `Constants.AutoCapture.targetTextTruncationSuffix`. + /// + /// Captured text is arbitrary host content — a cell can render a whole JSON document — so it + /// is bounded before it reaches a payload. + func userpilotBoundedText() -> String { + let collapsed = split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ") + guard collapsed.count > Constants.AutoCapture.maxTargetTextLength else { return collapsed } + let keep = Constants.AutoCapture.maxTargetTextLength + - Constants.AutoCapture.targetTextTruncationSuffix.count + return collapsed.prefix(keep) + Constants.AutoCapture.targetTextTruncationSuffix + } +} + // swiftlint:enable file_length 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/Entities/EventStorage.swift b/Sources/Userpilot/Database/Entities/EventStorage.swift new file mode 100644 index 00000000..6944956b --- /dev/null +++ b/Sources/Userpilot/Database/Entities/EventStorage.swift @@ -0,0 +1,48 @@ +// +// 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) { + 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() + self.token = token + self.userId = userId + self.data = data + self.createdAt = Date().timeIntervalSince1970 * 1_000.0 + self.sizeBytes = data.count + } + + /// Decodes the stored envelope back to a `StoredOfflineEvent`. + func toStoredEvent() -> StoredOfflineEvent? { + do { + return try UserpilotDecoder.shared.decode(StoredOfflineEvent.self, from: data) + } catch { + return nil + } + } +} diff --git a/Sources/Userpilot/Database/Manager/EventDatabaseStorage.swift b/Sources/Userpilot/Database/Manager/EventDatabaseStorage.swift new file mode 100644 index 00000000..9b4e01f7 --- /dev/null +++ b/Sources/Userpilot/Database/Manager/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/Models/DatabaseStats.swift b/Sources/Userpilot/Database/Models/DatabaseStats.swift new file mode 100644 index 00000000..c250f0dc --- /dev/null +++ b/Sources/Userpilot/Database/Models/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/Models/StoredOfflineEvent.swift b/Sources/Userpilot/Database/Models/StoredOfflineEvent.swift new file mode 100644 index 00000000..ad15f0a1 --- /dev/null +++ b/Sources/Userpilot/Database/Models/StoredOfflineEvent.swift @@ -0,0 +1,90 @@ +// +// 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 + +/// 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 + // 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) + + if let payloadDict = try container.decodeIfPresent([String: AnyCodable].self, forKey: .payload) { + payload = payloadDict.mapValues { $0.value } + } else { + payload = nil + } + } +} 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/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/Events/SDKEvent.swift b/Sources/Userpilot/Experiences/Events/SDKEvent.swift index d6db28d4..11e12b59 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 { @@ -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/Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift b/Sources/Userpilot/Experiences/ExperienceOverlayWindow.swift index 5a6d2d2f..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". @@ -147,6 +146,21 @@ internal final class ExperienceOverlayWindow: UIWindow { isHidden = true } + /// Releases the overlay's hold on UIKit once its owning instance is gone. + /// + /// `init` surfaces the window, which puts it in its scene's window list and makes + /// UIKit retain it. Detaching the scene hands that reference back, so a destroyed + /// instance does not leave a hidden overlay in the list for the life of the + /// process — and so the window's final release follows its owner instead of + /// outliving it. Unconditional, unlike `hideIfIdle()`: nothing can present on + /// this overlay again once the owner is gone. + /// + /// Main-thread only, like all UIKit teardown. + func teardown() { + isHidden = true + windowScene = nil + } + // MARK: - Scene Lifecycle /// Collapses the overlay when the scene it lives on disconnects (e.g. the @@ -186,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 diff --git a/Sources/Userpilot/Experiences/ExperienceStateManager.swift b/Sources/Userpilot/Experiences/ExperienceStateMachine.swift similarity index 80% rename from Sources/Userpilot/Experiences/ExperienceStateManager.swift rename to Sources/Userpilot/Experiences/ExperienceStateMachine.swift index dc779cf9..ec50ef44 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. @@ -46,13 +46,11 @@ internal enum ExperienceFlowState { case active(triggerType: TriggerType, content: ExperienceContent) /// Thank you message is displayed after survey completion. + /// + /// 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) - - /// Automatic experience cached because another experience is in progress. - case cachedPendingAutomatic(experience: ExperienceContent) } // MARK: - State Checks @@ -62,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 @@ -105,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() @@ -140,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" } } } @@ -189,6 +173,7 @@ internal protocol ExperienceStateManaging: AnyObject { func markShowingThankYou() func markCachedManual(_ experienceId: String) func markCachedAutomatic(_ experience: ExperienceContent) + func clearCachedExperience() // MARK: - Active Experience Component Management @@ -206,12 +191,20 @@ internal protocol ExperienceStateManaging: AnyObject { // MARK: - High-Level Operations func markActiveFromCurrentState(content: ExperienceContent) + func processCachedExperience() -> ExperienceStateMachine.CachedExperienceAction } -// 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 { + + /// Work saved while another experience is being prepared or displayed. + enum CachedExperienceAction { + case none + case triggerManual(experienceId: String) + case processAutomatic(experience: ExperienceContent) + } // MARK: - Properties @@ -219,6 +212,9 @@ internal final class ExperienceStateManager { 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) { @@ -229,7 +225,7 @@ internal final class ExperienceStateManager { // MARK: - ExperienceStateManaging -extension ExperienceStateManager: ExperienceStateManaging { +extension ExperienceStateMachine: ExperienceStateManaging { // MARK: - State Access @@ -258,7 +254,10 @@ extension ExperienceStateManager: ExperienceStateManaging { } func hasCachedExperience() -> Bool { - state.value.hasCachedExperience() + if case .none = cachedExperience.value { + return false + } + return true } // MARK: - State Transition Methods @@ -299,19 +298,33 @@ extension ExperienceStateManager: ExperienceStateManaging { } func markShowingThankYou() { + // 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") + logger.info("Experience state: ShowingThankYou(preview=%@)", String(isPreview)) } 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 @@ -346,14 +359,14 @@ extension ExperienceStateManager: 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 @@ -361,6 +374,11 @@ extension ExperienceStateManager: 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 adb94d10..1e4f0d04 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 @@ -114,39 +117,45 @@ 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 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) + + /// 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() + /// Utility for managing display delays for surveys and NPS experiences private lazy var delayUtils = DelayUtils() /// Thread-safe lock for managing experience content operations private let experienceQueue = DispatchQueue( - label: DispatchQueueConstants.EXPERIENCE_QUEUE, + label: Constants.DispatchQueues.experienceQueue, 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 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() + return activeExperience != nil || experienceStateMachine.isActivelyRendered() } /// Expereinces presentation style. @@ -170,13 +179,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) } @@ -188,6 +197,7 @@ internal class ExperiencesPublisher: ExperiencesPublishing { * This prevents experiences from being shown to the wrong user. */ func logout() { + npsShownOnCurrentScreen.value = false resetState() } @@ -204,9 +214,11 @@ internal class ExperiencesPublisher: ExperiencesPublishing { func canRequestScreenEvent() -> Bool { return requestFakeScreenReloadEventDate?.isMoreThanOneSecond(from: Date()) ?? true && !hasActiveExperience - && !experienceStateManager.isActive() + && !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() - && !experienceStateManager.hasCachedExperience() + && !experienceStateMachine.hasCachedExperience() } /** @@ -217,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.experienceStateManager.isActive() - || self.experienceStateManager.hasCachedExperience() - || !self.pendingExperiences.isEmpty { - self.experienceStateManager.markCachedManual(experienceId) - self.logger.info("Experience cached - active experience in progress") - return - } + self?.triggerExperienceIfIdle(experienceId) + } + } - self.delayUtils.cancelDelay() - self.experienceStateManager.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 @@ -269,8 +285,25 @@ internal class ExperiencesPublisher: ExperiencesPublishing { /// Hides the overlay window when no experience is currently presented on it. /// Called from dismissal paths so the overlay window doesn't sit visible /// (and consume input focus) while idle. + /// + /// Reached from `resetState`, which runs on the caller's queue — `logout()` and + /// `updateScreen(_:)` are public API and wrappers call them from their own + /// queues (Capacitor from its `bridge` queue). Hiding a window is UIKit work, + /// so marshal it onto the main queue; the `Thread.isMainThread` fast path keeps + /// the already-on-main callers (`endExperience`, `experienceDidFinishDismissing`) + /// synchronous so their hide still lands before their completion handler. + /// + /// Uses the non-creating accessor: an instance that never presented an + /// experience has no overlay to collapse, and building one here would construct + /// (and briefly surface) a window purely in order to hide it. internal func hideExperienceOverlayIfIdle() { - userpilot?.experienceOverlayWindow.hideIfIdle() + if Thread.isMainThread { + userpilot?.existingExperienceOverlayWindow?.hideIfIdle() + } else { + performOn(.main) { [weak self] in + self?.userpilot?.existingExperienceOverlayWindow?.hideIfIdle() + } + } } /** @@ -289,12 +322,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?() @@ -306,8 +339,8 @@ internal class ExperiencesPublisher: ExperiencesPublishing { completion?() return } - if !self.experienceStateManager.hasCachedExperience() { - self.experienceStateManager.markIdle() + if !self.experienceStateMachine.hasCachedExperience() { + self.experienceStateMachine.markIdle() } self.experienceDidFinishDismissing() completion?() @@ -316,7 +349,28 @@ 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() + } + } 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() } @@ -347,14 +401,16 @@ internal class ExperiencesPublisher: ExperiencesPublishing { */ func getActiveMobileContent() -> ExperienceContent? { guard !pendingExperiences.isEmpty else { return nil } - let content = pendingExperiences.first?.content + let content = pendingExperiences.first?.experienceContent clearPendingExperiences() return content } /// Preview mode suppresses normal analytics while draft content is being rendered. func isPreviewExperienceMode() -> Bool { - experienceStateManager.isPreviewMode() || previewSessionTracker.isActive() + // 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 @@ -381,26 +437,32 @@ 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 - if socketManager.isSocketOpened { - requestFakeScreenReloadEventDate = Date() + requestFakeScreenReloadEventDate = Date() + // 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() + ) } - analyticsPublisher.publishFakeReloadScreenEvent( - sdkEvent.getContentType(), - sdkEvent.getContentId(), - false - ) - resetProcessingPreviewExperienceStatus() } 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) } @@ -410,30 +472,24 @@ 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 // 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(), - true + sdkEvent.getContentType(), sdkEvent.getContentId() ) - } else if sdkEvent.isEventForCloseExperience() { - processCachedExperienceAfterClose() } } } @@ -452,10 +508,13 @@ 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 + 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() { resumePendingPreviewExperience() return @@ -464,6 +523,23 @@ extension ExperiencesPublisher: SocketSubscription { } } + /// 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 + } + // MARK: - Socket Event Handling /* @@ -482,13 +558,15 @@ extension ExperiencesPublisher: SocketSubscription { _ eventSent: Bool ) { experienceQueue.async { [weak self] in - guard let self, - !experienceStateManager.isPreviewMode(), + 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, @@ -496,65 +574,60 @@ extension ExperiencesPublisher: SocketSubscription { self.themeHandler.saveTheme(themeData) } - self.processExperienceContentResponse(eventName, response) + // Process content from screen events and fetch experience content events + if eventName == Constants.Event.screenEvent || + eventName == SDKEventsName.fetchExperienceContent.rawValue { + + // 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 self.isDuplicateExperience(experience) { + self.logger.info( + "Ignoring duplicate experience: %@", + experience.experienceId().toString() + ) + return + } + if triggerType == .manual { + self.experienceStateMachine.markManualTrigger( + experience.experienceId().toString() + ) + } else { + self.experienceStateMachine.markAutomaticTrigger(experience) + } + self.pendingExperiences.append( + PendingExperience(experienceContent: experience, triggerType: triggerType) + ) + } + } // Process the first pending experience if let pendingExperience = self.pendingExperiences.first { - if pendingExperience.content.asNPSContent() != nil { + if pendingExperience.experienceContent.asNPSContent() != nil { self.openNPSBottomSheetExperience(pendingExperience) } else { - self.checkCachedThemes(pendingExperience.content.experienceThemeId()) + self.checkCachedThemes(pendingExperience.experienceContent.experienceThemeId()) } } } } - /** - * 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) - ) + /// 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() } /** @@ -567,7 +640,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 } @@ -590,16 +663,16 @@ 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( - PendingExperience(content: experience, triggerType: .manual) + PendingExperience(experienceContent: experience, triggerType: .manual) ) self.checkCachedThemes(experience.experienceThemeId()) } @@ -638,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 } @@ -657,8 +733,7 @@ extension ExperiencesPublisher { */ private func openExperienceFlow() { if let pendingExperience = pendingExperiences.first { - let experienceContent = pendingExperience.content - switch experienceContent { + switch pendingExperience.experienceContent { case .flow(let content): switch content.type { case .carousel: @@ -788,10 +863,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(_ pendingExperience: PendingExperience) { - 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( @@ -808,13 +887,12 @@ extension ExperiencesPublisher { _ surveyTheme: SurveyTheme, _ submissionId: Int64 ) { - experienceStateManager.markShowingThankYou() + experienceStateMachine.markShowingThankYou() performOn(.main) { [weak self] in guard let self = self, let host = self.experiencePresentationHost() else { - self?.resetProcessingExperienceStatus() self?.experienceDidFinishDismissing() return } @@ -836,7 +914,6 @@ extension ExperiencesPublisher { } } thankYouBottomSheetViewController.onDismissCompleted = { [weak self] in - self?.resetProcessingExperienceStatus() self?.experienceDidFinishDismissing() } host.presentBottomSheet(viewController: thankYouBottomSheetViewController) @@ -878,13 +955,13 @@ extension ExperiencesPublisher { presentation: PresentationStyle ) { tryCatch { - let experienceContent = pendingExperience.content - experienceStateManager.markWaitingDelay(pendingExperience.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 @@ -893,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) @@ -904,12 +981,12 @@ extension ExperiencesPublisher { viewController.modalPresentationStyle = .fullScreen } self.activeExperience = viewController - self.experienceStateManager.markActiveFromCurrentState(content: experienceContent) + self.experienceStateMachine.markActive(pendingExperience.triggerType, 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 + self.npsShownOnCurrentScreen.value = true } switch presentation { case .fullScreen, .normal: @@ -941,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 @@ -955,7 +1032,7 @@ extension ExperiencesPublisher { return false } - if experienceStateManager.shouldBypassScreenValidation() { + if pendingExperience.triggerType == .manual || pendingExperience.triggerType == .preview { logger.info("Can show experience: bypassing screen validation") return true } @@ -965,6 +1042,7 @@ extension ExperiencesPublisher { return true } + let experienceContent = pendingExperience.experienceContent let flowContent = experienceContent.asFlowContent() let surveyContent = experienceContent.asSurveyContent() let npsContent = experienceContent.asNPSContent() @@ -1009,18 +1087,19 @@ 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() + // 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() + self.processCachedExperienceAfterClose() } else { - if let lastPending = self.pendingExperiences.last, self.pendingExperiences.count > 1 { - self.pendingExperiences = [lastPending] - self.experienceStateManager.markAutomaticTrigger(lastPending.content) + if let lastContent = self.pendingExperiences.last, self.pendingExperiences.count > 1 { + self.pendingExperiences = [lastContent] self.openExperienceFlow() } } @@ -1034,6 +1113,7 @@ 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) } @@ -1042,12 +1122,13 @@ extension ExperiencesPublisher { tryCatch { delayUtils.cancelDelay() clearPendingExperiences() - npsTrackedScreen = "" + experienceStateMachine.clearCachedExperience() - if hasActiveExperience || experienceStateManager.getActiveComponent() != nil { - endExperience(manualClose: false, completion: completion) + if hasActiveExperience || experienceStateMachine.getActiveComponent() != nil { + endExperience(manualClose: true, completion: completion) } else { - resetProcessingExperienceStatus() + activeExperience = nil + experienceStateMachine.markIdle() hideExperienceOverlayIfIdle() completion?() } @@ -1056,60 +1137,15 @@ extension ExperiencesPublisher { private func resetProcessingExperienceStatus() { activeExperience = nil - experienceStateManager.markIdle() - } - - private func resetProcessingPreviewExperienceStatus(previewSessionId: UInt64? = nil) { - if let previewSessionId { - guard previewSessionTracker.finish(previewSessionId) else { return } - } else { - previewSessionTracker.cancel() - } - resetProcessingExperienceStatus() + experienceStateMachine.markIdle() } /** - * Processes the experience, if any, that was cached while another experience was active. + * Decodes every experience type present in the response, in Flow → Survey → NPS order. * - * 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) - ) - experienceStateManager.markAutomaticTrigger(experience) - checkCachedThemes(experience.experienceThemeId()) - - case .cachedPendingManual(let experienceId): - activeExperience = nil - experienceStateManager.markIdle() - triggerExperience(experienceId) - - default: - 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. + * 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] = [] @@ -1125,39 +1161,30 @@ extension ExperiencesPublisher { 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. - */ + /// `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 } /** - * Returns whether the same content is already active, pending, or cached. + * Whether this experience is already active, queued, 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. + * 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(experienceStateManager.getActiveContent(), experience) { + if sameExperience(experienceStateMachine.getActiveContent(), experience) { return true } - if pendingExperiences.contains(where: { sameExperience($0.content, experience) }) { + if pendingExperiences.contains(where: { sameExperience($0.experienceContent, experience) }) { return true } - return sameExperience(experienceStateManager.getCachedExperienceContent(), experience) + return sameExperience(experienceStateMachine.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. - */ + /// 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 @@ -1173,6 +1200,53 @@ extension ExperiencesPublisher { 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() + } + + private func processCachedExperienceAfterClose() { + 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 .triggerManual(let experienceId): + self.triggerExperienceIfIdle(experienceId) + + case .none: + break + } + } + } } // MARK: - Preview Experience @@ -1180,13 +1254,15 @@ 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, self.previewSessionTracker.isCurrent(previewSessionId) else { return } - self.experienceStateManager.markPreviewMode() + self.experienceStateMachine.markPreviewMode() self.userpilotRemoteSource.fetchPreviewExperience( params: PreviewExperienceQueryParams( - baseUrl: Environment.getExperienceContentUrl(), + baseUrl: Environment.getExperienceContentUrl(), appToken: self.config.token, contentType: queryItems.first(where: { $0.name == "type" })?.value ?? "", contentId: experienceId @@ -1239,8 +1315,10 @@ 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( - PendingExperience(content: experience, triggerType: .preview) + PendingExperience(experienceContent: experience, triggerType: .preview) ) self.themeHandler.saveTheme(theme) self.openExperienceFlow() @@ -1281,21 +1359,15 @@ 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 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 } @@ -1304,6 +1376,10 @@ extension ExperiencesPublisher { return currentScreen } + func mockSetNPSShownOnCurrentScreen(_ shown: Bool) { + npsShownOnCurrentScreen.value = shown + } + func mockActiveExperience(experience: UIViewController) { self.activeExperience = experience } diff --git a/Sources/Userpilot/Experiences/PreviewSessionTracker.swift b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift index a24fe0f7..d10ed272 100644 --- a/Sources/Userpilot/Experiences/PreviewSessionTracker.swift +++ b/Sources/Userpilot/Experiences/PreviewSessionTracker.swift @@ -4,16 +4,31 @@ // // 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? + private var renderedId: 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() } @@ -22,18 +37,22 @@ internal final class PreviewSessionTracker { 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() @@ -43,9 +62,27 @@ internal final class PreviewSessionTracker { return true } + /// Abandons whatever session is active, so its pending responses are ignored. func cancel() { lock.lock() 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/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 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/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/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/Extensions/UIWindow+Extensions.swift b/Sources/Userpilot/Extensions/UIWindow+Extensions.swift index 79ae1f04..3fdea062 100644 --- a/Sources/Userpilot/Extensions/UIWindow+Extensions.swift +++ b/Sources/Userpilot/Extensions/UIWindow+Extensions.swift @@ -200,34 +200,51 @@ 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 } - if let text = capture.labeledView.resolvedInteractionText(capture.label) { - eventProperties[AutoCaptureConstants.targetText] = text + if let resolvedLabel = capture.labeledView.resolvedInteractionText(capture.label) { + eventProperties[Constants.AutoCapture.targetText] = resolvedLabel } } else if useRedactedInner { - if let text = view.ignoreInnerHierarchyTextPlaceholder() { - eventProperties[AutoCaptureConstants.targetText] = text + if let placeholder = view.ignoreInnerHierarchyTextPlaceholder() { + eventProperties[Constants.AutoCapture.targetText] = placeholder } } 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 + if let text = sectionContainerText(for: view) ?? view.getTextContent() { + eventProperties[Constants.AutoCapture.targetText] = text } } InstanceResolver.shared.handleClickTracked(eventProperties, source: view) } + /// Text for a tap that landed inside a table section header/footer or a collection + /// supplementary view. + /// + /// These are single logical elements like rows are, so the container's own text is published + /// rather than whichever leaf the finger hit — the same rule + /// ``UITableViewCell/userpilotResolvedCellText(touchedView:)`` applies to cells. Returns `nil` + /// for taps outside such a container, leaving the regular leaf resolution in place. + private func sectionContainerText(for view: UIView) -> String? { + if let headerFooter = view.findParentTableViewHeaderFooter() { + return headerFooter.userpilotResolvedHeaderFooterText(touchedView: view) + } + if let reusable = view.findParentCollectionReusableView() { + return reusable.userpilotResolvedSupplementaryText(touchedView: view) + } + return nil + } + } diff --git a/Sources/Userpilot/Monitor/NetworkMonitor.swift b/Sources/Userpilot/Monitor/NetworkMonitor.swift new file mode 100644 index 00000000..dd00d113 --- /dev/null +++ b/Sources/Userpilot/Monitor/NetworkMonitor.swift @@ -0,0 +1,504 @@ +// +// 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() + + /// Re-probes reachability if the SDK currently believes it is offline. Throttled internally; + /// safe to call on every event. + func recheckIfOffline() +} + +// 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: Constants.DispatchQueues.networkMonitorState, attributes: .concurrent) + + private var pathMonitor: NWPathMonitor? + private var debounceWorkItem: DispatchWorkItem? + /// 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 + // 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 + + /// 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 + private var _hasInternetAccess: Bool = false // Real internet connectivity + 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 { + 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 + self?._lastReachabilityCheckAt = Date().timeIntervalSince1970 + } + + // 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 + + 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) { + self._isCheckingReachability = false + } + + self.updateInternetAccessState(hasAccess: hasAccess) + } + } + + // 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] { + var hosts: [String] = [] + appendHost(from: socketURL, to: &hosts) + appendHost(from: Constants.RemoteSource.settingsBaseURL, to: &hosts) + appendHost(from: Environment.getExperienceContentUrl(), 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 + + /// 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 + 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/Monitor/SessionMonitor.swift b/Sources/Userpilot/Monitor/SessionMonitor.swift new file mode 100644 index 00000000..04cb9616 --- /dev/null +++ b/Sources/Userpilot/Monitor/SessionMonitor.swift @@ -0,0 +1,150 @@ +// +// AutoPropertyDecorator.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 10/09/2024. +// Copyright © 2024 Userpilot. All rights reserved. +// +// [Brief Description] +// The `SessionMonitor` class provides automatic app lifecycle tracking to handle flushing events. +// + +import Foundation +import UIKit + +/** +Protocol defining session monitoring behavior. +Objects that conform to `SessionMonitoring` are expected to handle lifecycle events such as +app entering the background or foreground. + */ +internal protocol SessionMonitoring: AnyObject { + /// Starts the session monitoring process, setting up observers for app lifecycle events. + func reset() + + /// A flag to mintor app status + var isAppActive: Bool { get } +} + +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 + + /// 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) + + /// 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) { + 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. + NotificationCenter.default.addObserver( + self, + selector: #selector(didEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + // 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(didBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + // 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, UIApplication.shared.applicationState == .active else { return } + self.onAppStart() + } + } + + /// remove notification observer + deinit { + reset() + } + + /// Logic to check if the socket is currently open + var isAppActive: Bool { + _isAppActive.value + } + + func reset() { + // Stop listening for further lifecycle callbacks + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didEnterBackgroundNotification, + object: nil) + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didBecomeActiveNotification, + object: nil) + + hasStartedSession.value = false + + // Clean up state we previously persisted + storage.sessionDate = nil + } + + /// Called when the app enters the background. + /// This method flushes any pending analytics events. + /// - 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() + analyticsPublisher.flush() + } + + /// 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 didBecomeActive(notification: Notification) { + onAppStart() + } + + /// 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() + } + +} diff --git a/Sources/Userpilot/PushNotification/PushNotificationAutoConfig.swift b/Sources/Userpilot/PushNotification/PushNotificationAutoConfig.swift index a88a94e4..25f45007 100644 --- a/Sources/Userpilot/PushNotification/PushNotificationAutoConfig.swift +++ b/Sources/Userpilot/PushNotification/PushNotificationAutoConfig.swift @@ -44,18 +44,27 @@ internal enum PushNotificationAutoConfig { // pointer identity, so adding the same observer twice does not duplicate. pushNotificationMonitors.add(observer as AnyObject) let pendingResponse = response - if pendingResponse != nil { - response = nil - } lock.unlock() // Process any cached response that arrived before this monitor existed. - if let pendingResponse = pendingResponse { - _ = observer.didReceiveNotification( - response: pendingResponse, - completionHandler: {} - ) + guard let pendingResponse = pendingResponse else { return } + + let didHandle = observer.didReceiveNotification( + response: pendingResponse, + completionHandler: {} + ) + + // Consume it only if this observer actually claimed it. A monitor + // declines a response belonging to another token or another user, and + // clearing the cache regardless would let the first monitor to register + // swallow a response meant for an instance still coming up. + guard didHandle else { return } + + lock.lock() + if self.response === pendingResponse { + self.response = nil } + lock.unlock() } /// Returns a snapshot of all currently registered monitors. @@ -88,10 +97,17 @@ internal enum PushNotificationAutoConfig { } /// Called when a push notification is received and handled by the app. - /// If the response contains a Userpilot app token, it is routed directly to the - /// matching instance. Otherwise, registered monitors are tried until one handles - /// the response. If no monitor handles it, the completion handler is executed - /// without any special handling. + /// Registered monitors are tried until one handles the response. A response + /// nobody can handle is cached and replayed to the next monitor to register, + /// so a tap that arrives before the SDK is configured is not lost. + /// + /// Responses are not routed by app token here: every monitor already compares + /// the payload's token against its own instance before claiming a response, so + /// looking the instance up first only duplicated that check — and when the + /// instance for that token did not exist yet, it dropped the response instead + /// of caching it. That is the normal cold-start ordering for the Flutter, + /// React Native and Capacitor wrappers, which configure the SDK from their own + /// runtime, well after the notification is delivered. /// /// - Parameters: /// - response: The response to the notification containing the user's interaction with the notification. @@ -100,58 +116,30 @@ internal enum PushNotificationAutoConfig { _ response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - let userInfo = response.notification.request.content.userInfo - if - let parsedNotification = UserpilotNotification(userInfo: userInfo), - let appToken = parsedNotification.appToken, - !appToken.isEmpty { - guard let instance = Userpilot.instance(forToken: appToken) else { - completionHandler() - return - } - - let didHandle = instance.didReceiveNotification( + // Stop at the first monitor that claims the response. Only one monitor + // executes the completion handler so we don't trigger UIKit's "called + // completionHandler more than once" assertion. + for monitor in currentMonitors() { + let didHandle = monitor.didReceiveNotification( response: response, completionHandler: completionHandler ) - if !didHandle { - completionHandler() + if didHandle { + // A newer response was handled, so an older cached one is stale. + lock.lock() + self.response = nil + lock.unlock() + return } - return } - // Snapshot monitors and, when there are none, cache the response under the SAME - // lock acquisition. This serialises against `register(observer:)`: a registering - // monitor is either already in this snapshot (delivered below), or its `register` - // runs after we cache and therefore observes `response` and replays it. Splitting - // these into two separate lock acquisitions would let a monitor register in the - // gap and miss the push. + // Nobody could take it — usually because the instance it belongs to has not + // been configured yet. Hold it for the next monitor to register, which + // replays it from `register(observer:)`. lock.lock() - let monitors = pushNotificationMonitors.allObjects.compactMap { - $0 as? PushNotificationMonitoring - } - if monitors.isEmpty { - // No monitor yet (e.g. plugin lifecycle race) — cache so the next monitor to - // register can replay the response. - self.response = response - } + self.response = response lock.unlock() - guard !monitors.isEmpty else { - completionHandler() - return - } - - // Stop at the first monitor that claims the response. Only one monitor - // executes the completion handler so we don't trigger UIKit's "called - // completionHandler more than once" assertion. - for monitor in monitors { - let didHandle = monitor.didReceiveNotification( - response: response, - completionHandler: completionHandler - ) - if didHandle { return } - } completionHandler() } diff --git a/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift b/Sources/Userpilot/PushNotification/PushNotificationMonitor.swift index 8dfeba5f..832e33b7 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. @@ -40,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, @@ -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 @@ -73,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 @@ -86,7 +85,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) @@ -102,20 +101,48 @@ 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)) } /// Handles the socket event for sending the push token. @@ -142,7 +169,6 @@ internal class PushNotificationMonitor: PushNotificationMonitoring, SocketSubscr if let cachedToken { setPushToken(cachedToken) } - attemptDeferredNotificationResponse() } // MARK: - Refresh Push Status @@ -250,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( @@ -267,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: @@ -309,14 +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), - socketSubscription: self - ) - } - if let url = parsedNotification.deeplink { linkOpener.handleURL(url) } @@ -324,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/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. diff --git a/Sources/Userpilot/Remote/UserpilotRemoteSource.swift b/Sources/Userpilot/Remote/UserpilotRemoteSource.swift index 9876ea57..839b1ac1 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. @@ -195,7 +195,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 6f39e608..27dee507 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,45 +91,58 @@ 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 + + /// 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? @@ -178,17 +165,9 @@ internal class SocketManager { /// socket susbcriber @Multicast var socketSubscription: SocketSubscription - // 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 } } - } + /// 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 /// Guards the socket state snapshot below. private let stateLock = NSLock() @@ -196,9 +175,9 @@ internal class SocketManager { /// 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`. + /// any queue. 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. @@ -210,6 +189,10 @@ internal class SocketManager { // 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 /** @@ -217,13 +200,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) } // MARK: - State snapshot @@ -275,12 +274,19 @@ extension SocketManager { callback on the URLSession delegate queue, so without this hop `connect()` runs concurrently with `disconnect()`/`teardown()` and the socket is torn down mid-connect. */ + /// 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 shutdown was requested while the settings request was in flight - do not reopen. + // 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.socketState == .shuttingDown { + if self.isShutdownState { self.isFetchingSocketSettings.value = false return } @@ -288,9 +294,8 @@ extension SocketManager { } } - /* - Creates the socket and channel and starts connecting. Must only be called on the main queue. - */ + // 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 @@ -305,22 +310,24 @@ extension SocketManager { 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 + // No socket to dial - release the gate so a later attempt is not blocked forever. isFetchingSocketSettings.value = false return } @@ -340,16 +347,14 @@ extension SocketManager { self.channelJoined = false self.channelJoining = false } - if self.socketState != .shuttingDown { - self.updateSocketState(.closed) - 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 @@ -362,7 +367,7 @@ 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 @@ -372,41 +377,44 @@ extension SocketManager { } phoenixChannel?.join()? .delegateReceive( - SocketManager.successKey, to: self, + Constants.Socket.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() } 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.setChannelDetached() - 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?.setChannelDetached() - 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?.setChannelDetached() - self?.updateSocketState(.closed) self?.isFetchingSocketSettings.value = false } @@ -420,7 +428,25 @@ 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. + /// 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 { + 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 guard let self else { return } self.resetStateSnapshot() @@ -443,13 +469,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 { - withStateLock { channelJoining || socketStateValue == .connecting } + phoenixSocket?.isConnecting == true || phoenixChannel?.isJoining == true } /// Logic to check if the socket is currently open @@ -457,53 +483,41 @@ extension SocketManager: SocketEvents { withStateLock { socketConnected && channelJoined == 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 - /// - /// 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 - ) { - withStateLock { - if forceUpdateState || newSocketState == .error { - socketStateValue = newSocketState - return - } - if socketStateValue == newSocketState || socketStateValue == .error { return } - // Keep shuttingDown until a later connect() force-clears it; otherwise close() - // and transport callbacks would reopen during teardown. - if socketStateValue == .shuttingDown { return } - socketStateValue = 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 { - withStateLock { socketConnected && channelJoined == false } + 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 } - // Resume/identify after flush or logout must be able to dial again. - if isShutdownState { - updateSocketState(.closed, forceUpdateState: true) + // Half-open transport (connected, channel never joined) passes the gate + // 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 { + performOn(.main) { [weak self] in + self?.quarantinePreviousSocket() + } } - // `compareAndSet` is the single-flight gate: it supersedes the locked state claim the - // hotfix added on main, since it already prevents two queues from both dialling. if !isFetchingSocketSettings.compareAndSet(expected: false, new: true) { return } @@ -524,52 +538,61 @@ extension SocketManager: SocketEvents { /// Implementation to close the WebSocket connection func close() { - // Do not clobber shuttingDown: an in-flight fetchSettings callback checks that - // marker in `openSocket()` and must not create a new socket during teardown. - if socketState != .shuttingDown { - updateSocketState(.closed) - } closeSocket() } /// 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()`. + /// 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), 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, - socketSubscription: SocketSubscription? + payload: Payload ) { - 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) - } - } - } - } - } + _ = tryCatch { + phoenixChannel? + .push( + eventName, + payload: payload ?? [:], + timeout: Constants.Socket.pushTimeout + )? + .receive(Constants.Socket.successKey) { [weak self] message in + self?.notifyEventSent(eventName, payload, message, true) + } + .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) } } @@ -578,19 +601,3 @@ extension SocketManager: SocketEvents { 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+Config.swift b/Sources/Userpilot/Userpilot+Config.swift index 87dbc600..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 @@ -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/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..e89fc763 --- /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-APP_TOKEN + + + +``` + +Replace `APP_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 4f19c4f2..e9ec5937 100644 --- a/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md +++ b/Sources/Userpilot/Userpilot.docc/URLSchemeConfiguring.md @@ -2,13 +2,15 @@ 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. ## 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). +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`. @@ -22,12 +24,16 @@ For example, if your Userpilot Token is `NX-12345678` your url scheme value woul $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - userpilot-USERPILOT_TOKEN + userpilot-APP_TOKEN ``` +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 diff --git a/Sources/Userpilot/Userpilot.swift b/Sources/Userpilot/Userpilot.swift index 50ca5e45..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 `SocketEvents` instance that manages WebSocket connections and event-driven communication. - private lazy var socketManager = container.resolve(SocketEvents.self) - - /// Lazy loading of the `SessionMonitoring` instance that manages app lifecycle. + private lazy var socketManager = container.resolve(SocketManaging.self) 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. @@ -106,14 +88,43 @@ public class Userpilot: NSObject { /// /// Marked `internal` so `ExperiencesPublisher` can route presentations /// through it; never exposed to host apps directly. - internal lazy var experienceOverlayWindow: ExperienceOverlayWindow = { - ExperienceOverlayWindow(owningInstance: self) - }() + /// + /// Creating a `UIWindow` is main-thread-only UIKit work, so only presentation + /// paths (which already run on the main queue) may touch this. Teardown paths + /// use `existingExperienceOverlayWindow` instead. + internal var experienceOverlayWindow: ExperienceOverlayWindow { + if let existing = experienceOverlayWindowStorage { return existing } + let overlay = ExperienceOverlayWindow(owningInstance: self) + experienceOverlayWindowStorage = overlay + return overlay + } + + /// The overlay window only if one was already built — never creates one. + /// + /// Teardown paths (logout, screen change, dismissal) collapse the overlay, and + /// an instance that never presented an experience has none to collapse. Reading + /// the creating accessor there would construct — and, per + /// `ExperienceOverlayWindow.init`, momentarily surface — a whole window just to + /// hide it, off the main thread whenever the caller isn't on it. + internal var existingExperienceOverlayWindow: ExperienceOverlayWindow? { + experienceOverlayWindowStorage + } + + /// Backing store for `experienceOverlayWindow`. `nil` until this instance + /// actually presents an experience. + private var experienceOverlayWindowStorage: ExperienceOverlayWindow? // 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? @@ -193,10 +204,37 @@ 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 { Registry.shared.unregister(self) + releaseExperienceOverlayWindowOnMain() + } + + /// Tears the overlay window down on the main thread as this instance goes away. + /// + /// `Registry` holds instances weakly, so a `Userpilot` deallocates on whichever + /// thread the host app (or an embedding SDK) drops its reference on. Everything + /// `teardown()` does is main-thread-only UIKit work, and `UIWindow.dealloc` + /// itself tears down a view hierarchy, so when that thread isn't main the window + /// must outlive this `deinit` by one main-queue hop — the block's capture is what + /// keeps it alive until then. + private func releaseExperienceOverlayWindowOnMain() { + guard let overlay = experienceOverlayWindowStorage else { return } + experienceOverlayWindowStorage = nil + if Thread.isMainThread { + overlay.teardown() + } else { + performOn(.main) { overlay.teardown() } + } } // MARK: - Setup Methods @@ -205,7 +243,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 +256,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 +266,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) @@ -343,7 +387,7 @@ extension Userpilot { } let event = Event( type: .screen(title), - properties: [AutoCaptureConstants.source: AutoCaptureConstants.manualCaptureSourceValue] + properties: [Constants.AutoCapture.source: Constants.AutoCapture.manualCaptureSourceValue] ) analyticsPublisher.publish(event) } @@ -386,7 +430,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..13743a17 --- /dev/null +++ b/Sources/Userpilot/Utilities/Helper/Constants+AutoCapture.swift @@ -0,0 +1,109 @@ +// +// 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 + +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 = "****" + + /// Upper bound for published `target_text`. Captured text is host content (a cell can + /// render a whole JSON blob), so it is collapsed to one line and truncated past this + /// length with `targetTextTruncationSuffix`. + static let maxTargetTextLength = 256 + + /// Marks `target_text` that was cut at `maxTargetTextLength`. + static let targetTextTruncationSuffix = "…" + } +} diff --git a/Sources/Userpilot/Utilities/Helper/Constants.swift b/Sources/Userpilot/Utilities/Helper/Constants.swift index 5bee6d40..e88452a6 100644 --- a/Sources/Userpilot/Utilities/Helper/Constants.swift +++ b/Sources/Userpilot/Utilities/Helper/Constants.swift @@ -14,29 +14,118 @@ 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" -} - -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 configurationDuration = TimeInterval(30 * 60) // 30 minutes +/** + 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 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" + 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" + /// How long fetched SDK settings stay fresh before a refetch. + 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" @@ -47,91 +136,3 @@ internal struct WrapperSDKConstants { static let enableScreenAutoCapture = "WrapperEnableScreenAutoCapture" static let enableInteractionAutoCapture = "WrapperEnableInteractionAutoCapture" } - -/// 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/Helper/MulticastDelegate.swift b/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift index 15a46332..b4b8a4cc 100644 --- a/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift +++ b/Sources/Userpilot/Utilities/Helper/MulticastDelegate.swift @@ -50,41 +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 - /// A set of weak references to the delegates, preventing retain cycles. - /// `NSHashTable.weakObjects()` ensures delegates are automatically removed when deallocated. + /// 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() + + /// 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) { - // Add the delegate only if it's not already in the list. - if !delegates.contains(delegate as AnyObject) { - delegates.add(delegate as AnyObject) + let object = delegate as AnyObject + lock.withLock { + 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) { - // Iterating in reverse to safely remove elements while iterating. - for delegate in delegates.allObjects.reversed() where delegate === delegateToRemove as AnyObject { - delegates.remove(delegate) + let object = delegateToRemove as AnyObject + lock.withLock { + for delegate in delegates.allObjects where delegate === object { + delegates.remove(delegate) + } } } - /// Invokes a closure on all the delegates in the multicast list. - /// - 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) { - // Ensure that force-casting is safe by iterating through the delegates. - for delegate in delegates.allObjects.reversed() { - 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 } } } } diff --git a/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift b/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift index 2c201fc0..2b42dfba 100644 --- a/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift +++ b/Sources/Userpilot/Utilities/Link/DeepLinkHandler.swift @@ -25,9 +25,9 @@ internal class DeepLinkHandler: DeepLinkHandling { 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() + // The scheme is the token as the SDK was configured with it, so a staging token keeps + // its `STG-` prefix: `STG-NX-12345678` matches `userpilot-stg-nx-12345678`. + let isValidScheme = scheme == "userpilot-\(token)".lowercased() guard isValidScheme, url.host == "sdk" else { return nil } // Supported paths: 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/Sources/Userpilot/Utilities/Threading/DelayUtils.swift b/Sources/Userpilot/Utilities/Threading/DelayUtils.swift index 7be11ed0..63ecab77 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. @@ -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/Sources/Userpilot/Utilities/Threading/EventDebounce.swift b/Sources/Userpilot/Utilities/Threading/EventDebounce.swift index f0dbddc2..8820e973 100644 --- a/Sources/Userpilot/Utilities/Threading/EventDebounce.swift +++ b/Sources/Userpilot/Utilities/Threading/EventDebounce.swift @@ -31,7 +31,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..a8c40294 --- /dev/null +++ b/Sources/Userpilot/Utilities/Threading/EventQueue.swift @@ -0,0 +1,108 @@ +// +// EventQueue.swift +// Userpilot SDK +// +// Created by Motasem Hamed on 02/11/2025. +// Copyright © 2025 Userpilot. All rights reserved. +// +// [Brief Description] +// Thread-safe FIFO queue with optional prioritization for internal events. +// + +import Foundation + +internal class EventQueue { + private var queue: [Element] = [] + private let dispatchQueue = DispatchQueue( + label: Constants.DispatchQueues.eventQueue, + attributes: .concurrent + ) + + // MARK: - Write operations (exclusive) + + public func enqueue(_ event: Element, isInternalEvent: Bool = false) { + dispatchQueue.sync(flags: .barrier) { + if isInternalEvent { + self.queue.insert(event, at: 0) + } else { + self.queue.append(event) + } + } + } + + @discardableResult + public func dequeue() -> Element? { + 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() -> Element? { + dispatchQueue.sync { + queue.first + } + } + + public func size() -> Int { + dispatchQueue.sync { + queue.count + } + } + + public func isEmpty() -> Bool { + dispatchQueue.sync { + queue.isEmpty + } + } + + public func getAll() -> [Element] { + dispatchQueue.sync { + queue + } + } + + public func getAndClear() -> [Element] { + dispatchQueue.sync(flags: .barrier) { + let events = queue + queue.removeAll() + return events + } + } + + public func contains(_ event: Element, where compare: (Element, Element) -> Bool) -> Bool { + dispatchQueue.sync { + queue.contains(where: { compare($0, event) }) + } + } + + public func find(where predicate: (Element) -> Bool) -> Element? { + dispatchQueue.sync { + queue.first(where: predicate) + } + } + + public func getFirst() -> Element? { + 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/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift b/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift index fd901bc4..4409ba62 100644 --- a/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift +++ b/Sources/Userpilot/Vendor/SwiftPhoenixClient/PhoenixTransport.swift @@ -316,8 +316,9 @@ 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. + /// 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 diff --git a/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift b/Sources/Userpilot/Vendor/SwiftPhoenixClient/Socket.swift index 7a6aeb28..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? @@ -165,8 +179,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 + } + } //---------------------------------------------------------------------- @@ -650,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/Sources/Userpilot/Version.swift b/Sources/Userpilot/Version.swift index 3ffbb6c4..1cb2a6d6 100644 --- a/Sources/Userpilot/Version.swift +++ b/Sources/Userpilot/Version.swift @@ -13,6 +13,6 @@ import Foundation /// The current version of the Userpilot SDK. private let versionMajor = "1" -private let versionMinor = "3" +private let versionMinor = "4" private let versionPatch = "0" let userpilotVersion = "\(versionMajor).\(versionMinor).\(versionPatch)" diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index cb463265..59a43ae2 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -33,24 +33,30 @@ 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) - - // Assert - XCTAssertTrue(userpilot.socketManager.isShutdownState || !userpilot.socketManager.isSocketOpened) + analyticsPublisher.publish(identify) + + 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_shouldSetupScreenEntity() { + func testPublish_screenEvent_shouldSetupScreenSessionStateMachine() { // Arrange let screenEvent = Event(type: .screen("Home Screen")) userpilot.socketManager.isSocketOpened = true @@ -60,8 +66,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_screenEvent_shouldSyncScreenTrackerForWrapperInteractionOnlyConfig() { @@ -116,7 +122,7 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.socketManager.isSocketOpened = true var didPublishEvent = false - userpilot.socketManager.onPublish = { _, _, _ in + userpilot.socketManager.onPublish = { _, _ in didPublishEvent = true } @@ -135,40 +141,6 @@ class AnalyticsPublisherTests: XCTestCase { wait(for: [expectation], timeout: 2.0) } - func testPublish_autoCaptureEvent_shouldRejectEmptyScreenPayload() { - // Arrange - let logger = MockLogger() - let config = Userpilot.Config(token: "NX-AUTOCAPTURE-\(UUID().uuidString)") - .defaultInstance(false) - config.logger = logger - userpilot = MockUserpilot(config: config) - analyticsPublisher = AnalyticsPublisher(container: userpilot.container) - userpilot.socketManager.isSocketOpened = true - let errorLogged = expectation(description: "autocapture screen error logged") - logger.onError = { message, _ in - if String(describing: message).contains("Auto capture event must have screen") { - errorLogged.fulfill() - } - } - let event = Event( - type: .autoCaptureEvent, - properties: [AutoCaptureConstants.rawInteractionType: "tap"], - screen: [:], - interactionEventName: "tap" - ) - var didPublishEvent = false - userpilot.socketManager.onPublish = { _, _, _ in - didPublishEvent = true - } - - // Act - analyticsPublisher.publish(event) - - // Assert - wait(for: [errorLogged], timeout: 1) - XCTAssertFalse(didPublishEvent) - } - func testPublish_whenSocketNotOpened_shouldCacheEvent() { // Arrange let customEvent = Event(type: .event("test_event")) @@ -194,8 +166,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() { @@ -220,18 +192,76 @@ 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 + 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" @@ -248,7 +278,7 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertTrue(connectCalled) } - func testResume_shouldNotConnectWhenSocketAlreadyOpen() { + func testResume_shouldAskSocketManagerToConnectWhenUserIdExists() { // Arrange userpilot.storage.userId = "test-user" userpilot.socketManager.isSocketOpened = true @@ -260,7 +290,7 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.resume() // Assert - XCTAssertFalse(connectCalled) + XCTAssertTrue(connectCalled) } func testResume_shouldNotConnectWhenUserIdEmpty() { @@ -281,13 +311,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) } @@ -302,7 +331,7 @@ class AnalyticsPublisherTests: XCTestCase { userpilot.socketManager.onClose = { closeCalled = true } // Act - analyticsPublisher.logout(socketState: .closed, shouldClearCachedIdentifyEvent: true) + analyticsPublisher.logout(clearCachedIdentifyEvent: true) // Assert XCTAssertTrue(closeCalled) @@ -316,10 +345,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) @@ -329,15 +358,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 } @@ -353,7 +385,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 = { @@ -368,36 +400,36 @@ 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() { - // 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) } // MARK: - Experience Events Tests @@ -420,13 +452,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 @@ -437,112 +469,319 @@ 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() { + // 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 - let screenEvent = Event(type: .screen("Test Screen")) - analyticsPublisher.publish(screenEvent) + userpilot.offlineEventsHandler.hasCachedEvents = true + userpilot.offlineEventsHandler.holdRestoreCompletion = true + var publishedEvents: [String] = [] + userpilot.socketManager.onPublish = { eventName, _ in + publishedEvents.append(eventName) + } - let expectation = XCTestExpectation(description: "Wait for delayed fake reload publish") + // Act + analyticsPublisher.publishInternalSDKEvent(makePushTokenEvent()) - var publishScreenEventCalled = false - userpilot.socketManager.onPublish = { _, _, _ in - publishScreenEventCalled = true - expectation.fulfill() - } + // Assert - the offline batch owns the wire, the token does not overtake it + XCTAssertFalse(publishedEvents.contains(pushTokenEventName)) - // Act (add delay before publishing, cause of throttling logic) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, true) - } + // Act - the batch lands and the cached SDK events drain + userpilot.offlineEventsHandler.finishRestore() - // Assert (wait for the delayed call) - wait(for: [expectation], timeout: 1.0) - XCTAssertTrue(publishScreenEventCalled) + // Assert + XCTAssertTrue(publishedEvents.contains(pushTokenEventName)) } - func testPublishFakeReloadScreenEvent_withSameTimeForScreenEvent_shouldNotPublishScreenEvent() { - // Arrange + func testPublishInternalSDKEvent_pushTokenWithNoOfflineEvents_shouldStillSendInTheSamePass() { + // Arrange - taking the cached route must not delay the token when nothing is syncing userpilot.socketManager.isSocketOpened = true - let screenEvent = Event(type: .screen("Test Screen")) - analyticsPublisher.publish(screenEvent) - - var publishScreenEventCalled = false - userpilot.socketManager.onPublish = { _, _, _ in publishScreenEventCalled = true } + userpilot.offlineEventsHandler.hasCachedEvents = false + var publishedPayload: [String: Any]? + userpilot.socketManager.onPublish = { eventName, payload in + if eventName == self.pushTokenEventName { publishedPayload = payload } + } // Act - analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, true) + analyticsPublisher.publishInternalSDKEvent(makePushTokenEvent()) // Assert - // Would need to verify socket manager publish was called with fake reload flag - XCTAssertFalse(publishScreenEventCalled) + 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 testPublishFakeReloadScreenEvent_shouldNotAddSeenContent_WhenMarkSeenIsFalse() { + 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 - let screenEvent = Event(type: .screen("Test Screen")) - analyticsPublisher.publish(screenEvent) + analyticsPublisher.onSocketOpened() - let expectation = XCTestExpectation(description: "Wait for delayed fake reload publish") - var fakeReloadPayload: Payload = nil - userpilot.socketManager.onPublish = { _, payload, _ in - fakeReloadPayload = payload - expectation.fulfill() + // 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 (add delay before publishing, cause of throttling logic) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, false) + // 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 - 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) + XCTAssertTrue(publishedEvents.contains(contentEventName)) } - func testExperiencePublished_shouldUpdateSeenExperiences() { - // Arrange - let screenEvent = Event(type: .screen("Test Screen")) - analyticsPublisher.publish(screenEvent) + // 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 - analyticsPublisher.experiencePublished(.flow, 123) + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() // Assert - XCTAssertTrue(analyticsPublisher.screenEntity?.seenExperiences.contains(123) ?? false) + XCTAssertEqual(publishedEvents, ["sdk-event-1", "sdk-event-2", "sdk-event-3"]) } - func testExperiencePublished_shouldUpdateSeenSurveys() { - // Arrange - let screenEvent = Event(type: .screen("Test Screen")) - analyticsPublisher.publish(screenEvent) + 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 - analyticsPublisher.experiencePublished(.survey, 456) + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() - // Assert - XCTAssertTrue(analyticsPublisher.screenEntity?.seenSurveys.contains(456) ?? false) + // 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"]) + } + + /// 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 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 + // 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() @@ -556,7 +795,7 @@ class AnalyticsPublisherTests: XCTestCase { analyticsPublisher.experiencePublished(.flow, flow.id) analyticsPublisher.experiencePublished(.survey, survey.id) - // Assert + // 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( @@ -566,36 +805,226 @@ 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))) + } + + /// 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. + 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 + 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 + publishScreenEventCalled = true + expectation.fulfill() + } + + // 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: 2.0) + XCTAssertTrue(publishScreenEventCalled) + } + + func testPublishFakeReloadScreenEvent_withSameTimeForScreenEvent_shouldNotPublishScreenEvent() { + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.publish(Event(type: .screen("Test Screen"))) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + let published = recordPublishedEvents() + + let sent = analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + + XCTAssertFalse(sent) + XCTAssertEqual(published(), []) + } + + func testExperiencePublished_shouldUpdateSeenExperiences() { + // Arrange + userpilot.socketManager.isSocketOpened = true + let screenEvent = Event(type: .screen("Test Screen")) + analyticsPublisher.publish(screenEvent) + + // Act + analyticsPublisher.experiencePublished(.flow, 123) + + // Assert + 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) + + // Act + analyticsPublisher.experiencePublished(.survey, 456) + + // Assert + 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 @@ -644,11 +1073,50 @@ 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( properties: Payload = nil, - screen: Payload = [AutoCaptureConstants.screenClass: "HomeViewController"], + screen: Payload = [Constants.AutoCapture.screenClass: "HomeViewController"], interactionEventName: String = "tap" ) -> Event { return Event( @@ -660,6 +1128,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, @@ -671,9 +1147,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) } @@ -699,17 +1178,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" ) @@ -722,13 +1201,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 ) @@ -740,9 +1219,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" ] ) @@ -754,17 +1233,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" ) @@ -793,7 +1272,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) @@ -802,6 +1281,359 @@ class AnalyticsPublisherTests: XCTestCase { // Should not reprocess same identify event XCTAssertFalse(publishIdentifyEventCalled) } + + // MARK: - User Switch With A Screen Tracked Right After Identify + + /// A user switch blanks the user id via `clean()` while the new user's identify waits in the + /// queue to be replayed by `onSocketClosed`. Any event queued behind that identify — a screen + /// tracked right after `identify` — must survive the replay, otherwise the queue empties and + /// the post-identify screen event goes out carrying the *previous* screen. + func testUserSwitch_withQueuedScreen_shouldPublishQueuedScreenTitleAndSkipPostIdentifyScreen() { + // Arrange: user N is identified and sitting on the "online queue" screen + userpilot.storage.userId = "userN" + userpilot.storage.user = User(userId: "userN").toJson() ?? "" + userpilot.socketManager.isSocketOpened = true + userpilot.experiencesPublisher.onCanRequestScreenEvent = { true } + + var screenPayloads: [Payload] = [] + userpilot.socketManager.onPublish = { eventName, payload in + if eventName == Constants.Event.screenEvent { screenPayloads.append(payload) } + } + + analyticsPublisher.publish(Event(type: .screen("online queue"))) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + + // Arrange: the new user's identify and a screen right behind it are both queued while the + // socket is joining (mirrors the asynchronous processing on device) + userpilot.socketManager.isJoiningSocket = true + analyticsPublisher.publish(Event(type: .identify("userA"))) + analyticsPublisher.publish(Event(type: .screen("queue s1 home"))) + userpilot.socketManager.isJoiningSocket = false + + // Act: the identify is processed, which switches user and tears the old socket down + analyticsPublisher.onSocketOpened() + + // Act: the socket close replays the pending identify, then the connection comes back + userpilot.socketManager.isSocketOpened = false + analyticsPublisher.onSocketClosed() + userpilot.socketManager.isSocketOpened = true + analyticsPublisher.onSocketOpened() + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + // Assert: only the queued screen event follows the switch — no post-identify screen event — + // and it carries its own title, starting a new session for the new user + XCTAssertEqual(screenPayloads.count, 2) + let payload = screenPayloads.last?.flatMap { $0 } ?? [:] + XCTAssertEqual(payload[Constants.Analytics.screenTitleProperty] as? String, "queue s1 home") + + let metadata = payload[Constants.Analytics.metaDataProperty] as? [String: Any] ?? [:] + XCTAssertEqual(metadata[Constants.Analytics.isSessionStartedProperty] as? Bool, true) + XCTAssertEqual(metadata[Constants.Analytics.fakeReload] as? Bool, false) + } + + // 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. + /// + /// `screenSessionStateMachine` is `private(set)`, so the session has to be built through a real + /// screen event — hence the throttle wait. + private func arrangeReloadableScreen( + title: String = "Reload Screen", + userId: String = "reload-user" + ) { + userpilot.socketManager.isSocketOpened = true + userpilot.storage.userId = userId + userpilot.storage.user = User( + userId: userId, + properties: ["plan": "pro"] + ).toJson() ?? "" + + analyticsPublisher.publish(Event(type: .screen(title))) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + + let throttleWindow = XCTestExpectation(description: "screen throttle window elapsed") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { throttleWindow.fulfill() } + wait(for: [throttleWindow], timeout: 2.0) + } + + /// Records the event names published to the socket from this point on. + private func recordPublishedEvents() -> () -> [String] { + var names = [String]() + let lock = NSLock() + userpilot.socketManager.onPublish = { name, _ in + lock.lock() + names.append(name) + lock.unlock() + } + return { + lock.lock() + defer { lock.unlock() } + return names + } + } + + func testReloadSendsTheScreenEventAlone() { + arrangeReloadableScreen() + let published = recordPublishedEvents() + + analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + + 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] = [] + var screenTitle: String? + userpilot.socketManager.onPublish = { name, payload in + published.append(name) + screenTitle = payload?[Constants.Analytics.screenTitleProperty] as? String + } + + analyticsPublisher.publish(Event(type: .screen("Another Screen"))) + + 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 testReloadSendsOnlyScreen_whenCachedUserIsEmpty() { + arrangeReloadableScreen() + userpilot.storage.user = "" + let published = recordPublishedEvents() + + analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + + XCTAssertEqual(published(), [Constants.Event.screenEvent]) + } + + func testReloadSendsNothing_forAThrottledScreen() { + arrangeReloadableScreen() + analyticsPublisher.publishFakeReloadScreenEvent(.flow, 10, isFakeReload: true) + analyticsPublisher.onSocketEventSent(Constants.Event.screenEvent, nil, Message(), true) + XCTAssertTrue(analyticsPublisher.mockGetEventsToFlush().isEmpty) + let published = recordPublishedEvents() + + let sent = analyticsPublisher.publishFakeReloadScreenEvent(.flow, 11, isFakeReload: true) + + XCTAssertFalse(sent) + XCTAssertEqual(published(), []) + } + + func testDuplicateIdentifyIsDropped() { + arrangeReloadableScreen() + let published = recordPublishedEvents() + + analyticsPublisher.publish( + Event(type: .identify("reload-user"), properties: ["plan": "pro"])) + + XCTAssertEqual(published(), []) + } + + func testIdentifyCarryingNewDataIsForwarded() { + arrangeReloadableScreen() + let published = recordPublishedEvents() + + analyticsPublisher.publish( + Event(type: .identify("reload-user"), properties: ["plan": "enterprise"])) + + XCTAssertEqual(published(), [Constants.Event.identifyEvent]) + } + + func testPushTokenIsReassertedWhenAnIdentifyIsAcknowledged() { + // The token senders are value-guarded, so an unchanged token would never re-pair + // token <-> user without this. + arrangeReloadableScreen() + var resynced = false + userpilot.pushNotificationMonitor.onResyncPushToken = { resynced = true } + + analyticsPublisher.publish( + Event(type: .identify("reload-user"), properties: ["plan": "enterprise"])) + analyticsPublisher.onSocketEventSent(Constants.Event.identifyEvent, nil, Message(), true) + + 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]) + } + + // 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/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/OfflineEventsHandlerTests.swift b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift new file mode 100644 index 00000000..5ba0cdc4 --- /dev/null +++ b/Tests/UserpilotTests/Analytics/OfflineEventsHandlerTests.swift @@ -0,0 +1,377 @@ +// +// 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. + /// 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 + ) 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: 10.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: 10.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_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( + 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/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/Analytics/SessionMonitorTests.swift b/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift index 49c9aba7..d79d6e09 100644 --- a/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift +++ b/Tests/UserpilotTests/Analytics/SessionMonitorTests.swift @@ -42,34 +42,50 @@ 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 testInit_shouldResumeAnalyticsIfAppIsActive() { + 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) - let expectation = self.expectation(description: "Initial active app handling") - - // Act - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - XCTAssertGreaterThanOrEqual(trackedResumeEvent, 0, "Resume may be called if app is active at init") - expectation.fulfill() - } + // Act — cold start, then a background round trip + monitor.didBecomeActive(notification: activation) + monitor.didEnterBackground(notification: background) + monitor.didBecomeActive(notification: activation) // Assert - wait(for: [expectation], timeout: 1.0) + 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_shouldResumeOnce_WhenActivationNotificationArrives() { + monitor.reset() + monitor = nil + var resumeCount = 0 + userpilot.analyticsPublisher.onResume = { resumeCount += 1 } + monitor = SessionMonitor(container: userpilot.container) + + 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) + + XCTAssertEqual(resumeCount, 1, "Init catch-up and the activation must start one session") + XCTAssertTrue(monitor.isAppActive) } func testReset_shouldRemoveNotificationObservers() { @@ -91,7 +107,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 +119,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() { 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/CellInteractionTextTests.swift b/Tests/UserpilotTests/AutoCapture/CellInteractionTextTests.swift new file mode 100644 index 00000000..97034307 --- /dev/null +++ b/Tests/UserpilotTests/AutoCapture/CellInteractionTextTests.swift @@ -0,0 +1,246 @@ +// +// CellInteractionTextTests.swift +// UserpilotTests +// +// Verifies how `target_text` is resolved for `table_view_cell_selected` / +// `collection_view_item_selected`. The regression covered here is a cell whose +// content renders a large text blob (e.g. a JSON preview label): the touched +// leaf view used to be the only text source, so tapping the blob published the +// whole blob as `target_text` while the row's own title was never consulted. +// + +import XCTest +@testable import Userpilot + +final class CellInteractionTextTests: XCTestCase { + + // MARK: - Table view cell + + func testCellText_forDefaultCellTouchedOnNonTextSubview_usesCellTitle() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = "Events log" + let icon = UIImageView() + cell.contentView.addSubview(icon) + + XCTAssertEqual(cell.userpilotResolvedCellText(touchedView: icon), "Events log") + } + + func testCellText_forDefaultCellTouchedOnDetailLabel_usesCellTitle() { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = "Language" + cell.detailTextLabel?.text = "English" + + XCTAssertEqual( + cell.userpilotResolvedCellText(touchedView: cell.detailTextLabel), + "Language", + "Row selection must publish the row title, not whichever label the finger landed on" + ) + } + + func testCellText_forCustomCellTouchedOnJSONPreview_usesRowTitleNotTheBlob() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + let title = UILabel() + title.text = "Event" + let jsonLabel = UILabel() + jsonLabel.numberOfLines = 0 + jsonLabel.text = """ + { + "event_name": "selection_change", + "metadata": { + "hierarchy": "UITableViewCell:attr__index=\\"2\\";UITableView:attr__index=\\"0\\"" + } + } + """ + cell.contentView.addSubview(title) + cell.contentView.addSubview(jsonLabel) + + XCTAssertEqual(cell.userpilotResolvedCellText(touchedView: jsonLabel), "Event") + } + + func testCellText_forCellWithOnlyLongText_isCollapsedAndBounded() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + let blob = UILabel() + blob.text = "{\n \"event_name\": \"" + String(repeating: "a", count: 600) + "\"\n}" + cell.contentView.addSubview(blob) + + guard let resolved = cell.userpilotResolvedCellText(touchedView: blob) else { + return XCTFail("Expected bounded text, got nil") + } + + XCTAssertLessThanOrEqual(resolved.count, Constants.AutoCapture.maxTargetTextLength) + XCTAssertFalse(resolved.contains("\n"), "Multi-line blobs must be collapsed to one line") + XCTAssertTrue(resolved.hasSuffix("…"), "Bounded text is marked as truncated, got \(resolved)") + } + + func testCellText_forHiddenLabel_skipsHiddenText() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + let hidden = UILabel() + hidden.text = "No properties" + hidden.isHidden = true + let visible = UILabel() + visible.text = "Identify" + cell.contentView.addSubview(hidden) + cell.contentView.addSubview(visible) + + XCTAssertEqual(cell.userpilotResolvedCellText(touchedView: cell.contentView), "Identify") + } + + func testCellText_forRedactedLabel_skipsRedactedTextAndUsesNextLabel() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + let secret = UILabel() + secret.text = "4242 4242 4242 4242" + secret.userpilotRedactText = true + let visible = UILabel() + visible.text = "Payment method" + cell.contentView.addSubview(secret) + cell.contentView.addSubview(visible) + + XCTAssertEqual(cell.userpilotResolvedCellText(touchedView: cell.contentView), "Payment method") + } + + func testCellText_forRedactedCell_usesRedactionPlaceholder() { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = "Events log" + cell.userpilotRedactText = true + + XCTAssertEqual( + cell.userpilotResolvedCellText(touchedView: cell.contentView), + Constants.AutoCapture.reductText + ) + } + + /// 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() + textView.text = "Terms and conditions" + cell.contentView.addSubview(textView) + + XCTAssertEqual(cell.userpilotResolvedCellText(touchedView: textView), "Terms and conditions") + } + + // MARK: - Table section header / footer + + func testHeaderText_forTouchedOnNonTextSubview_usesHeaderTitle() { + let header = UITableViewHeaderFooterView(reuseIdentifier: nil) + header.textLabel?.text = "Recent events" + let icon = UIImageView() + header.contentView.addSubview(icon) + + XCTAssertEqual(header.userpilotResolvedHeaderFooterText(touchedView: icon), "Recent events") + } + + func testHeaderText_forCustomHeaderTouchedOnSubtitle_usesHeaderTitle() { + let header = UITableViewHeaderFooterView(reuseIdentifier: nil) + let title = UILabel() + title.text = "Recent events" + let subtitle = UILabel() + subtitle.text = "Tap a row to inspect the full payload JSON that was sent" + header.contentView.addSubview(title) + header.contentView.addSubview(subtitle) + + XCTAssertEqual(header.userpilotResolvedHeaderFooterText(touchedView: subtitle), "Recent events") + } + + func testHeaderText_forRedactedHeader_usesRedactionPlaceholder() { + let header = UITableViewHeaderFooterView(reuseIdentifier: nil) + let title = UILabel() + title.text = "Recent events" + header.contentView.addSubview(title) + header.userpilotRedactText = true + + XCTAssertEqual( + header.userpilotResolvedHeaderFooterText(touchedView: title), + Constants.AutoCapture.reductText + ) + } + + /// 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() { + let header = UICollectionReusableView(frame: .zero) + let title = UILabel() + title.text = "Screens" + let count = UILabel() + count.text = "12" + header.addSubview(title) + header.addSubview(count) + + XCTAssertEqual(header.userpilotResolvedSupplementaryText(touchedView: count), "Screens") + } + + func testSupplementaryText_forViewWithoutText_fallsBackToTouchedView() { + let header = UICollectionReusableView(frame: .zero) + let textView = UITextView() + textView.text = "Section notes" + + XCTAssertEqual(header.userpilotResolvedSupplementaryText(touchedView: textView), "Section notes") + } + + // MARK: - Collection view cell + + func testItemText_forCustomItemTouchedOnJSONPreview_usesRowTitleNotTheBlob() { + let cell = UICollectionViewCell(frame: .zero) + let title = UILabel() + title.text = "Screen" + let blob = UILabel() + blob.text = "{\n \"screen_name\": \"Userpilot\"\n}" + cell.contentView.addSubview(title) + cell.contentView.addSubview(blob) + + XCTAssertEqual(cell.userpilotResolvedCellText(touchedView: blob), "Screen") + } + + func testItemText_forRedactedItem_usesRedactionPlaceholder() { + let cell = UICollectionViewCell(frame: .zero) + let title = UILabel() + title.text = "Screen" + cell.contentView.addSubview(title) + cell.userpilotRedactText = true + + XCTAssertEqual( + cell.userpilotResolvedCellText(touchedView: title), + Constants.AutoCapture.reductText + ) + } +} 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]) } } diff --git a/Tests/UserpilotTests/Database/Models/StoredOfflineEventTests.swift b/Tests/UserpilotTests/Database/Models/StoredOfflineEventTests.swift new file mode 100644 index 00000000..36407a17 --- /dev/null +++ b/Tests/UserpilotTests/Database/Models/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/ExperienceStateManagerTests.swift b/Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift similarity index 68% rename from Tests/UserpilotTests/Experiences/ExperienceStateManagerTests.swift rename to Tests/UserpilotTests/Experiences/ExperienceStateMachineTests.swift index 8e595490..78f04c45 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() { @@ -150,7 +150,7 @@ final class ExperienceStateManagerTests: XCTestCase { stateManager.markPreviewMode() stateManager.markActiveFromCurrentState(content: makeExperienceContent()) - // Act + // Act — the thank-you state replaces the active state that knew it was a preview stateManager.markShowingThankYou() // Assert @@ -158,15 +158,27 @@ final class ExperienceStateManagerTests: XCTestCase { 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") // 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") @@ -180,10 +192,10 @@ final class ExperienceStateManagerTests: 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()) @@ -209,6 +221,73 @@ final class ExperienceStateManagerTests: 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 271b4edf..1835f914 100644 --- a/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift +++ b/Tests/UserpilotTests/Experiences/ExperiencesPublisherTests.swift @@ -56,7 +56,7 @@ final class ExperiencesPublisherTests: XCTestCase { func testCanRequestScreenEvent_shouldReturnFalse_WhenPreviewIsPending() { // Arrange - userpilot.experienceStateManager.markPreviewMode() + userpilot.experienceStateMachine.markPreviewMode() // Act let result = experiencesPublisher.canRequestScreenEvent() @@ -73,7 +73,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() } @@ -95,7 +95,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 } @@ -106,8 +106,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) @@ -121,7 +121,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() @@ -138,16 +138,234 @@ final class ExperiencesPublisherTests: XCTestCase { eventName: SDKEventsName.flowExperienceDismissed.rawValue, eventPayload: ["mobile_content_id": 77] ) - closeEvent.isCloseEvent = true // 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 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") + let requestedManualIds = AtomicReference<[String]>([]) + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in + if event is ThemeContentEvent { + themeRequested.fulfill() + } else if let event = event as? ExperienceContentEvent { + requestedManualIds.update { $0 + [event.experienceId] } + manualReplayed.fulfill() + } + } + experiencesPublisher.onSocketEventSent( + EventType.screenEvent, nil, + Message(payload: MockContentFactory.makeFlowContentPayload()), true + ) + 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: 5.0) + XCTAssertFalse(userpilot.experienceStateMachine.isManualTrigger()) + XCTAssertTrue(requestedManualIds.value.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 + ) + 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.value.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.value.isEmpty, "The close signal precedes actual UI dismissal") + publisher.presentationHost.presentedExperience = nil + experiencesPublisher.experienceDidFinishDismissing() + wait(for: [manualReplayed], timeout: 5.0) + + XCTAssertEqual(requestedManualIds.value, ["manual-B"]) + XCTAssertFalse(userpilot.experienceStateMachine.hasCachedExperience()) + } + + 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 { + 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]) + 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: 5.0) + experiencesPublisher.triggerExperience("manual-B") + let cached = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.userpilot.experienceStateMachine.getCachedExperienceId() == "manual-B" + }, object: nil + ) + wait(for: [cached], timeout: 5.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: [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()) + } + + 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() { @@ -234,23 +452,12 @@ final class ExperiencesPublisherTests: XCTestCase { let screenTitle = "TestScreen" // Act - experiencesPublisher.updateSceen(screenTitle) + experiencesPublisher.updateScreen(screenTitle) // Assert 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") @@ -368,6 +575,136 @@ 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 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") @@ -402,8 +739,8 @@ final class ExperiencesPublisherTests: XCTestCase { .toFlowContent()? .flowContent ) - userpilot.experienceStateManager.markAutomaticTrigger(.flow(content: flow)) - userpilot.experienceStateManager.markActiveFromCurrentState(content: .flow(content: flow)) + userpilot.experienceStateMachine.markAutomaticTrigger(.flow(content: flow)) + userpilot.experienceStateMachine.markActiveFromCurrentState(content: .flow(content: flow)) // Act experiencesPublisher.onSocketEventSent( @@ -415,7 +752,7 @@ final class ExperiencesPublisherTests: XCTestCase { // Assert DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertNil(self.userpilot.experienceStateManager.getCachedExperienceContent()) + XCTAssertNil(self.userpilot.experienceStateMachine.getCachedExperienceContent()) XCTAssertNil(self.experiencesPublisher.getActiveMobileContent()) expectation.fulfill() } @@ -431,7 +768,7 @@ final class ExperiencesPublisherTests: XCTestCase { .flowContent ) userpilot.analyticsPublisher.onIsExperienceSeen = { _ in true } - userpilot.experienceStateManager.markCachedAutomatic(.flow(content: flow)) + userpilot.experienceStateMachine.markCachedAutomatic(.flow(content: flow)) let closeEvent = MockSDKEvent( eventName: SDKEventsName.flowExperienceDismissed.rawValue, eventPayload: ["mobile_content_id": flow.id] @@ -441,10 +778,18 @@ 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.experienceStateManager.getCachedExperienceContent()) + XCTAssertNil(userpilot.experienceStateMachine.getCachedExperienceContent()) XCTAssertNil(experiencesPublisher.getActiveMobileContent()) - if case .idle = userpilot.experienceStateManager.getCurrentState() { + if case .idle = userpilot.experienceStateMachine.getCurrentState() { // Expected state. } else { XCTFail("Expected idle state after ignoring cached seen content") @@ -473,51 +818,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 @@ -527,7 +869,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 } @@ -542,8 +884,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 } @@ -557,12 +899,12 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldSuppressCompletedSurvey_WhenPreviewThankYouIsShowing() { // Arrange var publishedEvent: SDKEvent? - userpilot.experienceStateManager.markPreviewMode() - userpilot.experienceStateManager.markActiveFromCurrentState( + userpilot.experienceStateMachine.markPreviewMode() + userpilot.experienceStateMachine.markActiveFromCurrentState( content: .survey(content: MockContentFactory.makeSurveyContent()) ) - userpilot.experienceStateManager.markShowingThankYou() - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.experienceStateMachine.markShowingThankYou() + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } let completedSurveyEvent = ExperienceSurveyCompletedEvent( @@ -572,17 +914,19 @@ 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) - XCTAssertFalse(userpilot.experienceStateManager.isPreviewMode()) + XCTAssertFalse(userpilot.experienceStateMachine.isPreviewMode()) } func testPublishInternalSDKEvent_shouldPublishCompletedSurvey_WhenThankYouIsNotPreview() { // Arrange var publishedEvent: SDKEvent? - userpilot.experienceStateManager.markShowingThankYou() - userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event, _ in + userpilot.experienceStateMachine.markShowingThankYou() + userpilot.analyticsPublisher.onPublishInternalSDKEvent = { event in publishedEvent = event } let completedSurveyEvent = ExperienceSurveyCompletedEvent( @@ -602,10 +946,11 @@ final class ExperiencesPublisherTests: XCTestCase { func testPublishInternalSDKEvent_shouldResetPreviewMode_WhenPreviewExperienceCloses() { // Arrange - userpilot.experienceStateManager.markPreviewMode() - var markExperienceAsSeen: Bool? - userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, markSeen in - markExperienceAsSeen = markSeen + userpilot.experienceStateMachine.markPreviewMode() + var publishedFakeReload = false + userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in + publishedFakeReload = true + return true } let closeEvent = MockSDKEvent( eventName: SDKEventsName.flowExperienceDismissed.rawValue, @@ -615,10 +960,12 @@ 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.experienceStateManager.isPreviewMode()) - XCTAssertEqual(markExperienceAsSeen, false) + XCTAssertFalse(userpilot.experienceStateMachine.isPreviewMode()) + XCTAssertTrue(publishedFakeReload) } func testPublishInternalSDKEvent_shouldUpdateFakeReloadDate_ForCloseNPSEvent() { @@ -635,23 +982,111 @@ final class ExperiencesPublisherTests: XCTestCase { XCTAssertFalse(canRequest) } - func testPublishInternalSDKEvent_shouldHandleCloseEvent_WithDeepLink() { + /// 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 - let mockEvent = MockSDKEvent(eventName: "close-event", hasDeepLink: true) + userpilot.socketManager.isSocketOpened = false + let mockEvent = MockSDKEvent(eventName: "dismissed_mobile_content") mockEvent.isCloseEvent = true // Act experiencesPublisher.publishInternalSDKEvent(mockEvent) - let result = experiencesPublisher.getActiveMobileContent() + let canRequest = experiencesPublisher.canRequestScreenEvent() // Assert - XCTAssertNil(result) + 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) + } + + /// 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()) + ) + 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 + + experiencesPublisher.publishInternalSDKEvent(event) + + XCTAssertEqual(published, ["dismissed_mobile_content"]) + XCTAssertFalse(userpilot.experienceStateMachine.isActive()) + XCTAssertEqual(reloadCount, 0) } 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() @@ -666,6 +1101,7 @@ final class ExperiencesPublisherTests: XCTestCase { userpilot.analyticsPublisher.onPublishFakeReloadScreenEvent = { _, _, _ in publishFakeReloadEventCalled = true reloadExpectation.fulfill() + return true } // Act @@ -699,21 +1135,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 @@ -746,7 +1180,7 @@ final class ExperiencesPublisherTests: XCTestCase { // 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() } @@ -758,30 +1192,115 @@ final class ExperiencesPublisherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } - func testTriggerPreviewExperience_shouldCloseActiveExperienceAsProgrammaticReset() { + // MARK: - Thread Safety Tests + + func testThreadSafety_multipleSimultaneousAccess() { // Arrange - let closeExpectation = XCTestExpectation(description: "active experience closed silently") - let fetchExpectation = XCTestExpectation(description: "preview fetch requested") - let mockVC = MockUPExperience() - experiencesPublisher.mockActiveExperience(experience: mockVC) + let expectation = XCTestExpectation(description: "Wait for concurrent operations") + expectation.expectedFulfillmentCount = 10 - mockVC.onTriggerClose = { manualClose in - XCTAssertFalse(manualClose) - closeExpectation.fulfill() + // 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: .userInitiated).async { + let mockPayload: [String: Any?] = MockContentFactory.makeFlowContentPayload() + let message = Message(payload: ["payload": mockPayload]) + publisher.onNewMessage(message) + expectation.fulfill() + } } - userpilot.remoteSource.onFetchPreviewExperience = { _, _ in - fetchExpectation.fulfill() + + // 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 = publisher.getActiveMobileContent() + // Should have some content (the last one to be processed) + XCTAssertNotNil(result) + resultExpectation.fulfill() } + wait(for: [resultExpectation], timeout: 10.0) + } + + // MARK: - Overlay Window Threading Tests + + /// Regression: `logout()` runs on whatever queue the host app — or a wrapper such as + /// Capacitor, which calls in from its `bridge` queue — invokes it on. `resetState` + /// reaches the overlay through `hideExperienceOverlayIfIdle()`, which used to + /// *construct* the lazy `ExperienceOverlayWindow` on that background thread. A + /// `UIWindow` built off the main thread crashes UIKit with + /// "Call must be made on main thread" inside `_performAfterCATransactionCommits`. + func testLogout_offTheMainThread_doesNotCreateTheOverlayWindow() { + // Arrange — no experience has been presented, so no overlay exists yet + XCTAssertNil(userpilot.existingExperienceOverlayWindow) // Act - experiencesPublisher.triggerPreviewExperience("preview-123", []) + let loggedOut = XCTestExpectation(description: "logout ran to completion off the main thread") + // 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: 10.0) + + // Assert — collapsing an idle overlay must never build one + XCTAssertNil( + userpilot.existingExperienceOverlayWindow, + "Hiding an overlay that was never needed must not construct a UIWindow" + ) + } + + /// An overlay that *does* exist still has to be collapsed by a logout arriving + /// off the main thread — the fix marshals the UIKit work, it does not drop it. + func testLogout_offTheMainThread_hidesAnExistingOverlayOnTheMainThread() { + // Arrange — build and surface the overlay from the test's main thread + let overlay = userpilot.experienceOverlayWindow + overlay.prepareForPresentation() + XCTAssertFalse(overlay.isHidden) + + // Act + let loggedOut = XCTestExpectation(description: "logout ran to completion off the main thread") + // 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: 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). + let mainQueueDrained = XCTestExpectation(description: "main queue processed the marshalled hide") + performOn(.main) { mainQueueDrained.fulfill() } + wait(for: [mainQueueDrained], timeout: 2.0) // Assert - wait(for: [closeExpectation, fetchExpectation], timeout: 1.0) + XCTAssertTrue(overlay.isHidden, "A logout must collapse an overlay that was already surfaced") } - func testTriggerPreviewExperience_shouldIgnoreStaleResponse_WhenNewerPreviewStarts() throws { + // MARK: - Preview session tracking + + 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: [ @@ -803,47 +1322,208 @@ final class ExperiencesPublisherTests: XCTestCase { } } - // Act + // 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 + // Assert — only the current preview renders; the abandoned one is dropped wait(for: [latestThemeSaved], timeout: 1.0) XCTAssertFalse(savedThemeIds.contains(11)) } - // MARK: - Thread Safety Tests + 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) - func testThreadSafety_multipleSimultaneousAccess() { - // Arrange - let expectation = XCTestExpectation(description: "Wait for concurrent operations") - expectation.expectedFulfillmentCount = 10 + let closeEvent = MockSDKEvent( + eventName: SDKEventsName.flowExperienceDismissed.rawValue, + eventPayload: ["mobile_content_id": 15] + ) + closeEvent.isCloseEvent = true + onScreen.onTriggerClose = { [weak self] _ in + self?.experiencesPublisher.publishInternalSDKEvent(closeEvent) + } - // Act - for _ in 0..<10 { - DispatchQueue.global(qos: .background).async { - let mockPayload: [String: Any?] = MockContentFactory.makeFlowContentPayload() - let message = Message(payload: ["payload": mockPayload]) - self.experiencesPublisher.onNewMessage(message) - expectation.fulfill() - } + let fetchExpectation = XCTestExpectation(description: "preview fetch requested") + userpilot.remoteSource.onFetchPreviewExperience = { _, completion in + completion(.failure(.emptyResponse)) + fetchExpectation.fulfill() } - // Assert - wait(for: [expectation], timeout: 2.0) + // Act — a QR deep link starts a preview while that experience is still closing + experiencesPublisher.triggerPreviewExperience("19", []) - // 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() - // Should have some content (the last one to be processed) - XCTAssertNotNil(result) - resultExpectation.fulfill() + // 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 { + // 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") + 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) + XCTAssertNotNil(experiencesPublisher.getActiveMobileContent(), "The preview renderer consumes its content") + + 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()) + + 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: [resultExpectation], timeout: 1.0) + 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 { @@ -865,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 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 817a49ad..2917c7e4 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 @@ -53,14 +57,11 @@ 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() + var offlineEventsHandler = MockOfflineEventsHandler() + var eventStorage = MockEventStorage() var sessionMonitor = MockSessionMonitor() var pushNotificationMonitor = MockPushNotificationMonitor() var socketManager = MockSocketManager() @@ -70,7 +71,7 @@ class MockUserpilot: Userpilot { var themeHandler = MockThemeHandler() var imageLoader = MockImageLoader() var linkOpener = MockLinkOpening() - var experienceStateManager: ExperienceStateManaging! + var experienceStateMachine: ExperienceStateManaging! var mockLogger = MockLogger() } @@ -183,6 +184,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 +204,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)? @@ -235,17 +242,22 @@ class MockLinkOpening: LinkOpening { func handleURL(_ url: URL) { onHandleURL?(url) } + + var didProcessPendingDeepLink = false + func processPendingDeepLink() { + didProcessPendingDeepLink = true + } } // 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 +278,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,28 +311,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?, Bool) -> Void)? + var onPublishFakeReloadScreenEvent: ((ExperienceType?, Int?, Bool) -> Bool)? func publishFakeReloadScreenEvent( - _ experienceType: ExperienceType, + _ experienceType: ExperienceType?, _ experienceId: Int?, - _ markExperienceAsSeen: Bool - ) { - onPublishFakeReloadScreenEvent?(experienceType, experienceId, markExperienceAsSeen) + isFakeReload: Bool + ) -> Bool { + return onPublishFakeReloadScreenEvent?(experienceType, experienceId, isFakeReload) ?? false } var onExperiencePublished: ((ExperienceType, Int) -> Void)? @@ -341,7 +344,7 @@ class MockAnalyticsPublisher: AnalyticsPublishing { var isStartSession: Bool = false - var screenEntity: ScreenViewEntity? + var screenSessionStateMachine: ScreenSessionStateMachine? } // MARK: - Mock Storage @@ -386,6 +389,11 @@ class MockPushNotificationMonitor: PushNotificationMonitoring { onSetPushToken?(deviceToken) } + var onResyncPushToken: (() -> Void)? + func resyncPushToken() { + onResyncPushToken?() + } + var onRefreshPushStatus: (() -> Void)? func refreshPushStatus(completion: ((UNAuthorizationStatus) -> Void)?) { onRefreshPushStatus?() @@ -402,11 +410,132 @@ class MockPushNotificationMonitor: PushNotificationMonitoring { } return result } +} + +// 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?() + } + + var recheckIfOfflineCount = 0 + func recheckIfOffline() { + recheckIfOfflineCount += 1 + } +} + +// MARK: - Mock Offline Events Handler + +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 + + /// 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 saveSDKEventToLocalStorage(_ sdkEvent: SDKEvent) { + savedSDKEvents.append(sdkEvent) + } + + 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?() + } + + func clearLocalEvents() { + didClearLocalEvents = true + // The real handler deletes every row, internal ones included. + savedEvents.removeAll() + savedSDKEvents.removeAll() + } +} + +// MARK: - Mock Event Storage + +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) + } + + 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 + ) + } - var onAttemptDeferredNotificationResponse: (() -> Void)? - func attemptDeferredNotificationResponse() -> Bool { - onAttemptDeferredNotificationResponse?() - return false + func hasEvents() -> Bool { + !events.isEmpty } } 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 diff --git a/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift b/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift index 88fa350f..deae065e 100644 --- a/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift +++ b/Tests/UserpilotTests/MultiInstance/MultiInstanceIntegrationTests.swift @@ -124,6 +124,48 @@ final class MultiInstanceIntegrationTests: XCTestCase { ) } + // MARK: - Instance Deallocation + + /// `Registry` holds instances weakly, so a `Userpilot` deallocates on whichever + /// thread the host drops its reference on. Its overlay `UIWindow` must not be torn + /// down there: every teardown step is main-thread-only UIKit work, and running it + /// on a background thread trips the same UIKit assertion that crashed logout. + /// + /// The overlay is held strongly here so it stays inspectable after its owner is + /// gone — asserting on ARC release timing would only measure the autorelease pool. + func testInstanceReleasedOffTheMainThread_tearsItsOverlayDownOnTheMainThread() throws { + // Arrange — a surfaced overlay attached to a live scene + var instance: MockUserpilot? = MockUserpilot( + config: Userpilot.Config(token: "NX-\(UUID().uuidString)").defaultInstance(false) + ) + let overlay = try XCTUnwrap(instance?.experienceOverlayWindow) + XCTAssertFalse(overlay.isHidden) + XCTAssertNotNil(overlay.windowScene, "Precondition: the overlay resolved a scene") + + // Act — drop the last strong reference off the main thread + let released = XCTestExpectation(description: "instance deallocated off the main thread") + // 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: 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: 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") + XCTAssertNil( + overlay.windowScene, + "UIKit retains a window in its scene's list, so a destroyed instance would leak the overlay" + ) + } + // MARK: - Shared dropping func testShared_returnsHostInstanceWhenBothCoexist() throws { diff --git a/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift b/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift new file mode 100644 index 00000000..273bef4a --- /dev/null +++ b/Tests/UserpilotTests/PushNotification/PushNotificationAutoConfigTests.swift @@ -0,0 +1,71 @@ +// +// PushNotificationAutoConfigTests.swift +// Userpilot SDK +// + +import XCTest +@testable import Userpilot + +/// A push opened before the SDK is configured. +/// +/// Hybrid wrappers call `initialize` from their own runtime, so the tap can be +/// delivered while no instance exists yet — reliably so in a Capacitor app, +/// whose WebView boots long after `didFinishLaunchingWithOptions`. +final class PushNotificationAutoConfigTests: PushNotificationMonitorTestCase { + + func testDidReceive_replaysTheResponse_whenItsInstanceRegistersLater() throws { + // Arrange: a Userpilot push carrying a token no instance has claimed. + let lateToken = "NX-\(UUID().uuidString)" + let response = try XCTUnwrap( + UNNotificationResponse.mock( + userInfo: .userpilotPushNotification(appToken: lateToken) + ) + ) + let completionCalled = expectation(description: "completion handler called") + + // Act: the tap arrives with nothing able to handle it. + PushNotificationAutoConfig.didReceive(response) { completionCalled.fulfill() } + wait(for: [completionCalled], timeout: 1.0) + + // The instance comes up afterwards, the way it does when a hybrid app + // finally calls initialize. Its monitor registers from `init`. + let lateUserpilot = MockUserpilot( + 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 linkOpened = expectation(description: "deep link opened") + lateUserpilot.linkOpener.onHandleURL = { url in + XCTAssertEqual(url.absoluteString, "app://some-link") + linkOpened.fulfill() + } + + // 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) + } + + func testDidReceive_routesToTheLiveInstance_whenOneIsAlreadyRegistered() throws { + // The monitor built by the test case registered itself on init, so this + // is the warm path: it must still be answered directly, not cached. + let response = try XCTUnwrap( + 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) {} + + 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 776bcdab..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 b4746063..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, @@ -100,8 +114,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 +162,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 +186,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/PushNotificationMonitorTestCase.swift b/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTestCase.swift index 033f38e3..83e69923 100644 --- a/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTestCase.swift +++ b/Tests/UserpilotTests/PushNotification/PushNotificationMonitorTestCase.swift @@ -75,7 +75,7 @@ extension Dictionary where Key == AnyHashable, Value == Any { } } -private extension UNNotificationResponse { +extension UNNotificationResponse { final class KeyedArchiver: NSKeyedArchiver { override func decodeObject(forKey _: String) -> Any { "" } 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/Socket/SocketManagerTests.swift b/Tests/UserpilotTests/Socket/SocketManagerTests.swift index 94e2a6ac..3b103682 100644 --- a/Tests/UserpilotTests/Socket/SocketManagerTests.swift +++ b/Tests/UserpilotTests/Socket/SocketManagerTests.swift @@ -9,344 +9,432 @@ 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() + drainMainQueue() - // 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() + drainMainQueue() + + 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() + drainMainQueue() - func testUpdateSocketState_withForceUpdate_shouldAlwaysUpdate() { - // Arrange - socketManager.updateSocketState(.error) + XCTAssertEqual(fetchCount, 1) + XCTAssertTrue(transports.isEmpty) - // Act - socketManager.updateSocketState(.opened, forceUpdateState: true) + completions.first?(.success(())) + drainMainQueue() - // 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() + drainMainQueue() - // 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 } + } - socketManager.connect() + /// 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) + } - // Act - socketManager.publish( - eventName, - payload: payload, - socketSubscription: specificSubscription + 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( + 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/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.. 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() diff --git a/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift b/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift index 7fb250c7..2f6b5a5b 100644 --- a/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift +++ b/Tests/UserpilotTests/Utilities/Link/DeepLinkHandlerTests.swift @@ -98,11 +98,28 @@ final class DeepLinkHandlerTests: XCTestCase { XCTAssertNil(DeepLinkHandler.Action(url: url, token: "NX-12345")) } - func testAction_stagingToken_acceptsProdScheme() { - let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + func testAction_stagingToken_acceptsStagingScheme() { + let url = URL(string: "userpilot-stg-nx-12345://sdk/experience_preview/EX-123")! XCTAssertNotNil(DeepLinkHandler.Action(url: url, token: "STG-NX-12345")) } + func testAction_stagingToken_rejectsProdScheme() { + let url = URL(string: "userpilot-nx-12345://sdk/experience_preview/EX-123")! + 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")) 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 { 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..335ef801 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() } @@ -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") 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() } } diff --git a/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift b/Tests/UserpilotTests/Utilities/Threading/EventQueueTests.swift new file mode 100644 index 00000000..9503ad50 --- /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..