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 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
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_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_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"

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,
action: String,
targetViewClass: String,
targetText: String? = nil,
hierarchy: String
) {
self.init(eventType: Constants.AMP_USER_INTERACTION_EVENT, eventProperties: [
Constants.AMP_APP_VIEW_CONTROLLER: viewController,
Constants.AMP_APP_TITLE: title,
Constants.AMP_APP_TARGET_ACCESSIBILITY_LABEL: accessibilityLabel,
Constants.AMP_APP_ACTION: action,
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
172 changes: 172 additions & 0 deletions Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst)
import UIKit

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

private static let lock = NSLock()

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

private static let initializeNotificationListeners: () = {
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(UIKitUserInteractions.amp_textFieldDidBeginEditing), name: UITextField.textDidBeginEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(UIKitUserInteractions.amp_textFieldDidEndEditing), name: UITextField.textDidEndEditingNotification, object: nil)
}()

static func register(_ amplitude: Amplitude) {
lock.withLock {
amplitudeInstances.add(amplitude)
}
initializeSwizzle
initializeNotificationListeners
}

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))
}

@objc static func amp_textFieldDidBeginEditing(_ notification: NSNotification) {
guard let textField = notification.object as? UITextField else { return }
let userInteractionEvent = textField.eventFromData(with: "didBeginEditing")
amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
}

@objc static func amp_textFieldDidEndEditing(_ notification: NSNotification) {
guard let textField = notification.object as? UITextField else { return }
let userInteractionEvent = textField.eventFromData(with: "didEndEditing")
amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
}
}

extension UIApplication {
@objc func amp_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
let sendActionResult = amp_sendAction(action, to: target, from: sender, for: event)

guard
sendActionResult,
let view = sender as? UIView,
view.amp_shouldTrack(action, for: event)
else { return sendActionResult }

let userInteractionEvent = view.eventFromData(with: NSStringFromSelector(action).components(separatedBy: ":").first ?? "")

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

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 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)
}

func extractData(with action: String) -> ViewData {
let viewController = owningViewController
return ViewData(
viewController: viewController?.descriptiveTypeName,
title: viewController?.title,
accessibilityLabel: accessibilityLabel,
action: action,
targetViewClass: descriptiveTypeName,
targetText: amp_title,
hierarchy: sequence(first: self, next: \.superview)
.map { $0.descriptiveTypeName }
.joined(separator: UIView.viewHierarchyDelimiter))
}
}

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

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

protocol ActionTrackable {
var amp_title: String? { get }
func amp_shouldTrack(_ action: Selector, for event: UIEvent?) -> Bool
}

extension UIView: ActionTrackable {
@objc var amp_title: String? { nil }
@objc func amp_shouldTrack(_ action: Selector, for event: UIEvent?) -> Bool { true }
}

extension UIButton {
override var amp_title: String? { currentTitle }
}

extension UISegmentedControl {
override var amp_title: String? { titleForSegment(at: selectedSegmentIndex) }
}

extension UITextField {
override func amp_shouldTrack(_ action: Selector, for event: UIEvent?) -> Bool { false }
}

#if !os(tvOS)
extension UISlider {
override func amp_shouldTrack(_ action: Selector, for event: UIEvent?) -> Bool {
event?.allTouches?.contains { $0.phase == .ended && $0.view === self } ?? false
}
}

@available(iOS 14.0, *)
extension UIColorWell {
override var amp_title: String? { title }
}
#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