Skip to content

πŸ”– Release 1.4.0 β€” Offline events, QR preview, identify refresh & concurrency hardening - #52

Merged
motasem-userpilot merged 104 commits into
mainfrom
1.4.0
Sep 27, 2026
Merged

motasem-userpilot merged 104 commits into
mainfrom
1.4.0

Conversation

@motasem-userpilot

@motasem-userpilot motasem-userpilot commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

πŸš€ Description

Release branch for Userpilot iOS SDK 1.4.0 β€” 85 commits, 167 files changed (+11,096 / βˆ’3,801). Userpilot.podspec and Version.swift are bumped 1.3.0 β†’ 1.4.0.

Offline support (new subsystem)

  • Events are persisted and replayed when connectivity returns, including autocapture events and the SDK's own internal events (fake_reload, session/screen bookkeeping).
  • New Database / event-storage layer, mirroring the Android package layout so the two SDKs stay readable side by side.
  • Analytics publishing reworked around network monitoring and a serialized event queue; internal SDK events are drained after offline sync finishes, taken off the queue atomically, and survive a socket-error close.
  • Seen-content state is cleared when the user navigates while offline, so an experience is not wrongly suppressed after reconnect.

Preview / QR review flow

  • Preview experiences can be opened by scanning a QR code, plus support for test push notifications.
  • Preview sessions survive lifecycle changes; preview thank-you screens no longer emit events; preview dismissal no longer has side effects on seen content.

Analytics & identify

  • Unchanged identify calls are deduplicated; the push token is re-synced after an identify acknowledgement.
  • Queued events are preserved across a user switch.

Concurrency & thread-safety hardening

  • Socket lifecycle serialized and shared state guarded; a joining channel is treated as still connecting; the connect gate is released and shutdown preserved during teardown; the receive task is captured before it is detached.
  • New AtomicReference and generic EventQueue primitives replace the ad-hoc ReadWriteLock; the multicast delegate list is lock-guarded; SessionMonitor's session-start and app-active flags are guarded across threads and it now resumes on didBecomeActive rather than willEnterForeground.
  • Pending delays are always cancelled through their own queue; a single shared background queue is reused; dispatch queue labels are named.

Deep links & push notifications

  • Deep links are held until the host can route them, and a push opened before configure is held rather than dropped.
  • Notification taps are no longer gated on an open socket.
  • The URL scheme uses the configured token in lowercase, including any STG- prefix β€” documented in DocC.

Experiences UI

  • Overlay window is torn down with its owner; idle overlay hiding is marshalled onto the main thread.
  • NPS close button kept clear of the progress bar; a suppressed NPS no longer blocks subsequent track content.
  • Experience deduplication improved with seen-status checks.

Autocapture

  • enableInteractionValueCapture now defaults to true (see Migration Notes).
  • target_text is resolved at row level rather than leaf level.
  • Capture policy is enforced on text and label payloads; text/accessibility policy helpers restored.

Security

  • Remote request URLs are no longer logged.
  • Capture policy enforcement above prevents unfiltered text reaching event payloads.

Sample app, docs & CI

  • Demo app UI rebuilt in code, with Debug and Auto Capture QA hubs.
  • README plus new/updated DocC articles: OfflineMode, ScanQRCodeForReview, PushNotifications, PushNotificationsManually, URLSchemeConfiguring, UserpilotNanvigationHandler.
  • CI workflow renamed to Quality Gate (quality-gate.yml), green-gate scripts added and Actions workflows hardened, scripts/release-preflight.sh updated, local agent/plan artefacts gitignored.

πŸ“„ Motivation and Context

1.4.0 is the release train for offline event delivery, the QR-based preview review flow, and analytics delivery changes. Alongside those features it collects the crash- and race-fixes that landed on 1.3.0 and hot-fix/socket-connection β€” the socket lifecycle serialization in particular β€” so main picks them up in one merge.

The concurrency hardening is the thread that runs through most of the bug fixes: offline replay means events now outlive a single connected session, which exposed shared state in SocketManager, SessionMonitor and the analytics publisher that was previously masked by timing.

