diff --git a/.changeset/prewarm-push-open-capture.md b/.changeset/prewarm-push-open-capture.md new file mode 100644 index 000000000..d260aa72c --- /dev/null +++ b/.changeset/prewarm-push-open-capture.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': minor +--- + +Add `PostHogSDK.prewarmPushNotificationOpenCapture()` so a notification tap delivered before `setup()` is still captured as `$push_notification_opened`. diff --git a/.changeset/warn-missing-notification-delegate.md b/.changeset/warn-missing-notification-delegate.md new file mode 100644 index 000000000..da196c509 --- /dev/null +++ b/.changeset/warn-missing-notification-delegate.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': patch +--- + +Log a debug warning when `capturePushNotificationOpened` is enabled and no `UNUserNotificationCenter` delegate is set, which is the case where no notification tap can ever be captured. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 1d39e45bc..c32745a8a 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -189,6 +189,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` implementation. /// /// Default: true. Set to `false` to opt out. + /// + /// Requires your app to set `UNUserNotificationCenter.current().delegate`. Without one, iOS + /// reports the tap to nobody and no open can be captured, in any app state. @objc public var capturePushNotificationOpened: Bool = true #endif @@ -559,7 +562,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? integrations.append(PostHogPushNotificationSubscriptionIntegration()) } #endif - if capturePushNotificationOpened { + if installsPushNotificationOpenIntegration { integrations.append(PostHogPushNotificationOpenIntegration()) } } @@ -568,6 +571,15 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? return integrations } + #if os(iOS) || os(macOS) + /// `setup()`'s prewarm-discard gate is the negation of this, and the discard is the only thing + /// that releases a prewarm the config did not want. Both read this property so a new reason + /// not to install cannot be added on one side only. + var installsPushNotificationOpenIntegration: Bool { + capturePushNotificationOpened && enableSwizzling && !optOut + } + #endif + var _surveys: Bool = true // swiftlint:disable:this identifier_name private func setSurveys(_ value: Bool) { // protection against objc API availability warning instead of error diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2b3b7681d..c91af0057 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -275,6 +275,16 @@ let maxRetryDelay = 30.0 notifyExceptionStepsDidChange() } + #if os(iOS) || os(macOS) + // Releases a prewarm this setup turns out not to want — including while opted out, + // where the integrations above were never installed and so could never release it. + if #available(iOS 14.0, macOS 11.0, *) { + if !config.installsPushNotificationOpenIntegration { + DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() + } + } + #endif + // Next-launch retry for a persisted, not-yet-delivered push subscription // (no-ops while opted out, offline, or when the record was already delivered). pushSubscriptionHandler?.retryIfNeeded() @@ -3139,6 +3149,27 @@ let maxRetryDelay = 30.0 #endif #if os(iOS) || os(macOS) + /// Installs the notification-open swizzles before `setup()` is called. + /// + /// A cold launch from a notification tap delivers the response to the app within a few hundred + /// milliseconds — sooner than a cross-platform host (Flutter, React Native) can reach its own + /// `setup()` call from the Dart/JS runtime, so the swizzles are not yet in place and the open is + /// lost. Call this from `application(_:didFinishLaunchingWithOptions:)`, or from a plugin + /// registration that runs inside it, and the response is held until `setup()` installs the + /// integration, which then captures it. + /// + /// Holds at most one response, and only replays it when `setup()` follows within 30 seconds. + /// Native iOS apps that call `setup()` from `didFinishLaunchingWithOptions` do not need this. + /// + /// The swizzles are installed immediately and released again when the last subscriber detaches + /// (`close()`), or at `setup()` when the config disables push-open capture or the app is + /// opted out. If `setup()` is never called they stay for the process lifetime. The per-class + /// delegate wrapper, as elsewhere in this SDK, stays for the process lifetime regardless. + @available(iOS 14.0, macOS 11.0, *) + @objc public static func prewarmPushNotificationOpenCapture() { + DI.main.pushNotificationPublisher.prewarmNotificationResponseCapture() + } + /// Manually captures a `$push_notification_opened` event for a notification the user tapped. /// /// Use this when you're not relying on the automatic swizzling installed by diff --git a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift index 1b97ad764..855d93872 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift @@ -13,6 +13,11 @@ private static let integrationInstallState = PostHogIntegrationInstallState() + /// No lifecycle hook says "the app is finished installing its notification delegate", so the + /// absence of one can only be concluded from a deadline. Long enough for an app that installs + /// it during launch, short enough that the warning lands in the same debugging session. + private static let missingDelegateGracePeriod: TimeInterval = 5 + private weak var postHog: PostHogSDK? private var token: RegistrationToken? @@ -38,18 +43,53 @@ return } token = DI.main.pushNotificationPublisher.onNotificationResponse.subscribe { [weak self] response in - // Auto-capture remote pushes only. A local - // notification (calendar/interval/location trigger, or none) is the app's own, not a - // delivered push; users who want to capture those can call - // `capturePushNotificationOpened(response:)` themselves — it stays unfiltered. - // Exclude dismiss actions: a `.customDismissAction` category invokes this same delegate - // callback on swipe-to-dismiss, which is not the user "opening" the notification. - guard response.notification.request.trigger is UNPushNotificationTrigger, - response.actionIdentifier != UNNotificationDismissActionIdentifier - else { - return - } - self?.postHog?.capturePushNotificationOpened(response: response) + self?.capture(response) + } + + // Draining after subscribing, so a response racing this call is never dropped. + if let pending = DI.main.pushNotificationPublisher.consumePendingNotificationResponse() { + capture(pending) + } + + warnIfNoNotificationDelegate(while: token) + } + + private func capture(_ response: UNNotificationResponse) { + // Auto-capture remote pushes only. A local + // notification (calendar/interval/location trigger, or none) is the app's own, not a + // delivered push; users who want to capture those can call + // `capturePushNotificationOpened(response:)` themselves — it stays unfiltered. + // Exclude dismiss actions: a `.customDismissAction` category invokes this same delegate + // callback on swipe-to-dismiss, which is not the user "opening" the notification. + guard response.notification.request.trigger is UNPushNotificationTrigger, + response.actionIdentifier != UNNotificationDismissActionIdentifier + else { + return + } + postHog?.capturePushNotificationOpened(response: response) + } + + /// Without a `UNUserNotificationCenter` delegate the system never reports a tap to anyone, so + /// there is nothing to swizzle and no open is ever captured — the integration installs cleanly + /// and then stays silent forever, which is near-impossible to diagnose from the outside. + /// + /// The check is delayed because an app may install its delegate after setup(), which the + /// swizzled setter handles; only a delegate still missing well after launch is a real problem. + private func warnIfNoNotificationDelegate(while token: RegistrationToken?) { + // An app extension is never the notification-center delegate, so the advice below would be + // wrong there. + guard Bundle.main.bundleURL.pathExtension == "app" else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + Self.missingDelegateGracePeriod) { [weak token] in + // A weak token reads "still subscribed" without racing `stop()` on another thread. + guard token != nil, UNUserNotificationCenter.current().delegate == nil else { return } + hedgeLog(""" + Push notification opened integration: no UNUserNotificationCenter delegate is set, so \ + notification taps are never reported to the app and `$push_notification_opened` cannot \ + be captured. Set `UNUserNotificationCenter.current().delegate` in your application \ + delegate, or capture opens yourself with \ + `PostHogSDK.shared.capturePushNotificationOpened(response:)`. + """) } } diff --git a/PostHog/PushNotifications/PushNotificationPublisher.swift b/PostHog/PushNotifications/PushNotificationPublisher.swift index 0c799924c..aa187e182 100644 --- a/PostHog/PushNotifications/PushNotificationPublisher.swift +++ b/PostHog/PushNotifications/PushNotificationPublisher.swift @@ -15,6 +15,17 @@ var onNotificationResponse: PostHogMulticastCallback { get } /// Fires when APNs delivers a device token (already converted to a lowercase-hex string). var onDeviceToken: PostHogMulticastCallback { get } + /// Installs the notification-delegate swizzles before the SDK is configured, so a response + /// delivered during launch is held until an integration subscribes. A no-op once a subscriber + /// exists — setup() has already run, so there is nothing to hold for it. + func prewarmNotificationResponseCapture() + /// Publishes a response to subscribers. With no subscriber it is held only while prewarmed, + /// and otherwise dropped. + func deliver(notificationResponse: UNNotificationResponse) + func consumePendingNotificationResponse() -> UNNotificationResponse? + /// Undoes a prewarm that setup() turned out not to want, so an app that disabled push-open + /// capture is not left permanently swizzled. + func discardPrewarmedNotificationResponseCapture() } // MARK: - Publisher @@ -44,18 +55,52 @@ private let delegateClassesLock = NSLock() private var swizzledDelegateClasses = Set() + /// Guards the setter-swizzle install state and the pre-subscriber response buffer. + private let stateLock = NSLock() + private var isDelegateSetterSwizzled = false + private var isPrewarmed = false + private var pendingResponse: (response: UNNotificationResponse, capturedAt: Date)? + + /// A buffered response stands for the launch that is happening now. Anything older than this + /// belongs to a launch the app never configured the SDK for, and would be reported with a + /// misleading timestamp. + private static let pendingResponseTTL: TimeInterval = 30 + + #if TESTING + /// The points where a concurrent caller can overtake this one. Exposed so the + /// interleavings the guards exist for can be driven in a fixed order instead of raced — + /// every route into those states is the race itself, so there is no other way to reach + /// them deterministically. + enum RaceWindow { + case prewarmAfterSubscriberCheck + case discardAfterSubscriberCheck + } + + var onRaceWindow: ((RaceWindow) -> Void)? + + /// Counts entries to the swizzle transitions, including the ones the bundle guard turns + /// into no-ops — a test runner is never an app, so the swizzle state itself is not + /// observable here, but the decision to re-arm is. + private(set) var swizzleInstallAttempts = 0 + private(set) var swizzleUninstallAttempts = 0 + #endif + private init() { // weakSelf avoids capturing self in the subscriber-count closures before init completes. weak var weakSelf: PushNotificationPublisher? onNotificationResponse = PostHogMulticastCallback(onSubscriberCountChanged: { count in guard let self = weakSelf else { return } if count == 1 { - self.swizzleNotificationCenterDelegateSetter() - if let existing = UNUserNotificationCenter.current().delegate { - self.swizzleNotificationDelegateMethods(on: type(of: existing)) - } + // From the first subscriber on, a response arriving with no subscriber is + // dropped rather than buffered for a later setup(). + self.stateLock.withLock { self.isPrewarmed = false } + self.installNotificationDelegateSwizzles() } else if count == 0 { - self.unswizzleNotificationCenterDelegateSetter() + // Also clears the flag: a prewarm racing the first subscribe can re-set it after + // the branch above cleared it, and a live prewarm with no subscriber is exactly + // the state that buffers a response into the next setup(). + self.stateLock.withLock { self.isPrewarmed = false } + self.uninstallNotificationDelegateSwizzles() } }) onDeviceToken = PostHogMulticastCallback(onSubscriberCountChanged: { count in @@ -77,11 +122,39 @@ UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:) ) - private func swizzleNotificationCenterDelegateSetter() { + /// `isDelegateSetterSwizzled` is what makes this idempotent, and it is load-bearing: the + /// swizzle is a method exchange, so an unguarded second call would reverse the first. + private func installNotificationDelegateSwizzles() { + #if TESTING + stateLock.withLock { swizzleInstallAttempts += 1 } + #endif + // Reachable from public API, so it can run outside an app. + guard Self.isRunningInAppContext else { return } + + let shouldInstall = stateLock.withLock { + guard !isDelegateSetterSwizzled else { return false } + isDelegateSetterSwizzled = true + return true + } + guard shouldInstall else { return } + swizzle(forClass: UNUserNotificationCenter.self, original: Self.delegateSetterOriginal, new: Self.delegateSetterSwizzled) + if let existing = UNUserNotificationCenter.current().delegate { + swizzleNotificationDelegateMethods(on: type(of: existing)) + } } - private func unswizzleNotificationCenterDelegateSetter() { + private func uninstallNotificationDelegateSwizzles() { + #if TESTING + stateLock.withLock { swizzleUninstallAttempts += 1 } + #endif + let shouldUninstall = stateLock.withLock { + guard isDelegateSetterSwizzled else { return false } + isDelegateSetterSwizzled = false + return true + } + guard shouldUninstall else { return } + // Calling swizzle() again reverses the setter exchange. `swizzledDelegateClasses` is // deliberately NOT cleared: the per-class `didReceive` replacements stay in place for the // process lifetime (invoking an empty multicast is a no-op), and clearing the set would wrap @@ -89,6 +162,87 @@ swizzle(forClass: UNUserNotificationCenter.self, original: Self.delegateSetterOriginal, new: Self.delegateSetterSwizzled) } + func prewarmNotificationResponseCapture() { + // Read outside `stateLock` — `subscriberCount` takes the multicast's own lock. A prewarm + // racing the very first subscribe can still set the flag; the TTL bounds that. + guard onNotificationResponse.subscriberCount == 0 else { return } + #if TESTING + onRaceWindow?(.prewarmAfterSubscriberCheck) + #endif + + let alreadyPrewarmed = stateLock.withLock { + let wasPrewarmed = isPrewarmed + isPrewarmed = true + return wasPrewarmed + } + guard !alreadyPrewarmed else { return } + installNotificationDelegateSwizzles() + } + + func deliver(notificationResponse response: UNNotificationResponse) { + if onNotificationResponse.subscriberCount == 0 { + let buffered = stateLock.withLock { () -> Bool in + guard isPrewarmed else { return false } + pendingResponse = (response, Date()) + return true + } + if buffered { + // A subscriber that arrived while this ran already drained an empty buffer, so + // hand the response over here instead of stranding it. `consume` clears under + // the lock, so it cannot be delivered twice. + if onNotificationResponse.subscriberCount > 0, + let pending = consumePendingNotificationResponse() + { + onNotificationResponse.invoke(pending) + } + return + } + // The count read above can already be stale, and invoking an empty multicast is a + // no-op, so falling through cannot drop a response that a subscriber arriving + // mid-call should have seen. + } + onNotificationResponse.invoke(response) + } + + func consumePendingNotificationResponse() -> UNNotificationResponse? { + stateLock.withLock { + guard let pending = pendingResponse else { return nil } + pendingResponse = nil + guard Date().timeIntervalSince(pending.capturedAt) <= Self.pendingResponseTTL else { + return nil + } + return pending.response + } + } + + func discardPrewarmedNotificationResponseCapture() { + stateLock.withLock { + pendingResponse = nil + isPrewarmed = false + } + // A live subscriber means an integration still needs these swizzles. + guard onNotificationResponse.subscriberCount == 0 else { return } + #if TESTING + onRaceWindow?(.discardAfterSubscriberCheck) + #endif + uninstallNotificationDelegateSwizzles() + + // The count read above, and the prewarm flag cleared at the top, can both be overtaken + // while this tears down — prewarmNotificationResponseCapture() runs outside setupLock. + // Re-arming on either signal converges on "installed" whichever way they interleave, + // instead of leaving a live prewarm with an un-swizzled delegate setter. + let stillNeeded = stateLock.withLock { isPrewarmed } || onNotificationResponse.subscriberCount > 0 + if stillNeeded { + installNotificationDelegateSwizzles() + } + } + + /// `UNUserNotificationCenter` needs a real app bundle; it traps in test runners and CLI tools. + private static var isRunningInAppContext: Bool { + let bundleExtension = Bundle.main.bundleURL.pathExtension + return bundleExtension == "app" || bundleExtension == "appex" + } + func swizzleNotificationDelegateMethods(on delegateClass: AnyClass) { delegateClassesLock.withLock { let classId = ObjectIdentifier(delegateClass) @@ -145,7 +299,7 @@ let implementationHolder = OriginalDelegateIMP(originalImplementation) let replacement: ReplacementBlock = { delegate, center, response, completionHandler in - DI.main.pushNotificationPublisher.onNotificationResponse.invoke(response) + DI.main.pushNotificationPublisher.deliver(notificationResponse: response) guard let originalImplementation = implementationHolder.lock.withLock({ implementationHolder.value }) else { completionHandler() return @@ -354,6 +508,21 @@ shared.delegateClassesLock.withLock { shared.swizzledDelegateClasses.removeAll() } + let wasSwizzled = shared.stateLock.withLock { + let wasSwizzled = shared.isDelegateSetterSwizzled + shared.isDelegateSetterSwizzled = false + shared.isPrewarmed = false + shared.pendingResponse = nil + shared.onRaceWindow = nil + shared.swizzleInstallAttempts = 0 + shared.swizzleUninstallAttempts = 0 + return wasSwizzled + } + // Clearing the flag without reversing the exchange would leave the next install + // un-swizzling a setter it never swizzled. + if wasSwizzled { + swizzle(forClass: UNUserNotificationCenter.self, original: delegateSetterOriginal, new: delegateSetterSwizzled) + } } } #endif diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index f83a8cfb2..8dce0ec54 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -13,6 +13,34 @@ private final class TestPushNotificationPublisher: PushNotificationPublishing { let onNotificationResponse = PostHogMulticastCallback() let onDeviceToken = PostHogMulticastCallback() + private(set) var prewarmCount = 0 + private(set) var discardCount = 0 + private var isPrewarmed = false + private var pendingResponse: UNNotificationResponse? + + func prewarmNotificationResponseCapture() { + prewarmCount += 1 + isPrewarmed = true + } + + func deliver(notificationResponse response: UNNotificationResponse) { + guard onNotificationResponse.subscriberCount > 0 else { + if isPrewarmed { pendingResponse = response } + return + } + onNotificationResponse.invoke(response) + } + + func consumePendingNotificationResponse() -> UNNotificationResponse? { + defer { pendingResponse = nil } + return pendingResponse + } + + func discardPrewarmedNotificationResponseCapture() { + discardCount += 1 + isPrewarmed = false + pendingResponse = nil + } } #if os(iOS) @@ -53,9 +81,12 @@ let container = DI.Container() container.pushNotificationPublisher = publisher DI.main = container + // `PushNotificationPublisher.shared` is process-wide, so prewarm state leaks between tests. + PushNotificationPublisher.reset() } deinit { + PushNotificationPublisher.reset() DI.main = previousContainer } @@ -86,6 +117,205 @@ } #endif + /// The placeholder stands in for a `UNNotificationResponse`, which has no public initializer. + /// It is only ever compared by identity here — never dereferenced. + private func withPlaceholderResponse(_ body: (UNNotificationResponse) -> Void) { + let placeholder = NSObject() + body(unsafeBitCast(placeholder, to: UNNotificationResponse.self)) + withExtendedLifetime(placeholder) {} + } + + @Test("buffers a response delivered after prewarm but before any subscriber, and drains it once") + func buffersResponseDeliveredBeforeSubscriber() { + let publisher = PushNotificationPublisher.shared + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + + #expect(publisher.consumePendingNotificationResponse() === response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("drops a response delivered with no subscriber when not prewarmed") + func dropsResponseWhenNotPrewarmed() { + let publisher = PushNotificationPublisher.shared + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("prewarming twice leaves the buffer window open exactly once") + func prewarmIsIdempotent() { + let publisher = PushNotificationPublisher.shared + publisher.prewarmNotificationResponseCapture() + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() === response) + } + } + + @Test("discarding a prewarm clears the buffer and ends the prewarm window") + func discardEndsPrewarmWindow() { + let publisher = PushNotificationPublisher.shared + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + publisher.discardPrewarmedNotificationResponseCapture() + #expect(publisher.consumePendingNotificationResponse() == nil) + + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("prewarming while a subscriber is attached does not re-open the buffer window") + func prewarmWithLiveSubscriberIsIgnored() { + let publisher = PushNotificationPublisher.shared + var token: RegistrationToken? = publisher.onNotificationResponse.subscribe { _ in } + + publisher.prewarmNotificationResponseCapture() + token = nil + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("a prewarm overtaken by the first subscriber does not outlive it") + func prewarmOvertakenByFirstSubscriberDoesNotOutliveIt() { + let publisher = PushNotificationPublisher.shared + var token: RegistrationToken? + + // Drive the interleaving the subscriber-count read cannot be made atomic against: + // subscribe in the window between that read and the flag being set. + publisher.onRaceWindow = { window in + guard window == .prewarmAfterSubscriberCheck else { return } + token = publisher.onNotificationResponse.subscribe { _ in } + } + publisher.prewarmNotificationResponseCapture() + publisher.onRaceWindow = nil + + // That subscriber goes away, so nothing is left that wants a response held for it. + token = nil + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("a discard overtaken by a prewarm re-arms interception") + func discardOvertakenByPrewarmReArmsInterception() { + let publisher = PushNotificationPublisher.shared + + publisher.onRaceWindow = { window in + guard window == .discardAfterSubscriberCheck else { return } + // One-shot: the nested prewarm must not re-enter this hook. + publisher.onRaceWindow = nil + publisher.prewarmNotificationResponseCapture() + } + + let installsBefore = publisher.swizzleInstallAttempts + let uninstallsBefore = publisher.swizzleUninstallAttempts + publisher.discardPrewarmedNotificationResponseCapture() + publisher.onRaceWindow = nil + + // The prewarm that landed mid-teardown installs once; the discard then tears down and + // must re-arm, so the transition count is install, uninstall, install. + #expect(publisher.swizzleUninstallAttempts == uninstallsBefore + 1) + #expect(publisher.swizzleInstallAttempts == installsBefore + 2) + } + + @Test("a prewarm after the last subscriber detaches still opens the buffer window") + func prewarmAfterLastSubscriberDetachesBuffers() { + let publisher = PushNotificationPublisher.shared + var token: RegistrationToken? = publisher.onNotificationResponse.subscribe { _ in } + token = nil + + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() != nil) + } + } + + @Test("the public prewarm API reaches the publisher") + @available(iOS 14.0, macOS 11.0, *) + func publicPrewarmApiReachesPublisher() { + #expect(publisher.prewarmCount == 0) + PostHogSDK.prewarmPushNotificationOpenCapture() + #expect(publisher.prewarmCount == 1) + } + + /// Mirrors `PostHogIntegrationInstallationTest.getSut`, trimmed to what the discard gate reads. + @available(iOS 14.0, macOS 11.0, *) + private func makeSut( + optOut: Bool = false, + capturePushNotificationOpened: Bool = true, + enableSwizzling: Bool = true + ) -> PostHogSDK { + let config = PostHogConfig(projectToken: "test_project_token", host: "http://localhost:9001") + config.disableRemoteConfigForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.disableReachabilityForTesting = true + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + config.errorTrackingConfig.autoCapture = false + #if os(iOS) + config.sessionReplay = false + #endif + config.optOut = optOut + config.enableSwizzling = enableSwizzling + config.capturePushNotificationOpened = capturePushNotificationOpened + config.capturePushNotificationSubscriptions = false + + let storage = PostHogStorage(config) + storage.reset() + + PostHogPushNotificationOpenIntegration.clearInstalls() + return PostHogSDK.with(config) + } + + @Test("setup() releases a prewarm when push-open capture is disabled") + @available(iOS 14.0, macOS 11.0, *) + func setupDiscardsPrewarmWhenCaptureDisabled() { + let sut = makeSut(capturePushNotificationOpened: false) + defer { sut.close() } + + #expect(publisher.discardCount == 1) + } + + /// `setup()` skips `installIntegrations()` entirely while opted out, so the discard cannot + /// live there without leaving an opted-out app swizzled for the process lifetime. + @Test("setup() releases a prewarm while opted out, even with push-open capture enabled") + @available(iOS 14.0, macOS 11.0, *) + func setupDiscardsPrewarmWhileOptedOut() { + let sut = makeSut(optOut: true, capturePushNotificationOpened: true) + defer { sut.close() } + + #expect(publisher.discardCount == 1) + } + + @Test("setup() with push-open capture enabled does not discard the prewarm") + @available(iOS 14.0, macOS 11.0, *) + func setupKeepsPrewarmWhenCaptureEnabled() { + publisher.prewarmNotificationResponseCapture() + + let sut = makeSut(capturePushNotificationOpened: true) + defer { sut.close() } + + #expect(publisher.discardCount == 0) + } + @Test("captures and calls an Objective-C delegate with the original selector") func callsObjectiveCDelegate() { let delegate = PHDirectNotificationDelegateTestFixture() diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 19e4d45a3..f304d3e74 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -320,6 +320,7 @@ PostHog | PostHogSDK.isSessionReplayActive() | method | @objc func isSessionRepl PostHog | PostHogSDK.logger | property | @objc var logger: PostHogLogger? { get } | c:@M@PostHog@objc(cs)PostHogSDK(py)logger PostHog | PostHogSDK.optIn() | method | @objc func optIn() | c:@M@PostHog@objc(cs)PostHogSDK(im)optIn PostHog | PostHogSDK.optOut() | method | @objc func optOut() | c:@M@PostHog@objc(cs)PostHogSDK(im)optOut +PostHog | PostHogSDK.prewarmPushNotificationOpenCapture() | type.method | @objc static func prewarmPushNotificationOpenCapture() | c:@M@PostHog@objc(cs)PostHogSDK(cm)prewarmPushNotificationOpenCapture PostHog | PostHogSDK.register(_:) | method | @objc(registerProperties:) func register(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerProperties: PostHog | PostHogSDK.registerPushNotificationToken(_:) | method | @objc func registerPushNotificationToken(_ deviceToken: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken: PostHog | PostHogSDK.registerPushNotificationToken(_:appId:) | method | @objc func registerPushNotificationToken(_ deviceToken: String, appId: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken:appId: