Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: capture control events for autocapture #202

Merged
merged 5 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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: 4 additions & 1 deletion Sources/Amplitude/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public struct Constants {
static let AMP_APPLICATION_BACKGROUNDED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Application Backgrounded"
static let AMP_DEEP_LINK_OPENED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Deep Link Opened"
static let AMP_SCREEN_VIEWED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Screen Viewed"
static let AMP_USER_INTERACTION_EVENT = "\(AMP_AMPLITUDE_PREFIX)User Interaction"
static let AMP_ELEMENT_INTERACTED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Element Interacted"

static let AMP_REVENUE_EVENT = "revenue_amount"

static let AMP_APP_VERSION_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Version"
Expand All @@ -97,10 +98,12 @@ public struct Constants {
static let AMP_APP_VIEW_CONTROLLER = "\(AMP_AMPLITUDE_PREFIX)View Controller"
static let AMP_APP_TITLE = "\(AMP_AMPLITUDE_PREFIX)Title"
static let AMP_APP_TARGET_ACCESSIBILITY_LABEL = "\(AMP_AMPLITUDE_PREFIX)Target Accessibility Label"
static let AMP_APP_TARGET_ACCESSIBILITY_IDENTIFIER = "\(AMP_AMPLITUDE_PREFIX)Target Accessibility Identifier"
static let AMP_APP_ACTION = "\(AMP_AMPLITUDE_PREFIX)Action"
static let AMP_APP_TARGET_VIEW_CLASS = "\(AMP_AMPLITUDE_PREFIX)Target View Class"
static let AMP_APP_TARGET_TEXT = "\(AMP_AMPLITUDE_PREFIX)Target Text"
static let AMP_APP_HIERARCHY = "\(AMP_AMPLITUDE_PREFIX)Hierarchy"
static let AMP_APP_ACTION_METHOD = "\(AMP_AMPLITUDE_PREFIX)Action Method"
static let AMP_APP_GESTURE_RECOGNIZER = "\(AMP_AMPLITUDE_PREFIX)Gesture Recognizer"

public struct Configuration {
Expand Down
6 changes: 5 additions & 1 deletion Sources/Amplitude/Events/UserInteractionEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ public class UserInteractionEvent: BaseEvent {
viewController: String? = nil,
title: String? = nil,
accessibilityLabel: String? = nil,
accessibilityIdentifier: String? = nil,
action: String,
targetViewClass: String,
targetText: String? = nil,
hierarchy: String,
actionMethod: String? = nil,
gestureRecognizer: String? = nil
) {
self.init(eventType: Constants.AMP_USER_INTERACTION_EVENT, eventProperties: [
self.init(eventType: Constants.AMP_ELEMENT_INTERACTED_EVENT, eventProperties: [
Constants.AMP_APP_VIEW_CONTROLLER: viewController,
Constants.AMP_APP_TITLE: title,
Constants.AMP_APP_TARGET_ACCESSIBILITY_LABEL: accessibilityLabel,
Constants.AMP_APP_TARGET_ACCESSIBILITY_IDENTIFIER: accessibilityIdentifier,
Constants.AMP_APP_ACTION: action,
Constants.AMP_APP_TARGET_VIEW_CLASS: targetViewClass,
Constants.AMP_APP_TARGET_TEXT: targetText,
Constants.AMP_APP_HIERARCHY: hierarchy,
Constants.AMP_APP_ACTION_METHOD: actionMethod,
Constants.AMP_APP_GESTURE_RECOGNIZER: gestureRecognizer
])
}
Expand Down
150 changes: 93 additions & 57 deletions Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,51 @@
import UIKit

class UIKitUserInteractions {
struct EventData {
enum Source {
case actionMethod

case gestureRecognizer
}

let viewController: String?

let title: String?

let accessibilityLabel: String?

let accessibilityIdentifier: String?

let targetViewClass: String

let targetText: String?

let hierarchy: String

fileprivate func userInteractionEvent(for action: String, from source: Source? = nil, withName sourceName: String? = nil) -> UserInteractionEvent {
return UserInteractionEvent(
viewController: viewController,
title: title,
accessibilityLabel: accessibilityLabel,
accessibilityIdentifier: accessibilityIdentifier,
action: action,
targetViewClass: targetViewClass,
targetText: targetText,
hierarchy: hierarchy,
actionMethod: source == .actionMethod ? sourceName : nil,
gestureRecognizer: source == .gestureRecognizer ? sourceName : nil
)
}
}

fileprivate static let amplitudeInstances = NSHashTable<Amplitude>.weakObjects()

private static let lock = NSLock()

private static let addNotificationObservers: Void = {
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didBeginEditing), name: UITextField.textDidBeginEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didEndEditing), name: UITextField.textDidEndEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didBeginEditing), name: UITextView.textDidBeginEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didEndEditing), name: UITextView.textDidEndEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didEdit), name: UITextField.textDidEndEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didEdit), name: UITextView.textDidEndEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didTouch), name: UITableView.selectionDidChangeNotification, object: nil)
}()