Changes are mirrored with the Android SDK's 1.4.0 branch (same feature set, same package layout, matching PR template) so wrapper behaviour stays consistent across platforms.

πŸ“¦ Types of Changes

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Refactor (code change that neither fixes a bug nor adds a feature)
  • Optimization (improves performance without changing behavior)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

πŸ”Œ Public API & Wrapper Impact

  • No public API change β€” existing public signatures are unchanged
  • Public API changed, and it is additive only
  • Public API changed in a breaking way (requires a major version bump)

If the public API changed, confirm:

  • New/changed symbols are @objc-exposed and Objective-C-bridgeable, and non-throwing
  • Doc comments added on new public symbols
  • Wrapper migration notes written below

navigationDelegate gained a didSet observer while retaining the same @objc public weak var navigationDelegate: UserpilotNavigationDelegate? signature β€” no bridging change. Everything else added (EventQueue, AtomicReference, the database layer) is internal.

Wrapper migration notes (required for any public API change)

Required for Flutter / React Native / Capacitor: none. No existing public signature changed; no wrapper binding changes are required.

Behavioural note the wrappers should be aware of: enableInteractionValueCapture now defaults to true. No wrapper code change is needed, but wrapper users who did not explicitly set it will start receiving interaction values on autocaptured events β€” see Migration Notes.

πŸ§ͺ How Has This Been Tested?

  • Unit tests (XCTest)
  • Manual testing on real devices
  • Manual testing on simulators
  • Tested on different iOS versions (please list β€” deployment target is iOS 13)
  • Tested with different device types (iPhone, iPad)

XCTest coverage was added or extended across the whole release, notably:

  • New suites: EventQueueTests (+374), AtomicReferenceTests (+209), offline storage / database, analytics state machines, DeepLinkSchemeTests (production vs staging scheme matching), MulticastDelegateTests.
  • Reworked: SocketManagerTests (566 lines changed, restructured for testability), LinkOpenerTests, PushNotificationMonitorTests, DelayUtilsTests, EventDebounceTests.
  • Removed: ReadWriteLockTests alongside the primitive it covered.
  • The timing race was removed from the flush test.

Verification status: the manual-testing boxes above are unchecked because that sign-off has not been recorded on this PR yet β€” please tick them as device/simulator passes are done. The automated gates (xcodebuild test, swiftlint --strict, XCFramework build, pod lib lint) are left for the Green gate CI check on this PR to confirm rather than being asserted here.

πŸ“· Screenshots / Screen Recordings (if appropriate)

Not attached. UI-affecting changes are the NPS close-button/progress-bar spacing, overlay window teardown, and the rebuilt sample-app home with the Debug and Auto Capture hubs β€” all reproducible from the Sample app.

πŸ›  Migration Notes (optional)

1. enableInteractionValueCapture now defaults to true.
Hosts that never set it explicitly will begin capturing interaction values (UISwitch state, UISegmentedControl / UIStepper / UIPickerView / UIDatePicker selections) on autocaptured events. To keep the previous behaviour, opt out explicitly:

Userpilot.Configuration()
    .enableInteractionValueCapture(false)

2. Offline persistence introduces on-device event storage.
Events queued while offline are now written to the SDK's local store and replayed on reconnect. Fresh install only β€” there is no 1.3.0 store to migrate from β€” but expect a small amount of new SDK-owned on-device storage, and expect previously-dropped offline events to now arrive (batched, on reconnect) rather than being lost.

3. URL scheme derivation.
The URL scheme uses the configured token in lowercase, including any STG- prefix. Staging integrations must register userpilot-stg-nx-... for an STG-NX-... token; see the URLSchemeConfiguring DocC article.

⚠️ Deprecations (optional)

None. ReadWriteLock was removed, but it was internal β€” no public surface was deprecated or withdrawn.

🧹 Cleanup Tasks (optional)

  • Keep this PR's template in sync with the Android SDK's 1.4.0 PR, per the note at the top of the template.
  • UserpilotNanvigationHandler.md keeps its existing misspelled filename; renaming it is a separate docs change so DocC links are not broken mid-release.

