From 26b97af983c16027e498a99211751bb5d4af1091 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 17 Oct 2024 08:50:42 +0300 Subject: [PATCH] feat: add base autocapture integration --- PostHog.xcodeproj/project.pbxproj | 12 + .../PostHogAutocaptureIntegration.swift | 322 ++++++++++++++++++ PostHog/PostHogSDK.swift | 2 + 3 files changed, 336 insertions(+) create mode 100644 PostHog/Autocapture/PostHogAutocaptureIntegration.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 7a9f32044..38acd27d4 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */; }; 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; 69F5183A2BB2BA8300F52C14 /* UIApplicationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */; }; + DA26419C2CC0499300CB427B /* PostHogAutocaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA26419A2CC0499300CB427B /* PostHogAutocaptureIntegration.swift */; }; DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */; }; /* End PBXBuildFile section */ @@ -383,6 +384,7 @@ 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInputTraits+Util.swift"; sourceTree = ""; }; 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; 69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationTracker.swift; sourceTree = ""; }; + DA26419A2CC0499300CB427B /* PostHogAutocaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegration.swift; sourceTree = ""; }; DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = ""; }; DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -545,6 +547,7 @@ 3AC745B7296D6FE60025C109 /* PostHog */ = { isa = PBXGroup; children = ( + DA26419B2CC0499300CB427B /* Autocapture */, 69EE82B82BA9C4DA00EB9542 /* Replay */, 69BA38E62B893F2200AA69D6 /* Resources */, 69779BED2AE6B29E00D7A48E /* Models */, @@ -742,6 +745,14 @@ path = PostHogExampleStoryboard; sourceTree = ""; }; + DA26419B2CC0499300CB427B /* Autocapture */ = { + isa = PBXGroup; + children = ( + DA26419A2CC0499300CB427B /* PostHogAutocaptureIntegration.swift */, + ); + path = Autocapture; + sourceTree = ""; + }; DA8D37252CBEAC02005EBD27 /* Products */ = { isa = PBXGroup; children = ( @@ -1125,6 +1136,7 @@ 690FF0AF2AEB9C1400A0B06B /* DateUtils.swift in Sources */, 69F518162BAC7F9200F52C14 /* UIView+Util.swift in Sources */, 69261D192AD9673500232EC7 /* PostHogBatchUploadInfo.swift in Sources */, + DA26419C2CC0499300CB427B /* PostHogAutocaptureIntegration.swift in Sources */, 69ED1A5C2C7F15F300FE7A91 /* PostHogSessionManager.swift in Sources */, 69F23A742BB3088E001194F6 /* URLSessionSwizzler.swift in Sources */, 69F518142BAC7F4300F52C14 /* Date+Util.swift in Sources */, diff --git a/PostHog/Autocapture/PostHogAutocaptureIntegration.swift b/PostHog/Autocapture/PostHogAutocaptureIntegration.swift new file mode 100644 index 000000000..cc6540518 --- /dev/null +++ b/PostHog/Autocapture/PostHogAutocaptureIntegration.swift @@ -0,0 +1,322 @@ +// +// PostHogAutocaptureIntegration.swift +// PostHog +// +// Created by Yiannis Josephides on 14/10/2024. +// + +#if os(iOS) || targetEnvironment(macCatalyst) + import UIKit + + // TODO: Configuration + // TODO: ph-no-capture + // TODO: Rage Clicks - possible? + // TODO: Dead Clicks - possible? + class PostHogAutocaptureIntegration { + struct EventData { + enum Source { + case notification + case actionMethod + case gestureRecognizer + } + + let screenName: String? + let accessibilityLabel: String? + let accessibilityIdentifier: String? + let targetViewClass: String + let targetText: String? + let hierarchy: String + let touchCoordinates: CGPoint + } + + let config: PostHogConfig + + // static -> won't be added twice + private static let addNotificationObservers: Void = { + NotificationCenter.default.addObserver(PostHogAutocaptureIntegration.self, selector: #selector(didEndEditing), name: UITextField.textDidEndEditingNotification, object: nil) + NotificationCenter.default.addObserver(PostHogAutocaptureIntegration.self, selector: #selector(didEndEditing), name: UITextView.textDidEndEditingNotification, object: nil) + }() + + // static -> lazy loaded once (won't swizzle back) + private static let setupSwizzlingOnce: Void = { + swizzle( + forClass: UIApplication.self, + original: #selector(UIApplication.sendAction), + new: #selector(UIApplication.ph_swizzled_uiapplication_sendAction) + ) + + swizzle( + forClass: UIGestureRecognizer.self, + original: #selector(setter: UIGestureRecognizer.state), + new: #selector(UIGestureRecognizer.ph_swizzled_uigesturerecognizer_state) + ) + }() + + init(_ config: PostHogConfig) { + self.config = config + Self.setupSwizzlingOnce + Self.addNotificationObservers + } + + // `UITextField` or `UITextView` did end editing notification + @objc static func didEndEditing(_ notification: NSNotification) { + guard let view = notification.object as? UIView else { return } + let source: EventData.Source = .notification + // Text fields in SwiftUI are identifiable only after the text field is edited. + print("PostHogSDK.shared.capture source: \(source) \(getCaptureDescription(for: view, eventDescription: "didEndEditing")))") + } + } + + private func getCaptureDescription(for element: UIView, eventDescription: String) -> String { + var description = "" + + if let targetText = element.eventData.targetText { + description = "\"\(targetText)\"" + } else if let vcName = element.nearestViewController?.descriptiveTypeName { + description = "in \(vcName)" + } + + return "\(eventDescription) \(element.descriptiveTypeName) \(description)".trimmingCharacters(in: .whitespacesAndNewlines) + } + + extension UIApplication { + @objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool { + defer { + // TODO: Reduce SwiftUI noise by finding the unique view that the action method is attached to. + // Currently, the action methods pointing to a SwiftUI target are blocked. + let targetClass = String(describing: object_getClassName(target)) + if targetClass.contains("SwiftUI") { + print("PostHogSDK.shared.capture SwiftUI -> \(targetClass)") + } else if let control = sender as? UIControl, + control.ph_shouldTrack(action, for: target), + let eventDescription = control.event(for: action, to: target)?.description + { + print("PostHogSDK.shared.capture \(getCaptureDescription(for: control, eventDescription: eventDescription))") + } + } + + // first, call original method + return ph_swizzled_uiapplication_sendAction(action, to: target, from: sender, for: event) + } + } + + extension UIGestureRecognizer { + @objc func ph_swizzled_uigesturerecognizer_state(_ state: UIGestureRecognizer.State) { + // first, call original method + ph_swizzled_uigesturerecognizer_state(state) + + guard state == .ended, let view else { return } + + // Block scroll and zoom events for `UIScrollView`. + if let scrollView = view as? UIScrollView { + if self === scrollView.panGestureRecognizer { + return + } + #if !os(tvOS) + if self === scrollView.pinchGestureRecognizer { + return + } + #endif + } + + let gestureAction: String? + switch self { + case is UITapGestureRecognizer: + gestureAction = "tap" + case is UISwipeGestureRecognizer: + gestureAction = "swipe" + case is UIPanGestureRecognizer: + gestureAction = "pan" + case is UILongPressGestureRecognizer: + gestureAction = "longPress" + #if !os(tvOS) + case is UIPinchGestureRecognizer: + gestureAction = "pinch" + case is UIRotationGestureRecognizer: + gestureAction = "rotation" + case is UIScreenEdgePanGestureRecognizer: + gestureAction = "screenEdgePan" + #endif + default: + gestureAction = nil + } + + guard let gestureAction else { return } + + print("PostHogSDK.shared.capture -> \(gestureAction) \(descriptiveTypeName) -> \(view.eventData)") + } + } + + extension UIView { + private static let viewHierarchyDelimiter = " → " + + var eventData: PostHogAutocaptureIntegration.EventData { + PostHogAutocaptureIntegration.EventData( + screenName: nearestViewController + .flatMap(UIViewController.ph_topViewController) + .flatMap(UIViewController.getViewControllerName), + accessibilityLabel: accessibilityLabel, + accessibilityIdentifier: accessibilityIdentifier, + targetViewClass: descriptiveTypeName, + targetText: sanitizeTitle(ph_autocaptureTitle), + hierarchy: sequence(first: self, next: \.superview) + .map(\.descriptiveTypeName) + .joined(separator: UIView.viewHierarchyDelimiter), + touchCoordinates: CGPoint.zero // TODO: + ) + } + } + + extension UIControl { + func event(for action: Selector, to target: Any?) -> UIControl.Event? { + var events: [UIControl.Event] = [ + .valueChanged, + .touchDown, + .touchDownRepeat, + .touchDragInside, + .touchDragOutside, + .touchDragEnter, + .touchDragExit, + .touchUpInside, + .touchUpOutside, + .touchCancel, + .editingDidBegin, + .editingChanged, + .editingDidEnd, + .editingDidEndOnExit, + .primaryActionTriggered + ] + + if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) { + events.append(.menuActionTriggered) + } + + // latest event for action + return events.first { event in + self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false + } + } + } + + extension UIControl.Event { + var description: String? { + if self == .touchUpInside { + return "tap" + } else if UIControl.Event.allTouchEvents.contains(self) { + return "touch" + } else if UIControl.Event.allEditingEvents.contains(self) { + return "edit" + } else if self == .valueChanged { + return "valueChange" + } else if self == .primaryActionTriggered { + return "primaryAction" + } else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered { + return "menuAction" + } + return nil + } + } + + extension UIApplication { + static var ph_currentWindow: UIWindow? { + Array(UIApplication.shared.connectedScenes) + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.windowLevel != .statusBar } + } + } + + extension UIViewController { + class func ph_topViewController(base: UIViewController? = UIApplication.ph_currentWindow?.rootViewController) -> UIViewController? { + if let nav = base as? UINavigationController { + return ph_topViewController(base: nav.visibleViewController) + + } else if let tab = base as? UITabBarController, let selected = tab.selectedViewController { + return ph_topViewController(base: selected) + + } else if let presented = base?.presentedViewController { + return ph_topViewController(base: presented) + } + return base + } + } + + extension UIResponder { + var nearestViewController: UIViewController? { + self as? UIViewController ?? next?.nearestViewController + } + } + + extension NSObject { + var descriptiveTypeName: String { + String(describing: type(of: self)) + } + } + + protocol AutoCapturable { + var ph_autocaptureTitle: String? { get } + var ph_autocaptureEvents: UIControl.Event { get } + func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool + } + + extension UIView: AutoCapturable { + @objc var ph_autocaptureEvents: UIControl.Event { .touchUpInside } + @objc var ph_autocaptureTitle: String? { nil } + @objc func ph_shouldTrack(_: Selector, for _: Any?) -> Bool { + false // by default views are not tracked. Can be overwritten in subclasses + } + } + + extension UIButton { + override var ph_autocaptureTitle: String? { title(for: .normal) ?? title(for: .selected) } + } + + extension UIControl { + @objc override func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool { + actions(forTarget: target, forControlEvent: ph_autocaptureEvents)?.contains(action.description) ?? false + } + } + + extension UISegmentedControl { + override var ph_autocaptureEvents: UIControl.Event { .valueChanged } + override var ph_autocaptureTitle: String? { titleForSegment(at: selectedSegmentIndex) } + } + + extension UIPageControl { + override var ph_autocaptureEvents: UIControl.Event { .valueChanged } + } + + extension UISearchBar { + override var ph_autocaptureEvents: UIControl.Event { .editingDidEnd } + } + + extension UIToolbar { + override var ph_autocaptureEvents: UIControl.Event { + if #available(iOS 14.0, *) { .menuActionTriggered } else { .primaryActionTriggered } + } + } + + extension UITextField { + override var ph_autocaptureTitle: String? { text ?? attributedText?.string ?? placeholder } + } + + extension UITextView { + override var ph_autocaptureTitle: String? { text ?? attributedText?.string } + } + + extension UIStepper { + override var ph_autocaptureEvents: UIControl.Event { .valueChanged } + } + + #if !os(tvOS) + extension UIDatePicker { + override var ph_autocaptureEvents: UIControl.Event { .valueChanged } + } + #endif + + private func sanitizeTitle(_ title: String?) -> String? { + guard let title else { return nil } + return title.replacingOccurrences(of: "[^a-zA-Z0-9]+", with: "-", options: .regularExpression, range: nil) + } + +#endif diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2674856a6..a020804c2 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -49,6 +49,7 @@ let maxRetryDelay = 30.0 #if os(iOS) private var replayIntegration: PostHogReplayIntegration? #endif + private var autocaptureIntegration: PostHogAutocaptureIntegration? // nonisolated(unsafe) is introduced in Swift 5.10 #if swift(>=5.10) @@ -102,6 +103,7 @@ let maxRetryDelay = 30.0 #if os(iOS) replayIntegration = PostHogReplayIntegration(config) #endif + autocaptureIntegration = PostHogAutocaptureIntegration(config) #if !os(watchOS) do { reachability = try Reachability()