diff --git a/Amplitude-Swift.xcodeproj/project.pbxproj b/Amplitude-Swift.xcodeproj/project.pbxproj index 2a700cb0..ef5bfa77 100644 --- a/Amplitude-Swift.xcodeproj/project.pbxproj +++ b/Amplitude-Swift.xcodeproj/project.pbxproj @@ -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 */; }; @@ -153,6 +156,9 @@ 3E281B902B9BCC14009D913B /* DispatchQueueHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueHolder.swift; sourceTree = ""; }; 4E05BB932BE41AEB009DE475 /* Amplitude+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Amplitude+Extensions.swift"; sourceTree = ""; }; 4E2B646A2BA127460010E6F8 /* UIKitScreenViewsPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitScreenViewsPluginTests.swift; sourceTree = ""; }; + 6C23EF102C38AC19000DC8C8 /* UIKitUserInteractions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitUserInteractions.swift; sourceTree = ""; }; + 6C23EF122C38AC24000DC8C8 /* UserInteractionEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInteractionEvent.swift; sourceTree = ""; }; + 6C23EF142C38AC32000DC8C8 /* UIKitUserInteractionPluginTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitUserInteractionPluginTest.swift; sourceTree = ""; }; 8EDEC0630C3B587334275D9B /* AmplitudeSessionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmplitudeSessionTests.swift; sourceTree = ""; }; 8EDEC1160D95DC3F0E48DDF7 /* ObjCPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCPlugin.swift; sourceTree = ""; }; 8EDEC1576C95A2EB2FEF00A8 /* ObjCAmplitude.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjCAmplitude.swift; sourceTree = ""; }; @@ -331,6 +337,7 @@ B6F3389F2B6854A8006179E2 /* Plugins */ = { isa = PBXGroup; children = ( + 6C23EF142C38AC32000DC8C8 /* UIKitUserInteractionPluginTest.swift */, B6F338A22B685793006179E2 /* NetworkConnectivityCheckerPluginTests.swift */, 4E2B646A2BA127460010E6F8 /* UIKitScreenViewsPluginTests.swift */, ); @@ -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 */, @@ -392,6 +400,7 @@ OBJ_32 /* iOS */ = { isa = PBXGroup; children = ( + 6C23EF102C38AC19000DC8C8 /* UIKitUserInteractions.swift */, OBJ_33 /* IOSLifecycleMonitor.swift */, 8EDEC650EF79B104DC3C9F4C /* UIKitScreenViews.swift */, ); @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/Sources/Amplitude/Constants.swift b/Sources/Amplitude/Constants.swift index d8dccae7..0bad1a87 100644 --- a/Sources/Amplitude/Constants.swift +++ b/Sources/Amplitude/Constants.swift @@ -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" @@ -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 diff --git a/Sources/Amplitude/DefaultTrackingOptions.swift b/Sources/Amplitude/DefaultTrackingOptions.swift index e4553cfb..e42e52d2 100644 --- a/Sources/Amplitude/DefaultTrackingOptions.swift +++ b/Sources/Amplitude/DefaultTrackingOptions.swift @@ -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 } } diff --git a/Sources/Amplitude/Events/UserInteractionEvent.swift b/Sources/Amplitude/Events/UserInteractionEvent.swift new file mode 100644 index 00000000..17dea195 --- /dev/null +++ b/Sources/Amplitude/Events/UserInteractionEvent.swift @@ -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 + ]) + } +} diff --git a/Sources/Amplitude/ObjC/ObjCDefaultTrackingOptions.swift b/Sources/Amplitude/ObjC/ObjCDefaultTrackingOptions.swift index 4e80f46c..6e5e6b18 100644 --- a/Sources/Amplitude/ObjC/ObjCDefaultTrackingOptions.swift +++ b/Sources/Amplitude/ObjC/ObjCDefaultTrackingOptions.swift @@ -52,4 +52,14 @@ public class ObjCDefaultTrackingOptions: NSObject { options.screenViews = value } } + + @objc + public var userInteractions: Bool { + get { + options.userInteractions + } + set(value) { + options.userInteractions = value + } + } } diff --git a/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift b/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift index 5a9e8cbd..46555d0e 100644 --- a/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift +++ b/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift @@ -36,6 +36,9 @@ class IOSLifecycleMonitor: UtilityPlugin { if amplitude.configuration.defaultTracking.screenViews { UIKitScreenViews.register(amplitude) } + if amplitude.configuration.defaultTracking.userInteractions { + UIKitUserInteractions.register(amplitude) + } } @objc diff --git a/Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift b/Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift new file mode 100644 index 00000000..e6b93ae5 --- /dev/null +++ b/Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift @@ -0,0 +1,172 @@ +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) +import UIKit + +class UIKitUserInteractions { + fileprivate static let amplitudeInstances = NSHashTable.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 + + 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 { + 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 diff --git a/Tests/AmplitudeTests/AmplitudeTests.swift b/Tests/AmplitudeTests/AmplitudeTests.swift index 1038891c..d08b67bb 100644 --- a/Tests/AmplitudeTests/AmplitudeTests.swift +++ b/Tests/AmplitudeTests/AmplitudeTests.swift @@ -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) } diff --git a/Tests/AmplitudeTests/DefaultTrackingOptionsTests.swift b/Tests/AmplitudeTests/DefaultTrackingOptionsTests.swift index 80e7c88f..59767b4c 100644 --- a/Tests/AmplitudeTests/DefaultTrackingOptionsTests.swift +++ b/Tests/AmplitudeTests/DefaultTrackingOptionsTests.swift @@ -8,6 +8,7 @@ final class DefaultTrackingOptionsTests: XCTestCase { XCTAssertFalse(options.appLifecycles) XCTAssertFalse(options.screenViews) XCTAssertTrue(options.sessions) + XCTAssertFalse(options.userInteractions) } func testAll() { @@ -15,6 +16,7 @@ final class DefaultTrackingOptionsTests: XCTestCase { XCTAssertTrue(options.appLifecycles) XCTAssertTrue(options.screenViews) XCTAssertTrue(options.sessions) + XCTAssertFalse(options.userInteractions) } func testNone() { @@ -22,12 +24,14 @@ final class DefaultTrackingOptionsTests: XCTestCase { 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) } } diff --git a/Tests/AmplitudeTests/Plugins/UIKitUserInteractionPluginTest.swift b/Tests/AmplitudeTests/Plugins/UIKitUserInteractionPluginTest.swift new file mode 100644 index 00000000..4ad381db --- /dev/null +++ b/Tests/AmplitudeTests/Plugins/UIKitUserInteractionPluginTest.swift @@ -0,0 +1,71 @@ +import XCTest + +@testable import AmplitudeSwift + +#if os(iOS) + +class UIKitUserInteractionsTests: XCTestCase { + func testExtractDataForUIButton() { + let mockVC = UIViewController() + mockVC.title = "Mock VC Title" + + let button = UIButton(type: .system) + button.setTitle("Test Button", for: .normal) + button.accessibilityLabel = "Accessibility Button" + mockVC.view.addSubview(button) + + let buttonData = button.extractData(with: "action") + + 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")) + } + + func testExtractDataForCustomView() { + let mockVC = UIViewController() + mockVC.title = "Mock VC Title" + + class CustomView: UIView {} + let customView = CustomView() + mockVC.view.addSubview(customView) + + let customViewData = customView.extractData(with: "action") + + 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")) + } + + func testExtractDataForOrphanView() { + let orphanView = UIView() + let orphanData = orphanView.extractData(with: "action") + + XCTAssertNil(orphanData.viewController) + XCTAssertNil(orphanData.title) + XCTAssertNil(orphanData.accessibilityLabel) + XCTAssertEqual(orphanData.action, "action") + XCTAssertEqual(orphanData.targetViewClass, "UIView") + XCTAssertNil(orphanData.targetText) + XCTAssertEqual(orphanData.hierarchy, "UIView") + } + + func testDescriptiveTypeName() { + let button = UIButton() + XCTAssertEqual(button.descriptiveTypeName, "UIButton") + + let vc = UIViewController() + XCTAssertEqual(vc.descriptiveTypeName, "UIViewController") + + class ConstrainedGenericView: UIView {} + XCTAssertEqual(ConstrainedGenericView().descriptiveTypeName, "ConstrainedGenericView") + } +} + +#endif