Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/prewarm-push-open-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-ios': minor
---

Add `PostHogSDK.prewarmPushNotificationOpenCapture()` so a notification tap delivered before `setup()` is still captured as `$push_notification_opened`.
5 changes: 5 additions & 0 deletions .changeset/warn-missing-notification-delegate.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 13 additions & 1 deletion PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -559,7 +562,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
integrations.append(PostHogPushNotificationSubscriptionIntegration())
}
#endif
if capturePushNotificationOpened {
if installsPushNotificationOpenIntegration {
integrations.append(PostHogPushNotificationOpenIntegration())
}
}
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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:)`.
""")
}
}

Expand Down
Loading
Loading