βœ… Checklist

  • My code follows the project's coding standards and conventions (AGENTS.md)
  • Environment is .PRODUCTION with placeholder socket URL / token, and no NX- in SocketManager
  • xcodebuild test -scheme Userpilot passes, and I added tests where applicable
  • swiftlint --strict is clean (no new warnings or errors in Xcode)
  • XCFramework still builds (device + simulator)
  • I have updated documentation (docs/, README, DocC) if needed
  • My changes are backwards compatible (unless flagged as breaking above)
  • If I added files or resources, Userpilot.podspec still covers them (pod lib lint)
  • No PII, tokens, or secrets are logged

Tests were added throughout (see above), but the four unchecked boxes are the ones that require a local xcodebuild / swiftlint / XCFramework / pod lib lint run β€” deferred to the Green gate check on this PR rather than claimed here. Userpilot.podspec uses the glob Sources/Userpilot/**/*.swift, so the new files are covered by construction.

motasem-userpilot and others added 30 commits June 30, 2026 22:00
✨ Support test push notifications
πŸ“ Update docs
βœ… Add unit tests
🚧 Minor enhancements for some edge cases
…capture events.

✨  Added database/event storage support for offline events.
🚧 Updated analytics publishing to work with offline saving, network monitoring, and serialized event queue behavior.
🚧 Improved event/event-type modeling for identify, track, and autocapture events.
βœ…  Refactored socket tests and testability.
πŸ”₯ Removed unused ReadWriteLock and its tests.
βœ… Added/updated tests for offline storage, event queue, atomic reference, socket, analytics, database, and state machines.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two production crashes shared one defect: socket lifecycle ran on three
queues at once. openSocket() executed on whatever queue fetchSettings
called back on - the URLSession delegate queue, or the caller's on the
cached path - while closeSocket() ran on main. So connect() raced
teardown(): the URLSession was invalidated between being created and
used (NSGenericException), and readyState was read on an already freed
transport (EXC_BAD_ACCESS).

Phoenix already drives its reconnect timer, heartbeat and delegate
callbacks on main, so main is the queue that serializes its state
machine. Open, close and publish now all hop there. The state getters
read a lock-guarded snapshot instead of dereferencing the Phoenix
objects from arbitrary queues, socketState is guarded by the same lock,
and connect() claims the slot in one locked step instead of a
check-then-act. Closing now also releases the socket and channel, which
were kept alive and let publish push into a dead channel's send buffer.

No public API change, so no wrapper impact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QueueType.background was a computed property, so every
performOn(.background) call built its own queue and nothing dispatched
there was ever serialized against anything else. Store it once.

This also serializes ImageLoader's blurCache/imageCache writes, which
were being mutated from N different queues. Relabelled com.app.queue to
com.userpilot.sdk.background so the queue is identifiable in host-app
crash reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
receive() read self.task from inside a detached Task while connect()
reassigned it from another queue - an unsynchronized read of a strong
reference being replaced concurrently, the same hazard the existing
notifyDelegate comment documents. Capture it on the calling thread
instead. Each recursion re-captures the live task and the
receiveMessageTask didSet still cancels a superseded loop.

Touches Vendor/SwiftPhoenixClient (third-party): a two-line change to a
file that already carries local patches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the socket lifecycle serialization fix onto 1.3.0.

Conflict was limited to connect(). Resolved in favour of 1.3.0's
isFetchingSocketSettings.compareAndSet single-flight gate, which already
prevents two queues from both dialling and so supersedes the locked
state claim the hotfix added on main.

The hotfix's shuttingDown bail in openSocket() now also releases that
gate - returning early without clearing it would have wedged connect()
permanently, since 1.3.0 only clears it in the channel callbacks and on
fetch failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge 506e10a took UIViewController+Extensions.swift from the feature
branch but UIKitViewResolver.swift from the 1.4.0 line, deleting the six
capture-policy helpers while keeping their two call sites. Neither parent
was broken; the merged result does not compile.

