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: add user interactions capture #190

Merged
merged 16 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
12 changes: 12 additions & 0 deletions Amplitude-Swift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
4E05BB942BE41AEB009DE475 /* Amplitude+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E05BB932BE41AEB009DE475 /* Amplitude+Extensions.swift */; };
4E2B646B2BA127460010E6F8 /* UIKitScreenViewsPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2B646A2BA127460010E6F8 /* UIKitScreenViewsPluginTests.swift */; };
4E3871622BB34DBC002890AB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B6DF481F2B5B45BE00B3E6AA /* PrivacyInfo.xcprivacy */; };
6C23EF112C38AC19000DC8C8 /* UIKitUserInteractions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23EF102C38AC19000DC8C8 /* UIKitUserInteractions.swift */; };
6C23EF132C38AC24000DC8C8 /* UserInteractionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23EF122C38AC24000DC8C8 /* UserInteractionEvent.swift */; };
6C23EF162C38AD31000DC8C8 /* UIKitUserInteractionPluginTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23EF142C38AC32000DC8C8 /* UIKitUserInteractionPluginTest.swift */; };
8EDEC02B99EE2092B567A61D /* ObjCIngestionMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDEC500EBDA8B813056E2DB /* ObjCIngestionMetadata.swift */; };
8EDEC1073A308B12B5CCD975 /* AnalyticsConnectorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDECD39BAA97DD4320C0AA5 /* AnalyticsConnectorPlugin.swift */; };
8EDEC10C56FA7F7DEEB48B6F /* ObjCBaseEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDECCFD935A0C5A6FE85E87 /* ObjCBaseEvent.swift */; };
Expand Down Expand Up @@ -153,6 +156,9 @@
3E281B902B9BCC14009D913B /* DispatchQueueHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueHolder.swift; sourceTree = "<group>"; };
4E05BB932BE41AEB009DE475 /* Amplitude+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Amplitude+Extensions.swift"; sourceTree = "<group>"; };
4E2B646A2BA127460010E6F8 /* UIKitScreenViewsPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitScreenViewsPluginTests.swift; sourceTree = "<group>"; };
6C23EF102C38AC19000DC8C8 /* UIKitUserInteractions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitUserInteractions.swift; sourceTree = "<group>"; };
6C23EF122C38AC24000DC8C8 /* UserInteractionEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInteractionEvent.swift; sourceTree = "<group>"; };
6C23EF142C38AC32000DC8C8 /* UIKitUserInteractionPluginTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitUserInteractionPluginTest.swift; sourceTree = "<group>"; };
8EDEC0630C3B587334275D9B /* AmplitudeSessionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmplitudeSessionTests.swift; sourceTree = "<group>"; };
8EDEC1160D95DC3F0E48DDF7 /* ObjCPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCPlugin.swift; sourceTree = "<group>"; };
8EDEC1576C95A2EB2FEF00A8 /* ObjCAmplitude.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCAmplitude.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -331,6 +337,7 @@
B6F3389F2B6854A8006179E2 /* Plugins */ = {
isa = PBXGroup;
children = (
6C23EF142C38AC32000DC8C8 /* UIKitUserInteractionPluginTest.swift */,
B6F338A22B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift */,
4E2B646A2BA127460010E6F8 /* UIKitScreenViewsPluginTests.swift */,
);
Expand All @@ -340,6 +347,7 @@
OBJ_13 /* Events */ = {
isa = PBXGroup;
children = (
6C23EF122C38AC24000DC8C8 /* UserInteractionEvent.swift */,
OBJ_14 /* BaseEvent.swift */,
OBJ_15 /* EventOptions.swift */,
OBJ_16 /* GroupIdentifyEvent.swift */,
Expand Down Expand Up @@ -392,6 +400,7 @@
OBJ_32 /* iOS */ = {
isa = PBXGroup;
children = (
6C23EF102C38AC19000DC8C8 /* UIKitUserInteractions.swift */,
OBJ_33 /* IOSLifecycleMonitor.swift */,
8EDEC650EF79B104DC3C9F4C /* UIKitScreenViews.swift */,
);
Expand Down Expand Up @@ -709,6 +718,7 @@
OBJ_150 /* RevenueTests.swift in Sources */,
OBJ_151 /* PersistentStorageTests.swift in Sources */,
OBJ_152 /* TestUtilities.swift in Sources */,
6C23EF162C38AD31000DC8C8 /* UIKitUserInteractionPluginTest.swift in Sources */,
OBJ_153 /* TimelineTests.swift in Sources */,
OBJ_154 /* TypesTests.swift in Sources */,
OBJ_155 /* EventPipelineTests.swift in Sources */,
Expand Down Expand Up @@ -773,6 +783,7 @@
OBJ_120 /* OutputFileStream.swift in Sources */,
OBJ_121 /* PersistentStorageResponseHandler.swift in Sources */,
OBJ_122 /* QueueTimer.swift in Sources */,
6C23EF132C38AC24000DC8C8 /* UserInteractionEvent.swift in Sources */,
OBJ_124 /* UrlExtension.swift in Sources */,
8EDECFCCF4219767F26210D6 /* Sessions.swift in Sources */,
3E281B8C2B967F19009D913B /* Diagonostics.swift in Sources */,
Expand All @@ -787,6 +798,7 @@
8EDEC94F3562C2FACAA58A3D /* Weak.swift in Sources */,
8EDEC43FB30802F70112E577 /* ScreenViewedEvent.swift in Sources */,
8EDEC51F746CC25D27E32F6A /* DeepLinkOpenedEvent.swift in Sources */,
6C23EF112C38AC19000DC8C8 /* UIKitUserInteractions.swift in Sources */,
8EDECF81C2B1B38D472FD7EF /* ObjCConfiguration.swift in Sources */,
8EDECB800546E37719391E65 /* ObjCPlan.swift in Sources */,
8EDEC02B99EE2092B567A61D /* ObjCIngestionMetadata.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions Sources/Amplitude/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ 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_REVENUE_EVENT = "revenue_amount"

static let AMP_APP_VERSION_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Version"
Expand All @@ -93,6 +94,13 @@ public struct Constants {
static let AMP_APP_LINK_URL_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Link URL"
static let AMP_APP_LINK_REFERRER_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Link Referrer"
static let AMP_APP_SCREEN_NAME_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Screen Name"
static let AMP_APP_VIEW_CONTROLLET = "\(AMP_AMPLITUDE_PREFIX)View Controller"
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
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_ACTION_METHOD = "\(AMP_AMPLITUDE_PREFIX)Action Method"
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"

public struct Configuration {
public static let FLUSH_QUEUE_SIZE = 30
Expand Down
5 changes: 4 additions & 1 deletion Sources/Amplitude/DefaultTrackingOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ public class DefaultTrackingOptions {
public var sessions: Bool = true
public var appLifecycles: Bool
public var screenViews: Bool
public var userInteractions: Bool

public init(
sessions: Bool = true,
appLifecycles: Bool = false,
screenViews: Bool = false
screenViews: Bool = false,
userInteractions: Bool = false
) {
self.sessions = sessions
self.appLifecycles = appLifecycles
self.screenViews = screenViews
self.userInteractions = userInteractions
}
}
23 changes: 23 additions & 0 deletions Sources/Amplitude/Events/UserInteractionEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

public class UserInteractionEvent: BaseEvent {
convenience init(
viewController: String? = nil,
title: String? = nil,
accessibilityLabel: String? = nil,
actionMethod: String,
targetViewClass: String,
targetText: String? = nil,
hierarchy: String
) {
self.init(eventType: Constants.AMP_USER_INTERACTION_EVENT, eventProperties: [
Constants.AMP_APP_VIEW_CONTROLLET: viewController,
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
Constants.AMP_APP_TITLE: title,
Constants.AMP_APP_TARGET_ACCESSIBILITY_LABEL: accessibilityLabel,
Constants.AMP_APP_ACTION_METHOD: actionMethod,
Constants.AMP_APP_TARGET_VIEW_CLASS: targetViewClass,
Constants.AMP_APP_TARGET_TEXT: targetText,
Constants.AMP_APP_HIERARCHY: hierarchy
])
}
}
10 changes: 10 additions & 0 deletions Sources/Amplitude/ObjC/ObjCDefaultTrackingOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,14 @@ public class ObjCDefaultTrackingOptions: NSObject {
options.screenViews = value
}
}

@objc
public var userInteractions: Bool {
get {
options.userInteractions
}
set(value) {
options.userInteractions = value
}
}
}
3 changes: 3 additions & 0 deletions Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ class IOSLifecycleMonitor: UtilityPlugin {
if amplitude.configuration.defaultTracking.screenViews {
UIKitScreenViews.register(amplitude)
}
if amplitude.configuration.defaultTracking.userInteractions {
UIKitUserInteractions.register(amplitude)
}
}

@objc
Expand Down
153 changes: 153 additions & 0 deletions Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst)
import UIKit

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

private static let queue = DispatchQueue(label: "com.amplitude.autocapture", target: .global(qos: .utility))
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved

private static let initializeSwizzle: () = {
swizzleSendAction()
}()

static func register(_ amplitude: Amplitude) {
queue.sync {
amplitudeInstances.add(amplitude)
}
initializeSwizzle
}

private static func swizzleSendAction() {
let applicationClass = UIApplication.self
crleona marked this conversation as resolved.
Show resolved Hide resolved

let originalSelector = #selector(UIApplication.sendAction)
let swizzledSelector = #selector(UIApplication.amp_sendAction)

guard
let originalMethod = class_getInstanceMethod(applicationClass, originalSelector),
let swizzledMethod = class_getInstanceMethod(applicationClass, swizzledSelector)
else { return }

let originalImp = method_getImplementation(originalMethod)
let swizzledImp = method_getImplementation(swizzledMethod)

class_replaceMethod(applicationClass,
swizzledSelector,
originalImp,
method_getTypeEncoding(originalMethod))
class_replaceMethod(applicationClass,
originalSelector,
swizzledImp,
method_getTypeEncoding(swizzledMethod))
}
}

extension UIApplication {
private var keyWindow: UIWindow? {
connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}

@objc dynamic func amp_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
let sendActionResult = amp_sendAction(action, to: target, from: sender, for: event)

guard
sendActionResult,
let keyWindow = keyWindow,
let view = sender as? UIView
else { return sendActionResult }

if let textField = view as? UITextField, !textField.shouldTrack(action, for: event) {
return sendActionResult
} else {
#if !os(tvOS)
if let slider = view as? UISlider, !slider.shouldTrack(action, for: event) {
return sendActionResult
}
#endif
}
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved

let viewData = view.extractData(with: action, in: keyWindow)

UIKitUserInteractions.amplitudeInstances.allObjects.forEach {
$0.track(event: UserInteractionEvent(
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
viewController: viewData.viewController,
title: viewData.title,
accessibilityLabel: viewData.accessibilityLabel,
actionMethod: viewData.actionMethod,
targetViewClass: viewData.targetViewClass,
targetText: viewData.targetText,
hierarchy: viewData.hierarchy))
}

return sendActionResult
}
}

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

struct ViewData {
crleona marked this conversation as resolved.
Show resolved Hide resolved
let viewController: String?
let title: String?
let accessibilityLabel: String?
let actionMethod: String
let targetViewClass: String
let targetText: String?
let hierarchy: String
}

func extractData(with action: Selector, in window: UIWindow) -> ViewData {
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
var targetText: String?

if let button = self as? UIButton {
targetText = button.titleLabel?.text
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
} else if let textField = self as? UITextField {
targetText = textField.placeholder
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
}

let viewController = window.rootViewController
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
let viewControllerClassName = viewController?.descriptiveTypeName
let viewControllerTitle = viewController?.title
let targetAccessibilityLabel = self.accessibilityLabel
let actionName = NSStringFromSelector(action)
let targetViewClassName = self.descriptiveTypeName
let viewHierarchy = sequence(first: self, next: { $0.superview })
.map { $0.descriptiveTypeName }
.joined(separator: UIView.viewHierarchyDelimiter)

return ViewData(
viewController: viewControllerClassName,
title: viewControllerTitle,
accessibilityLabel: targetAccessibilityLabel,
actionMethod: actionName,
targetViewClass: targetViewClassName,
targetText: targetText,
hierarchy: viewHierarchy)
}
}

extension UIResponder {
var descriptiveTypeName: String {
String(describing: type(of: self))
}
}

extension UITextField {
func shouldTrack(_ action: Selector, for event: UIEvent?) -> Bool {
// primaryActionTriggered: is triggered when the text field loses focus.
action == Selector(("primaryActionTriggered:"))
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
}
}

#if !os(tvOS)
extension UISlider {
func shouldTrack(_ action: Selector, for event: UIEvent?) -> Bool {
PouriaAmini marked this conversation as resolved.
Show resolved Hide resolved
event?.allTouches?.contains { $0.phase == .ended && $0.view == self } ?? false
}
}
#endif

#endif
1 change: 1 addition & 0 deletions Tests/AmplitudeTests/AmplitudeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ final class AmplitudeTests: XCTestCase {
let defaultTracking = amplitude.configuration.defaultTracking
XCTAssertFalse(defaultTracking.appLifecycles)
XCTAssertFalse(defaultTracking.screenViews)
XCTAssertFalse(defaultTracking.userInteractions)
XCTAssertTrue(defaultTracking.sessions)
}

Expand Down
6 changes: 5 additions & 1 deletion Tests/AmplitudeTests/DefaultTrackingOptionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,30 @@ final class DefaultTrackingOptionsTests: XCTestCase {
XCTAssertFalse(options.appLifecycles)
XCTAssertFalse(options.screenViews)
XCTAssertTrue(options.sessions)
XCTAssertFalse(options.userInteractions)
}

func testAll() {
let options = DefaultTrackingOptions.ALL
XCTAssertTrue(options.appLifecycles)
XCTAssertTrue(options.screenViews)
XCTAssertTrue(options.sessions)
XCTAssertFalse(options.userInteractions)
}

func testNone() {
let options = DefaultTrackingOptions.NONE
XCTAssertFalse(options.appLifecycles)
XCTAssertFalse(options.screenViews)
XCTAssertFalse(options.sessions)
XCTAssertFalse(options.userInteractions)
}

func testCustom() {
let options = DefaultTrackingOptions(sessions: false, appLifecycles: true, screenViews: true)
let options = DefaultTrackingOptions(sessions: false, appLifecycles: true, screenViews: true, userInteractions: true)
XCTAssertTrue(options.appLifecycles)
XCTAssertTrue(options.screenViews)
XCTAssertFalse(options.sessions)
XCTAssertTrue(options.userInteractions)
}
}
Loading
Loading