Skip to content

Commit

Permalink
feat: add base autocapture integration
Browse files Browse the repository at this point in the history
  • Loading branch information
ioannisj committed Oct 17, 2024
1 parent dc2a112 commit 26b97af
Show file tree
Hide file tree
Showing 3 changed files with 336 additions and 0 deletions.
12 changes: 12 additions & 0 deletions PostHog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -383,6 +384,7 @@
69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInputTraits+Util.swift"; sourceTree = "<group>"; };
69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = "<group>"; };
69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationTracker.swift; sourceTree = "<group>"; };
DA26419A2CC0499300CB427B /* PostHogAutocaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegration.swift; sourceTree = "<group>"; };
DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = "<group>"; };
DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -545,6 +547,7 @@
3AC745B7296D6FE60025C109 /* PostHog */ = {
isa = PBXGroup;
children = (
DA26419B2CC0499300CB427B /* Autocapture */,
69EE82B82BA9C4DA00EB9542 /* Replay */,
69BA38E62B893F2200AA69D6 /* Resources */,
69779BED2AE6B29E00D7A48E /* Models */,
Expand Down Expand Up @@ -742,6 +745,14 @@
path = PostHogExampleStoryboard;
sourceTree = "<group>";
};
DA26419B2CC0499300CB427B /* Autocapture */ = {
isa = PBXGroup;
children = (
DA26419A2CC0499300CB427B /* PostHogAutocaptureIntegration.swift */,
);
path = Autocapture;
sourceTree = "<group>";
};
DA8D37252CBEAC02005EBD27 /* Products */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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 */,
Expand Down
322 changes: 322 additions & 0 deletions PostHog/Autocapture/PostHogAutocaptureIntegration.swift
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 26b97af

Please sign in to comment.