Restores main's file verbatim, which also brings back the omit-vs-redact
distinction: a disabled capture flag omits the property entirely, while
the per-view redact opt-in emits the "****" placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two production crashes shared one cause: socket lifecycle work was not
serialized. fetchSettings delivers on the URLSession delegate queue, so
connect() created a WebSocket task while a main-queue teardown had already
invalidated the session (NSGenericException). Separately, off-main state
readers dereferenced a transport that teardown had just deallocated
(EXC_BAD_ACCESS in PhoenixTransport.readyState).

openSocket() now hops to main, so every transport create and teardown is
serialized on the queue Phoenix already uses, and the socket, channel and
connection accessors are lock-guarded so a reader holds its own strong
reference. Setters release the replaced value after unlocking, since
NSLock is not recursive.

Touches Vendor/SwiftPhoenixClient (Socket, PhoenixTransport): the dangling
read and the receive-loop task capture are both in vendored code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five queues were built with inline string labels, including a copy-pasted
"com.app.queue". Moves them to Constants.DispatchQueues so every queue is
declared in one place and shows a meaningful label in crash reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1.4.0 moved the autocapture keys under Constants.AutoCapture and kept
`typealias AutoCaptureConstants = Constants.AutoCapture` as a bridge, so
both spellings were live (104 and 71 references). Standardizes on
Constants.AutoCapture, matching Constants.Socket and Constants.Analytics,
and removes the alias β€” the only one of its kind in the codebase.

main still spells it AutoCaptureConstants in 21 files, so code merged from
main will need the same rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several capture sites bypassed the policy. Three gates in
UIApplication+AutoCapture were inverted and published the raw menu or bar
item title only when the customer had disabled text capture. The rest
called shouldRedactText(), which collapses "capture disabled" and "redact
this view" into the same "****" value, so the backend could not tell them
apart.

Every site now goes through resolvedInteractionText,
ignoreInnerHierarchyTextPlaceholder and getAccessibilityLabelContent: a
disabled flag omits the property and only the per-view opt-in emits the
placeholder. Accessibility labels get the same treatment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes that never reached 1.4.0, ported from 1.3.0 (092066f, f2dc033).
They land together because both rewrite the same response-handling region
of ExperiencesPublisher; separating them would leave a commit that does
not compile.

Automatic selection stopped at the first decoded content type, so an
unseen Survey or NPS never showed once a Flow had been seen on that
screen. It now decodes all candidates and picks the first unseen one,
leaves manual fetchExperienceContent responses unfiltered, rejects content
that duplicates the active, pending or cached experience, and drops a
cached experience that became seen before it could be replayed.

A preview also travels through resetState, a network fetch and the
experience queue before it renders, and any of those hops could be
overtaken by a second deep link or a lifecycle reset β€” the abandoned
preview's late response still rendered. PreviewSessionTracker gives each
attempt an id that every hop re-checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thank-you state replaces the active state that knew its triggerType,
so a previewed survey's thank-you screen looked like a real one and
published real events. showingThankYou now carries the preview flag,
captured before the outgoing state is overwritten.

Ported from 1.3.0 (31de0b2) onto 1.4.0's renamed state machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file held only a comment noting that settings fetching had moved to
UserpilotRemoteSource. Nothing references it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IdentifyRefreshStateMachine shipped with no tests and a DEBUG-only
mockIdentifyRefreshState() hook that nothing used. Adds 20 tests over the
per-screen allowance, the anonymous and pending-replay exceptions, the
once-per-refresh push token claim, the rule that a screen change must not
discard an unsettled token obligation, and the documented thread safety.

Mutation-checked: removing the anonymous guard, the onScreenChanged guard
or the consumePushTokenSync state check fails 15 of the 20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MockSocketManager never resolved a push, so the ACK-gated queue released
its single-flight gate only when the 12s watchdog fired. Four autocapture
throttle tests timed out at 2s, and the two single-publish tests passed
for the wrong reason β€” the gate capped them at one regardless of whether
the throttle worked. The helper now simulates the push receipt
asynchronously, the way the real socket callback arrives.

