Repository navigation
π Release 1.4.0 β Offline events, QR preview, identify refresh & concurrency hardening - #52
Conversation
β¨ 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>
|
| AB68595F30594DA300EAEF1A /* XCLocalSwiftPackageReference "../../../ios-sdk copy" */ = { | ||
| isa = XCLocalSwiftPackageReference; | ||
| relativePath = "../../../ios-sdk"; | ||
| relativePath = "../../../ios-sdk copy"; |
There was a problem hiding this comment.
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
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.
| // 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() |
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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
Comments Outside DiffThese 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.
|
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() |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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:
| probe(host) { [weak self] hasAccess in | ||
| guard let self = self else { return } | ||
|
|
||
| self.stateQueue.async(flags: .barrier) { | ||
| self._isCheckingReachability = false | ||
| } | ||
|
|
||
| self.updateInternetAccessState(hasAccess: hasAccess) |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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
| _ = 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) |
There was a problem hiding this comment.
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
| // if skipNextAutoScreen { | ||
| // skipNextAutoScreen = false | ||
| // return | ||
| // } | ||
| // if reportScreenSwitch.isOn { | ||
| UserpilotManager.shared.screen(screenTitle) |
There was a problem hiding this comment.
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
| .enableScreenAutoCapture, | ||
| .enableInteractionAutoCapture, | ||
| .enableInteractionTextCapture, | ||
| .enableInteractionAccessibilityLabelCapture, | ||
| .enableInteractionValueCapture: | ||
| return true |
There was a problem hiding this comment.
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
| } 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 { |
There was a problem hiding this comment.
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.
| private func updateUserId(_ userId: String) { | ||
| if storage.userId.isNotEmpty, storage.userId != userId { | ||
| cachedSDKEvents.clear() | ||
| } | ||
| storage.userId = userId |
There was a problem hiding this comment.
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.
π Description
Release branch for Userpilot iOS SDK 1.4.0 β 85 commits, 167 files changed (+11,096 / β3,801).
Userpilot.podspecandVersion.swiftare bumped1.3.0 β 1.4.0.Offline support (new subsystem)
fake_reload, session/screen bookkeeping).Database/ event-storage layer, mirroring the Android package layout so the two SDKs stay readable side by side.Preview / QR review flow
Analytics & identify
Concurrency & thread-safety hardening
AtomicReferenceand genericEventQueueprimitives replace the ad-hocReadWriteLock; the multicast delegate list is lock-guarded;SessionMonitor's session-start and app-active flags are guarded across threads and it now resumes ondidBecomeActiverather thanwillEnterForeground.Deep links & push notifications
configureis held rather than dropped.STG-prefix β documented in DocC.Experiences UI
Autocapture
enableInteractionValueCapturenow defaults totrue(see Migration Notes).target_textis resolved at row level rather than leaf level.Security
Sample app, docs & CI
OfflineMode,ScanQRCodeForReview,PushNotifications,PushNotificationsManually,URLSchemeConfiguring,UserpilotNanvigationHandler.quality-gate.yml), green-gate scripts added and Actions workflows hardened,scripts/release-preflight.shupdated, 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.0andhot-fix/socket-connectionβ the socket lifecycle serialization in particular β somainpicks 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,SessionMonitorand 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
π Public API & Wrapper Impact
If the public API changed, confirm:
@objc-exposed and Objective-C-bridgeable, and non-throwingnavigationDelegategained adidSetobserver while retaining the same@objc public weak var navigationDelegate: UserpilotNavigationDelegate?signature β no bridging change. Everything else added (EventQueue,AtomicReference, the database layer) isinternal.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:
enableInteractionValueCapturenow defaults totrue. 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?
XCTest coverage was added or extended across the whole release, notably:
EventQueueTests(+374),AtomicReferenceTests(+209), offline storage / database, analytics state machines,DeepLinkSchemeTests(production vs staging scheme matching),MulticastDelegateTests.SocketManagerTests(566 lines changed, restructured for testability),LinkOpenerTests,PushNotificationMonitorTests,DelayUtilsTests,EventDebounceTests.ReadWriteLockTestsalongside the primitive it covered.π· 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.
enableInteractionValueCapturenow defaults totrue.Hosts that never set it explicitly will begin capturing interaction values (
UISwitchstate,UISegmentedControl/UIStepper/UIPickerView/UIDatePickerselections) on autocaptured events. To keep the previous behaviour, opt out explicitly: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 registeruserpilot-stg-nx-...for anSTG-NX-...token; see theURLSchemeConfiguringDocC article.None.
ReadWriteLockwas removed, but it wasinternalβ no public surface was deprecated or withdrawn.π§Ή Cleanup Tasks (optional)
UserpilotNanvigationHandler.mdkeeps its existing misspelled filename; renaming it is a separate docs change so DocC links are not broken mid-release.β Checklist
AGENTS.md).PRODUCTIONwith placeholder socket URL / token, and noNX-in SocketManagerxcodebuild test -scheme Userpilotpasses, and I added tests where applicableswiftlint --strictis clean (no new warnings or errors in Xcode)docs/, README, DocC) if neededUserpilot.podspecstill covers them (pod lib lint)