private static let setupMethodSwizzling: Void = {
Expand All @@ -26,17 +62,17 @@ class UIKitUserInteractions {
addNotificationObservers
}

@objc static func didBeginEditing(_ notification: NSNotification) {
@objc static func didEdit(_ notification: NSNotification) {
guard let view = notification.object as? UIView else { return }
let userInteractionEvent = view.eventFromData(with: "didBeginEditing")
let userInteractionEvent = view.eventData.userInteractionEvent(for: "Edit")
amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
}

@objc static func didEndEditing(_ notification: NSNotification) {
@objc static func didTouch(_ notification: NSNotification) {
guard let view = notification.object as? UIView else { return }
let userInteractionEvent = view.eventFromData(with: "didEndEditing")
let userInteractionEvent = view.eventData.userInteractionEvent(for: "Touch")
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
Expand Down Expand Up @@ -67,14 +103,12 @@ extension UIApplication {
let sendActionResult = amp_sendAction(action, to: target, from: sender, for: event)

guard sendActionResult,
let view = sender as? UIView,
view.amp_shouldTrack(action, for: target),
let actionName = NSStringFromSelector(action)
.components(separatedBy: ":")
.first
let control = sender as? UIControl,
control.amp_shouldTrack(action, for: target),
let actionEvent = control.event(for: action, to: target)?.description
else { return sendActionResult }

let userInteractionEvent = view.eventFromData(with: actionName)
let userInteractionEvent = control.eventData.userInteractionEvent(for: actionEvent, from: .actionMethod, withName: NSStringFromSelector(action))

UIKitUserInteractions.amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
Expand All @@ -90,7 +124,7 @@ extension UIGestureRecognizer {

guard state == .ended, let view else { return }

let gestureType = switch self {
let gestureAction: String? = switch self {
case is UITapGestureRecognizer: "Tap"
case is UISwipeGestureRecognizer: "Swipe"
case is UIPanGestureRecognizer: "Pan"
Expand All @@ -100,62 +134,29 @@ extension UIGestureRecognizer {
case is UIRotationGestureRecognizer: "Rotation"
case is UIScreenEdgePanGestureRecognizer: "Screen Edge Pan"
#endif
default: "Custom Gesture"
default: nil
}

let userInteractionEvent = eventFromData(with: gestureType, from: view)
guard let gestureAction else { return }

let userInteractionEvent = view.eventData.userInteractionEvent(for: gestureAction, from: .gestureRecognizer, withName: descriptiveTypeName)

UIKitUserInteractions.amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
}

func eventFromData(with action: String, from view: UIView) -> UserInteractionEvent {
let viewData = view.extractData(with: action)
return UserInteractionEvent(
viewController: viewData.viewController,
title: viewData.title,
accessibilityLabel: viewData.accessibilityLabel,
action: viewData.action,
targetViewClass: viewData.targetViewClass,
targetText: viewData.targetText,
hierarchy: viewData.hierarchy,
gestureRecognizer: descriptiveTypeName)
}
}

extension UIView {
private static let viewHierarchyDelimiter = " -> "

struct ViewData {
let viewController: String?
let title: String?
let accessibilityLabel: String?
let action: String
let targetViewClass: String
let targetText: String?
let hierarchy: String
}

func eventFromData(with action: String) -> UserInteractionEvent {
let viewData = extractData(with: action)
return UserInteractionEvent(
viewController: viewData.viewController,
title: viewData.title,
accessibilityLabel: viewData.accessibilityLabel,
action: viewData.action,
targetViewClass: viewData.targetViewClass,
targetText: viewData.targetText,
hierarchy: viewData.hierarchy)
}
private static let viewHierarchyDelimiter = " → "

func extractData(with action: String) -> ViewData {
var eventData: UIKitUserInteractions.EventData {
let viewController = owningViewController
return ViewData(
return UIKitUserInteractions.EventData(
viewController: viewController?.descriptiveTypeName,
title: viewController?.title,
accessibilityLabel: accessibilityLabel,
action: action,
accessibilityIdentifier: accessibilityIdentifier,
targetViewClass: descriptiveTypeName,
targetText: amp_title,
hierarchy: sequence(first: self, next: \.superview)
Expand All @@ -164,9 +165,44 @@ extension UIView {
}
}

extension UIControl {
func event(for action: Selector, to target: Any?) -> UIControl.Event? {
var events: [UIControl.Event] = [
.touchDown, .touchDownRepeat, .touchDragInside, .touchDragOutside,
.touchDragEnter, .touchDragExit, .touchUpInside, .touchUpOutside,
.touchCancel, .valueChanged, .editingDidBegin, .editingChanged,
.editingDidEnd, .editingDidEndOnExit, .primaryActionTriggered
]
if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) {
events.append(.menuActionTriggered)
}

return events.first { event in
self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false
}
}
}

extension UIControl.Event {
var description: String? {
if UIControl.Event.allTouchEvents.contains(self) {
return "Touch"
} else if UIControl.Event.allEditingEvents.contains(self) {
return "Edit"
} else if self == .valueChanged {
return "Value Change"
} else if self == .primaryActionTriggered {
return "Primary Action"
} else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered {
return "Menu Action"
}
return nil
}
}

extension UIResponder {
var owningViewController: UIViewController? {
return self as? UIViewController ?? next?.owningViewController
self as? UIViewController ?? next?.owningViewController
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ class UIKitUserInteractionsTests: XCTestCase {
button.accessibilityLabel = "Accessibility Button"
mockVC.view.addSubview(button)

let buttonData = button.extractData(with: "action")
let buttonData = button.eventData

XCTAssertEqual(buttonData.viewController, "UIViewController")
XCTAssertEqual(buttonData.title, "Mock VC Title")
XCTAssertEqual(buttonData.accessibilityLabel, "Accessibility Button")
XCTAssertEqual(buttonData.action, "action")
XCTAssertEqual(buttonData.targetViewClass, "UIButton")
XCTAssertEqual(buttonData.targetText, "Test Button")
XCTAssertTrue(buttonData.hierarchy.hasSuffix("UIButton -> UIView"))
XCTAssertTrue(buttonData.hierarchy.hasSuffix("UIButton UIView"))
}

func testExtractDataForCustomView() {
Expand All @@ -33,24 +32,22 @@ class UIKitUserInteractionsTests: XCTestCase {
let customView = CustomView()
mockVC.view.addSubview(customView)

let customViewData = customView.extractData(with: "action")
let customViewData = customView.eventData

XCTAssertEqual(customViewData.viewController, "UIViewController")
XCTAssertEqual(customViewData.title, "Mock VC Title")
XCTAssertNil(customViewData.accessibilityLabel)
XCTAssertEqual(customViewData.action, "action")
XCTAssertEqual(customViewData.targetViewClass, "CustomView")
XCTAssertTrue(customViewData.hierarchy.hasSuffix("CustomView -> UIView"))
XCTAssertTrue(customViewData.hierarchy.hasSuffix("CustomView UIView"))
}

func testExtractDataForOrphanView() {
let orphanView = UIView()
let orphanData = orphanView.extractData(with: "action")
let orphanData = orphanView.eventData

XCTAssertNil(orphanData.viewController)
XCTAssertNil(orphanData.title)
XCTAssertNil(orphanData.accessibilityLabel)
XCTAssertEqual(orphanData.action, "action")
XCTAssertEqual(orphanData.targetViewClass, "UIView")
XCTAssertNil(orphanData.targetText)
XCTAssertEqual(orphanData.hierarchy, "UIView")
Expand Down
Loading