Also carries the unchanged-identify and seen-experience cases for the
dedup fix, and the tail of the Constants.AutoCapture rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5

[Medium risk] Sample app refactored with new UI patterns and debug features.

The PR does not appear safe to merge while event delivery and user-boundary failures remain, alongside the changed capture default and broken sample package reference.

Findings

  1. P1Β Missing sample package β–Ά
  2. P1Β Recovery leaves offline events unsent β–Ά
  3. P1Β New-user events are discarded β–Ά
  4. P1Β Staging preview links are rejected β–Ά
  5. P1Β SecurityΒ Offline events cross user boundaries β–Ά
  6. P1Β Stale probe restores online state β–Ά
  7. P1Β Offline screens lose metadata β–Ά
  8. P1Β Socket callbacks race hook registration β–Ά
  9. P1Β New-user requests are discarded β–Ά
  10. P2Β No-screen scenario still reports screens β–Ά
  11. P2Β Sample screen tests cannot run β–Ά
  12. P2Β Numeric properties display as booleans β–Ά
Summary

This release adds offline event storage and replay, preview and notification flows, and substantial analytics, socket, autocapture, and sample-app changes. Since the previous review, it also changed the interaction-value capture default and updated its documentation and test. That default now conflicts with the migration behavior described for this release.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    UI[Control interaction] --> Capture[Autocapture]
    Config[Interaction-value capture setting] --> Capture
    Capture --> Publisher[Analytics publisher]
    Publisher -->|Online| Socket[Socket]
    Publisher -->|Offline| Store[Local event store]
    Store -->|Reconnect| Socket
Loading

Reviews (8) Β· Last reviewed commit: "πŸ› fix(AutoCapture): Disable interaction..."

AB68595F30594DA300EAEF1A /* XCLocalSwiftPackageReference "../../../ios-sdk copy" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../../../ios-sdk";
relativePath = "../../../ios-sdk copy";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing sample package

The project now looks for ../../../ios-sdk copy, outside the repository checkout. The sample archive workflow checks out only this repository, so it cannot resolve that package and cannot build the sample app.

Knowledge Base Used: Sample app integrations

Comment thread Sources/Userpilot/Monitor/NetworkMonitor.swift
Comment thread Sources/Userpilot/Socket/SocketManager.swift
Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift
Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift Outdated
Comment thread Sources/Userpilot/PushNotification/PushNotificationAutoConfig.swift
A failed probe could leave a connected interface marked offline until
its network path changed. Recheck reachability when events arrive,
with throttling to prevent bursts of probes, and cover recovery.
Resolve captured table titles against the label that owns the text.
This respects redaction on cell and header labels while keeping
unredacted titles available when only the detail label is redacted.
Apply the same screen-context check before sending or storing an
interaction. Treat empty screen payloads as missing so offline replay
cannot submit interactions that the live path should reject.
Offline batch items carry no user ID and inherit the socket user.
Drop stored rows belonging to a different user before constructing
the batch, and cover mixed-user replay and missing-user fallback.
Concurrent pushes and heartbeats could reuse a reference and route
an ACK to the wrong event. Guard makeRef in the vendored
SwiftPhoenixClient/Socket.swift with a lock, preserving wraparound
and caller ordering, with concurrent reference coverage.
Queue internal identifies ahead of screens and wait for resolution
without running host identify bookkeeping or repeating token sync.
Preserve queued user-switch events and waiting reloads, match ACKs
to queue heads, and flush screens without injecting new identifies.
Keep regression coverage with the queue behavior it verifies.
Verify payloads, state transitions, callbacks, and ACK results in
existing cases. Remove the empty sample test and unused mock hook,
and make asynchronous checks less timing-sensitive.
Keep LoggingTests.swift restored as requested.
Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift Outdated
Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift Outdated
Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift Outdated
// 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recovery leaves offline events unsent

If this probe finds the endpoint available and no further event arrives, the availability callback only drains the initial queue. It does not open a socket or start processing persisted events. The event that prompted the probe and the existing offline backlog therefore remain unsent until another activity or interface transition.

Knowledge Base Used: Analytics events and sessions

Restore analytics publishing from 8731504 and remove the optional
identify refresh setting, ACK tracking, and screen hooks.
Keep the test improvements from d08cfe6 and adapt screen cases.

BREAKING CHANGE: Remove enableRequestIdentifyBeforeScreen(_:).
Integrations using this unreleased setter must drop the call.
Current Flutter, React Native, and Capacitor wrappers need no changes.
Comment on lines +250 to +254
// In case of a new user, clear all queue and cache the identify new user event only
let newUserEvents = events.filter { $0.isIdentifyEvent && $0.userId != storage.userId }
if let firstNewUserEvent = newUserEvents.first {
storage.temporaryUser = firstNewUserEvent.toUser().toJson()
eventsQueue.enqueue(firstNewUserEvent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New-user events are discarded

If the app backgrounds while a new-user identify is queued behind an in-flight event, flush() clears the queue and keeps only that identify. Screens and track events queued for the new user are neither sent nor restored on foreground, so that user's first-session telemetry is lost.

Knowledge Base Used: Analytics events and sessions

Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift
@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Comments Outside Diff

These findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.

  • P1Β Control values stop being captured Sources/Userpilot/Userpilot+Config.swift:95 β–Ά

    If a host already enables interaction autocapture without setting enableInteractionValueCapture separately, changing this default to false stops its events from including switch states, selected values, and dates after upgrading. The control-value capture paths all check this option, so this also reverses the 1.4.0 migration behavior described in the PR.

Give generated screens their own entries in the analytics queue so
their replies cannot remove a following screen or track event.
Preserve reload metadata and verify delivery order and callbacks.
Retain only the latest identify when backgrounding during a user switch.
Preserve it through socket closure and mark the switch before resume
adopts its user ID. Verify metadata and first-screen session flags.
Track the session that reached the renderer and settle it when UIKit
dismissal completes. Prevent an outgoing experience from cancelling
a newer QR preview and cover replacement and final cleanup.
The change is internal to experience lifecycle handling.
Let navigation trigger screen evaluation when a preview closes with a
deep link. Apply the normal experience close exclusion to the preview
reload request while keeping cleanup at dismissal completion.
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Staging preview links are rejected

For a staging token such as STG-NX-12345, Builder preview links use the unprefixed userpilot-nx-12345 scheme. Comparing against the entire configured token makes didHandleURL decline those QR links, so staging previews no longer open.

Knowledge Base Used: Deep-link handling

// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Offline events cross user boundaries

If the host logs out or switches users during a large restore, this one-time user-ID snapshot can become stale before the batch is published. The database clear cannot retract rows already read, and publishing uses the channel current at send time. The old user's events can therefore be attributed to the new user.

How this was verified: The batch filters rows against a one-time user-ID snapshot, while the socket push uses the live channel without checking that user's identity.

Knowledge Base Used:

Comment thread Sources/Userpilot/Analytics/AnalyticsPublisher.swift Outdated
Comment thread Sources/Userpilot/Experiences/ExperiencesPublisher.swift Outdated
Comment on lines +268 to +275
probe(host) { [weak self] hasAccess in
guard let self = self else { return }

self.stateQueue.async(flags: .barrier) {
self._isCheckingReachability = false
}

self.updateInternetAccessState(hasAccess: hasAccess)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale probe restores online state

If a reachability probe finishes after the interface disconnects or monitoring stops for backgrounding, its result still marks the monitor ready and online. Foreground events can then enter the live in-memory queue instead of offline storage until another path update corrects the state.

Knowledge Base Used: Runtime foundations

eventData[Constants.OfflineEvents.createdAtProperty] = formatTimestampWithTimezone(
eventStorage.createdAt)
eventData[Constants.Analytics.screenTitleProperty] = event.screenTitle ?? ""
eventData[Constants.Analytics.metaDataProperty] = [Constants.Analytics.fakeReload: false]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Offline screens lose metadata

Replaying an offline screen replaces its metadata with only fake_reload: false. It loses the manual or automatic screen_source and lacks the session-start and seen-content context included in live screen requests. Offline analytics and content evaluation therefore receive a different screen record from the corresponding online path.

Knowledge Base Used: Analytics events and sessions

Comment on lines +559 to +576
_ = 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Socket callbacks race hook registration

Publishing now calls Phoenix Channel.push and registers its response hooks on the caller's queue, while replies and teardown run on main. Locking the message-reference counter does not protect the response-hook dictionary or channel state. A fast reply can race hook registration, losing the ACK or disrupting the ACK-gated analytics queue.

Knowledge Base Used: Stabilize Phoenix socket lifecycle concurrency

Comment on lines +50 to +55
// if skipNextAutoScreen {
// skipNextAutoScreen = false
// return
// }
// if reportScreenSwitch.isOn {
UserpilotManager.shared.screen(screenTitle)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No-screen scenario still reports screens

The QA screen exposes a β€œreport screen on appear” switch and sets a one-shot skip flag for its no-screen scenario, but both guards are commented out. viewWillAppear still reports a screen, so the scenario cannot check identification without one and its results are misleading.

Knowledge Base Used: Sample app integrations

Comment on lines +118 to +123
.enableScreenAutoCapture,
.enableInteractionAutoCapture,
.enableInteractionTextCapture,
.enableInteractionAccessibilityLabelCapture,
.enableInteractionValueCapture:
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sample screen tests cannot run

The new offline and online QA hubs call screen() manually, but a fresh sample installation enables screen autocapture by default. The SDK rejects manual screen calls in that mode, so the screen-burst and queue scenarios do not exercise the screen events they claim to test.

Knowledge Base Used: Sample app integrations

Comment on lines +177 to +185
} 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Numeric properties display as booleans

The JSON event preview tries as? Bool before distinguishing numeric NSNumber values from CFBoolean. A JSON integer such as 1 can consequently display as true, making the sample event log misleading when checking counts, indices, or other numeric properties.

Knowledge Base Used: Sample app integrations

Discard cached SDK requests when identity changes or logout begins.
Keep queued analytics and the new identify event so reconnect behavior
is preserved, and cover offline, disconnected, and resume paths.
Retain saved screen properties, including the capture source, when
building offline batches. Override fake_reload to false because these
entries represent original screen visits.
Preserve the per-screen NPS flag while QR previews open and close.
Clear it for a new screen or logout, and skip reload requests when a
previewed NPS closes, matching the normal NPS behavior.
Capture each pending experience's trigger before theme fetches and
delays. Keep cached manual requests separate from active state, with
manual priority and replay after dismissal or abandoned preparation.
Cover theme loading, targeting, empty replies, and preview thank-you.
Cover unrelated tokens and staging schemes passed to a production
instance so preview links retain the full-token environment boundary.
Comment on lines +545 to +549
private func updateUserId(_ userId: String) {
if storage.userId.isNotEmpty, storage.userId != userId {
cachedSDKEvents.clear()
}
storage.userId = userId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New-user requests are discarded

If a new user's identify is queued while the old socket is joining, a request made for the new user, such as a manually triggered experience, is cached until the socket can send it. On foreground, resume() adopts the queued identity, and this clear discards the request before the new channel sends it. The identify survives, but the requested experience never loads. Switch cleanup can also clear a new-user request that was queued after the identify.

Knowledge Base Used: Analytics events and sessions

Control the display delay and stub UIKit presentation in the pending
experience tests so CI timing does not decide their outcome.
Keep assertions for automatic targeting, trigger ownership, and manual
request replay after dismissal; expose the delay setter only in DEBUG.
Match Android by keeping control value capture opt-in.
Update the default-value test and integration documentation.
Explicit enableInteractionValueCapture() calls still enable capture.
@motasem-userpilot
motasem-userpilot merged commit 6310fe7 into main Sep 27, 2026
6 checks passed
@motasem-userpilot
motasem-userpilot deleted the 1.4.0 branch September 30, 2026 08:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants