diff --git a/Amplitude-Swift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Amplitude-Swift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..84c6c5ec --- /dev/null +++ b/Amplitude-Swift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "280b19df31bbf756de6f122c16eea977bf10bf445435d7b3c5deebee87d0e5d6", + "pins" : [ + { + "identity" : "analytics-connector-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/analytics-connector-ios.git", + "state" : { + "revision" : "e2ca17ac735bcbc48b13062484541702ef45153d", + "version" : "1.0.3" + } + } + ], + "version" : 3 +} diff --git a/Sources/Amplitude/Constants.swift b/Sources/Amplitude/Constants.swift index d8dccae7..2bddf817 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" @@ -94,6 +95,11 @@ public struct Constants { 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_INTERACTION_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Interaction" + static let AMP_ELEMENT_LABEL_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Element Label" + static let AMP_ELEMENT_VALUE_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Element Value" + static let AMP_ELEMENT_TYPE_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Element Type" + public struct Configuration { public static let FLUSH_QUEUE_SIZE = 30 public static let FLUSH_INTERVAL_MILLIS = 30 * 1000 // 30s diff --git a/Sources/Amplitude/DefaultTrackingOptions.swift b/Sources/Amplitude/DefaultTrackingOptions.swift index e4553cfb..760d21ef 100644 --- a/Sources/Amplitude/DefaultTrackingOptions.swift +++ b/Sources/Amplitude/DefaultTrackingOptions.swift @@ -11,6 +11,7 @@ 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, @@ -20,5 +21,16 @@ public class DefaultTrackingOptions { self.sessions = sessions self.appLifecycles = appLifecycles self.screenViews = screenViews + self.userInteractions = false + } + + public convenience init ( + sessions: Bool = true, + appLifecycles: Bool = false, + screenViews: Bool = false, + userInteractions: Bool = false + ) { + self.init(sessions: sessions, appLifecycles: appLifecycles, 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..592efc1f --- /dev/null +++ b/Sources/Amplitude/Events/UserInteractionEvent.swift @@ -0,0 +1,60 @@ +import UIKit + +public class UserInteractionEvent: BaseEvent { + + public enum InteractionValue { + case tap(dead: Bool = false) + case longPress(dead: Bool = false) + case rageTap + case focusGained + case focusLost(didTextFieldChange: Bool = false) + case sliderChanged(to: Int) + + var description: String { + switch self { + case .tap(let dead): return dead ? "Dead Tapped" : "Tapped" + case .longPress(let dead): return dead ? "Dead Long Pressed" : "Long Pressed" + case .rageTap: return "Rage Tapped" + case .focusGained: return "Focus Gained" + case .focusLost(let didTextFieldChange): + return didTextFieldChange ? "Focus Lost After Text Modification" : "Focus Lost" + case .sliderChanged(let percentage): + return "Value Changed To \(percentage)%" + } + } + } + + convenience init(_ interactionValue: InteractionValue, label: String? = nil, value: String? = nil, type: UIAccessibilityTraits = .none) { + self.init(eventType: Constants.AMP_USER_INTERACTION_EVENT, eventProperties: [ + Constants.AMP_INTERACTION_PROPERTY: interactionValue.description, + Constants.AMP_ELEMENT_LABEL_PROPERTY: label, + Constants.AMP_ELEMENT_VALUE_PROPERTY: value, + Constants.AMP_ELEMENT_TYPE_PROPERTY: type.stringify() + ]) + } +} + +extension UIAccessibilityTraits { + func stringify() -> String? { + var strings = [String]() + if contains(.adjustable) { strings.append("Adjustable") } + if contains(.allowsDirectInteraction) { strings.append("Allows Direct Interaction") } + if contains(.button) { strings.append("Button") } + if contains(.causesPageTurn) { strings.append("Causes Page Turn") } + if contains(.header) { strings.append("Header") } + if contains(.image) { strings.append("Image") } + if contains(.keyboardKey) { strings.append("Keyboard Key") } + if contains(.link) { strings.append("Link") } + if contains(.notEnabled) { strings.append("Not Enabled") } + if contains(.playsSound) { strings.append("Plays Sound") } + if contains(.searchField) { strings.append("Search Field") } + if contains(.selected) { strings.append("Selected") } + if contains(.startsMediaSession) { strings.append("Starts Media Session") } + if contains(.staticText) { strings.append("Static Text") } + if contains(.summaryElement) { strings.append("Summary Element") } + if contains(.tabBar) { strings.append("Tab Bar") } + if contains(.updatesFrequently) { strings.append("Updates Frequently") } + + return strings.isEmpty ? nil : strings.joined(separator: ", ") + } +} diff --git a/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift b/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift index 57ce8aff..c4310aa0 100644 --- a/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift +++ b/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift @@ -7,131 +7,137 @@ #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) - import Foundation - import SwiftUI - - class IOSLifecycleMonitor: UtilityPlugin { - private var application: UIApplication? - private var appNotifications: [NSNotification.Name] = [ - UIApplication.didEnterBackgroundNotification, - UIApplication.willEnterForegroundNotification, - UIApplication.didFinishLaunchingNotification, - UIApplication.didBecomeActiveNotification, - ] - private var utils: DefaultEventUtils? - private var sendApplicationOpenedOnDidBecomeActive = false - - override init() { - // TODO: Check if lifecycle plugin works for app extension - // App extensions can't use UIApplication.shared, so - // funnel it through something to check; Could be nil. - application = UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication - super.init() - setupListeners() +import Foundation +import SwiftUI + +class IOSLifecycleMonitor: UtilityPlugin { + private var application: UIApplication? { + // TODO: Check if lifecycle plugin works for app extension + // App extensions can't use UIApplication.shared, so + // funnel it through something to check; Could be nil. + UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication + } + + private var appNotifications: [NSNotification.Name] = [ + UIApplication.didEnterBackgroundNotification, + UIApplication.willEnterForegroundNotification, + UIApplication.didFinishLaunchingNotification, + UIApplication.didBecomeActiveNotification, + ] + private var utils: DefaultEventUtils? + private var sendApplicationOpenedOnDidBecomeActive = false + private var userInteractionCaptureDelegate: UserInteractionCaptureDelegate? + + override init() { + super.init() + setupListeners() + } + + public override func setup(amplitude: Amplitude) { + super.setup(amplitude: amplitude) + utils = DefaultEventUtils(amplitude: amplitude) + if amplitude.configuration.defaultTracking.screenViews { + UIKitScreenViews.register(amplitude) } + if amplitude.configuration.defaultTracking.userInteractions { + userInteractionCaptureDelegate = UserInteractionCaptureDelegate(amplitude, with: { [weak self] in self?.application }) + } + } - public override func setup(amplitude: Amplitude) { - super.setup(amplitude: amplitude) - utils = DefaultEventUtils(amplitude: amplitude) - if amplitude.configuration.defaultTracking.screenViews { - UIKitScreenViews.register(amplitude) - } + @objc + func notificationResponse(notification: Notification) { + switch notification.name { + case UIApplication.didEnterBackgroundNotification: + didEnterBackground(notification: notification) + case UIApplication.willEnterForegroundNotification: + applicationWillEnterForeground(notification: notification) + case UIApplication.didFinishLaunchingNotification: + applicationDidFinishLaunchingNotification(notification: notification) + case UIApplication.didBecomeActiveNotification: + applicationDidBecomeActive(notification: notification) + default: + break } + } - @objc - func notificationResponse(notification: Notification) { - switch notification.name { - case UIApplication.didEnterBackgroundNotification: - didEnterBackground(notification: notification) - case UIApplication.willEnterForegroundNotification: - applicationWillEnterForeground(notification: notification) - case UIApplication.didFinishLaunchingNotification: - applicationDidFinishLaunchingNotification(notification: notification) - case UIApplication.didBecomeActiveNotification: - applicationDidBecomeActive(notification: notification) - default: - break - } + func setupListeners() { + // Configure the current life cycle events + let notificationCenter = NotificationCenter.default + for notification in appNotifications { + notificationCenter.addObserver( + self, + selector: #selector(notificationResponse(notification:)), + name: notification, + object: application + ) } - func setupListeners() { - // Configure the current life cycle events - let notificationCenter = NotificationCenter.default - for notification in appNotifications { - notificationCenter.addObserver( - self, - selector: #selector(notificationResponse(notification:)), - name: notification, - object: application - ) - } + } - } + func applicationWillEnterForeground(notification: Notification) { + let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) - func applicationWillEnterForeground(notification: Notification) { - let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) - - let fromBackground: Bool - if let sharedApplication = application { - switch sharedApplication.applicationState { - case .active, .inactive: - fromBackground = false - case .background: - fromBackground = true - @unknown default: - fromBackground = false - } - } else { + let fromBackground: Bool + if let sharedApplication = application { + switch sharedApplication.applicationState { + case .active, .inactive: + fromBackground = false + case .background: + fromBackground = true + @unknown default: fromBackground = false } - - amplitude?.onEnterForeground(timestamp: timestamp) - sendApplicationOpened(fromBackground: fromBackground) + } else { + fromBackground = false } - func didEnterBackground(notification: Notification) { - let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) - self.amplitude?.onExitForeground(timestamp: timestamp) - if self.amplitude?.configuration.defaultTracking.appLifecycles == true { - self.amplitude?.track(eventType: Constants.AMP_APPLICATION_BACKGROUNDED_EVENT) - } + amplitude?.onEnterForeground(timestamp: timestamp) + sendApplicationOpened(fromBackground: fromBackground) + } + + func didEnterBackground(notification: Notification) { + let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) + self.amplitude?.onExitForeground(timestamp: timestamp) + if self.amplitude?.configuration.defaultTracking.appLifecycles == true { + self.amplitude?.track(eventType: Constants.AMP_APPLICATION_BACKGROUNDED_EVENT) } + } - func applicationDidFinishLaunchingNotification(notification: Notification) { - utils?.trackAppUpdatedInstalledEvent() + func applicationDidFinishLaunchingNotification(notification: Notification) { + utils?.trackAppUpdatedInstalledEvent() - // Pre SceneDelegate apps wil not fire a willEnterForeground notification on app launch. - // Instead, use the initial applicationDidBecomeActive - let usesSceneDelegate = application?.delegate?.responds(to: #selector(UIApplicationDelegate.application(_:configurationForConnecting:options:))) ?? false - if !usesSceneDelegate { - sendApplicationOpenedOnDidBecomeActive = true - } + // Pre SceneDelegate apps wil not fire a willEnterForeground notification on app launch. + // Instead, use the initial applicationDidBecomeActive + let usesSceneDelegate = application?.delegate?.responds(to: #selector(UIApplicationDelegate.application(_:configurationForConnecting:options:))) ?? false + if !usesSceneDelegate { + sendApplicationOpenedOnDidBecomeActive = true } + } - func applicationDidBecomeActive(notification: Notification) { - guard sendApplicationOpenedOnDidBecomeActive else { - return - } - sendApplicationOpenedOnDidBecomeActive = false - - let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) - amplitude?.onEnterForeground(timestamp: timestamp) - sendApplicationOpened(fromBackground: false) + func applicationDidBecomeActive(notification: Notification) { + guard sendApplicationOpenedOnDidBecomeActive else { + return } + sendApplicationOpenedOnDidBecomeActive = false - private func sendApplicationOpened(fromBackground: Bool) { - guard amplitude?.configuration.defaultTracking.appLifecycles ?? false else { - return - } - let info = Bundle.main.infoDictionary - let currentBuild = info?["CFBundleVersion"] as? String - let currentVersion = info?["CFBundleShortVersionString"] as? String - self.amplitude?.track(eventType: Constants.AMP_APPLICATION_OPENED_EVENT, eventProperties: [ - Constants.AMP_APP_BUILD_PROPERTY: currentBuild ?? "", - Constants.AMP_APP_VERSION_PROPERTY: currentVersion ?? "", - Constants.AMP_APP_FROM_BACKGROUND_PROPERTY: fromBackground, - ]) + let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) + amplitude?.onEnterForeground(timestamp: timestamp) + sendApplicationOpened(fromBackground: false) + } + + private func sendApplicationOpened(fromBackground: Bool) { + guard amplitude?.configuration.defaultTracking.appLifecycles ?? false else { + return } + let info = Bundle.main.infoDictionary + let currentBuild = info?["CFBundleVersion"] as? String + let currentVersion = info?["CFBundleShortVersionString"] as? String + self.amplitude?.track(eventType: Constants.AMP_APPLICATION_OPENED_EVENT, eventProperties: [ + Constants.AMP_APP_BUILD_PROPERTY: currentBuild ?? "", + Constants.AMP_APP_VERSION_PROPERTY: currentVersion ?? "", + Constants.AMP_APP_FROM_BACKGROUND_PROPERTY: fromBackground, + ]) } +} #endif diff --git a/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/CustomUIGestureRecognizers.swift b/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/CustomUIGestureRecognizers.swift new file mode 100644 index 00000000..10b63848 --- /dev/null +++ b/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/CustomUIGestureRecognizers.swift @@ -0,0 +1,312 @@ +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + +import UIKit + +internal final class GlobalUITextFieldGestureRecognizer: UIGestureRecognizer { + + // MARK: - Private Properties + + /// The delegate that manages user interactions options. + private var captureDelegateHandler: () -> UserInteractionCaptureDelegate? + + /// A list of `UITextFieldDelegate`s to hold a reference to the wrappers. + /// + /// This avoids immediate deallocation since the delegate property of `UITextField` + /// is a weak. + private var delegates = [UITextFieldDelegate]() + + // MARK: - Life Cycle + + init(for captureDelegateHandler: @escaping () -> UserInteractionCaptureDelegate?) { + self.captureDelegateHandler = captureDelegateHandler + super.init(target: nil, action: nil) + } + + // MARK: - Overridden Methods + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + guard + let captureDelegate = captureDelegateHandler() + else { + return + } + + guard + let initialTouchLocation = touches.first?.location(in: captureDelegate.keyWindow), + let target = captureDelegate.accessibilityTargets.first(where: { + $0.shape.contains(initialTouchLocation) + }), + let textField = target.object as? UITextField, + let originalDelegate = textField.delegate, + !(originalDelegate is TextFieldDelegateWrapper) + else { + return + } + + let delegateWrapper = TextFieldDelegateWrapper(captureDelegate, originalDelegate, accessibilityTarget: target) + delegates.append(delegateWrapper) + textField.delegate = delegateWrapper + } +} + +// MARK: - + +internal final class GlobalUISlideGestureRecognizer: UIGestureRecognizer { + + // MARK: - Private Properties + + /// The minimum slide amount in pixels to detect a slide movement. + private static let minimumSlideAmount: Float = 0.01 + + /// The delegate that manages user interactions options. + private var captureDelegateHandler: () -> UserInteractionCaptureDelegate? + + /// The target element when the screen was first touched. + private var initialTarget: AccessibilityTarget? + + /// The value of the target element when the screen was first touched. + private var initialValue: Float? + + // MARK: - Life Cycle + + init(for captureDelegateHandler: @escaping () -> UserInteractionCaptureDelegate?) { + self.captureDelegateHandler = captureDelegateHandler + super.init(target: nil, action: nil) + } + + // MARK: - Overridden Methods + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + guard + let captureDelegate = captureDelegateHandler() + else { + return + } + + guard + let initialTouchLocation = touches.first?.location(in: captureDelegate.keyWindow) + else { + return + } + + // The target of an interaction is the underlying target behind the + // first point when a touch begins since a slider could be modified + // even when the final touch point is outside of its bounds. + if let target = captureDelegate.accessibilityTargets.first(where: { + $0.shape.contains(initialTouchLocation) && + $0.type.contains(.adjustable) + }), + let slider = target.object as? UISlider + { + initialTarget = target + initialValue = slider.value + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + + guard + let captureDelegate = captureDelegateHandler() + else { + return + } + + defer { + self.initialTarget = nil + self.initialValue = .zero + } + + guard + let initialTarget, + let initialValue, + let slider = initialTarget.object as? UISlider, + slider.value != initialValue, + abs(initialValue - slider.value) >= GlobalUISlideGestureRecognizer.minimumSlideAmount + else { + return + } + + let percentage = Int(round(slider.value * 100)) + + captureDelegate.amplitude?.track(event: UserInteractionEvent( + .sliderChanged(to: percentage), + label: initialTarget.label, + value: initialTarget.value, + type: initialTarget.type)) + } +} + +// MARK: - + +internal final class GlobalUIPressGestureRecognizer: UIGestureRecognizer { + + // MARK: - Private Properties + + /// The activation delta between the initial touch point and the final touch point. + private static let pressActivationDelta: Double = 10 + + /// The minimum press duration to detect a long press. + private static let minimumPressDuration: TimeInterval = 0.5 + + /// The number of clicks required to classify the followed clicks as rage clicks during + /// the `rageClickTimeWindow` period. + private static let rageClickCountThreshold = 5 + + /// The time window to detect rage clicks. + private static let rageClickTimeWindow: TimeInterval = 0.8 + + /// The delegate that manages user interactions options. + private var captureDelegateHandler: () -> UserInteractionCaptureDelegate? + + /// The target element when the screen was first touched. + private var initialTarget: AccessibilityTarget? + + /// The root view of the screen when it was first touched. + private var initialRootView: UIView? + + /// The initial point where the scroll gesture began. + private var initialTouchLocation: CGPoint? + + /// The start time of the press gesture. + private var startTime: TimeInterval? + + /// Specifies whether the followed clicks are rage clicks. + private var isRageClicking = false + + /// The element targetted by a series of rage clicks. + private var currentRageClickTarget: NSObject? + + /// Recent taps and the corresponding time associated with the tap. + private var recentTaps: [(target: AccessibilityTarget, time: TimeInterval)] = [] + + // MARK: - Life Cycle + + init(for captureDelegateHandler: @escaping () -> UserInteractionCaptureDelegate?) { + self.captureDelegateHandler = captureDelegateHandler + super.init(target: nil, action: nil) + } + + // MARK: - Overridden Methods + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + guard + let captureDelegate = captureDelegateHandler() + else { + return + } + + initialTouchLocation = touches.first?.location(in: captureDelegate.keyWindow) + initialRootView = captureDelegate.keyWindow + startTime = Date().timeIntervalSince1970 + + if let initialTouchLocation, + let target = captureDelegate.accessibilityTargets.first(where: { + $0.shape.contains(initialTouchLocation) && + ($0.type.contains(.button) || $0.type.contains(.link)) + }) { + initialTarget = target + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + + guard + let captureDelegate = captureDelegateHandler() + else { + return + } + + defer { + self.initialTarget = nil + } + + guard + let initialTouchLocation, + let startTime, + let initialTarget, + let initialRootView, + let endTouchLocation = touches.first?.location(in: initialRootView), + abs(endTouchLocation.x - initialTouchLocation.x) <= GlobalUIPressGestureRecognizer.pressActivationDelta, + abs(endTouchLocation.y - initialTouchLocation.y) <= GlobalUIPressGestureRecognizer.pressActivationDelta + else { + return + } + + let endTime = Date().timeIntervalSince1970 + let pressDuration = endTime - startTime + + recentTaps.append((initialTarget, endTime)) + recentTaps = recentTaps.filter { endTime - $0.time <= GlobalUIPressGestureRecognizer.rageClickTimeWindow } + + // A series of continues rage clicks will be eliminated if the frequency of the clicks is higher + // than the threshold within the specified interval. This means that when a rage click is detected, + // consequent highly frequent clicks on the same element will not be tracked. + if recentTaps.count >= GlobalUIPressGestureRecognizer.rageClickCountThreshold { + if !isRageClicking { + currentRageClickTarget = initialTarget.object + isRageClicking = true + trackClickRaged() + + } else if initialTarget.object !== currentRageClickTarget { + currentRageClickTarget = nil + isRageClicking = false + recentTaps.removeAll() + trackClickNotRaged() + } + } else { + if isRageClicking { + currentRageClickTarget = nil + isRageClicking = false + + } else { + trackClickNotRaged() + } + } + + func trackClickNotRaged() { + var dead = false + var longPress = false + + if initialTarget.type.contains(.notEnabled) { + dead = true + } + + if pressDuration >= GlobalUIPressGestureRecognizer.minimumPressDuration { + longPress = true + } + + captureDelegate.amplitude?.track(event: UserInteractionEvent( + { + if dead && longPress { + return .longPress(dead: true) + } else if dead { + return .tap(dead: true) + } else if longPress { + return .longPress() + } + return .tap() + }(), + label: initialTarget.label, + value: initialTarget.value, + type: initialTarget.type)) + } + + func trackClickRaged() { + captureDelegate.amplitude?.track(event: UserInteractionEvent( + .rageTap, + label: initialTarget.label, + value: initialTarget.value, + type: initialTarget.type)) + } + } +} + +#endif diff --git a/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/CustomUITextFieldDelegate.swift b/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/CustomUITextFieldDelegate.swift new file mode 100644 index 00000000..39fbb6b5 --- /dev/null +++ b/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/CustomUITextFieldDelegate.swift @@ -0,0 +1,121 @@ +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + +import UIKit + +internal final class TextFieldDelegateWrapper: NSObject, UITextFieldDelegate { + + // MARK: - Private Properties + + /// The delegate that manages user interactions options. + private weak var userInteractionCaptureDelegate: UserInteractionCaptureDelegate? + + /// The original delegate of the `UITextField` target. This could be `nil` if there is no delegate. + private weak var existingDelegate: UITextFieldDelegate? + + /// The accessibility metadata of the `UITextField` target. + private var accessibilityTarget: AccessibilityTarget + + /// The content of the field when it gains focuse. + private var previousContent: String? + + // MARK: - Life Cycle + + init(_ userInteractionCaptureDelegate: UserInteractionCaptureDelegate, _ existingDelegate: UITextFieldDelegate?, accessibilityTarget: AccessibilityTarget) { + self.userInteractionCaptureDelegate = userInteractionCaptureDelegate + self.existingDelegate = existingDelegate + self.accessibilityTarget = accessibilityTarget + } + + // MARK: - Public Methods + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + existingDelegate?.textFieldShouldBeginEditing?(textField) ?? true + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + existingDelegate?.textFieldDidBeginEditing?(textField) + + guard + let captureDelegate = userInteractionCaptureDelegate + else { + return + } + + previousContent = textField.text + + captureDelegate.amplitude?.track(event: UserInteractionEvent( + .focusGained, + label: accessibilityTarget.label, + value: nil, + type: accessibilityTarget.type)) + } + + func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { + existingDelegate?.textFieldShouldEndEditing?(textField) ?? true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + existingDelegate?.textFieldDidEndEditing?(textField) + + guard + let captureDelegate = userInteractionCaptureDelegate + else { + return + } + + captureDelegate.amplitude?.track(event: UserInteractionEvent( + previousContent != textField.text ? .focusLost(didTextFieldChange: true) : .focusLost(), + label: accessibilityTarget.label, + value: nil, + type: accessibilityTarget.type)) + } + + func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { + existingDelegate?.textFieldDidEndEditing?(textField, reason: reason) + + guard + let captureDelegate = userInteractionCaptureDelegate + else { + return + } + + captureDelegate.amplitude?.track(event: UserInteractionEvent( + previousContent != textField.text ? .focusLost(didTextFieldChange: true) : .focusLost(), + label: accessibilityTarget.label, + value: nil, + type: accessibilityTarget.type)) + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + existingDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + existingDelegate?.textFieldDidChangeSelection?(textField) + } + + func textFieldShouldClear(_ textField: UITextField) -> Bool { + existingDelegate?.textFieldShouldClear?(textField) ?? true + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + existingDelegate?.textFieldShouldReturn?(textField) ?? true + } + + @available(iOS 16.0, *) + func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + existingDelegate?.textField?(textField, editMenuForCharactersIn: range, suggestedActions: suggestedActions) + } + + @available(iOS 16.0, *) + func textField(_ textField: UITextField, willPresentEditMenuWith animator: any UIEditMenuInteractionAnimating) { + existingDelegate?.textField?(textField, willPresentEditMenuWith: animator) + } + + @available(iOS 16.0, *) + func textField(_ textField: UITextField, willDismissEditMenuWith animator: any UIEditMenuInteractionAnimating) { + existingDelegate?.textField?(textField, willDismissEditMenuWith: animator) + } +} + +#endif diff --git a/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/UserInteractionCaptureDelegate.swift b/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/UserInteractionCaptureDelegate.swift new file mode 100644 index 00000000..10b92ad1 --- /dev/null +++ b/Sources/Amplitude/Plugins/iOS/iOSUserInteractionCapture/UserInteractionCaptureDelegate.swift @@ -0,0 +1,165 @@ +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + +import UIKit + +// MARK: File-private Variables + +private var globalUIPressGestureRecognizer: GlobalUIPressGestureRecognizer? +private var globalUISlideGestureRecognizer: GlobalUISlideGestureRecognizer? +private var globalUITextFieldGestureRecognizer: GlobalUITextFieldGestureRecognizer? + +// MARK: - + +internal final class UserInteractionCaptureDelegate { + + // MARK: - Properties + + weak var amplitude: Amplitude? + + /// The ket window of the application represented as a `UIView` element. + var keyWindow: UIView? { + guard + let windowScene = applicationHandler()?.connectedScenes.first as? UIWindowScene, + let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { + return nil + } + return keyWindow + } + + /// The accessibility targets within `keyWindow`. + var accessibilityTargets: [AccessibilityTarget] { + guard let keyWindow else { return [] } + return accessibilityHierarchyParser.parseAccessibilityElements(in: keyWindow) + } + + // MARK: - Private Properties + + private var applicationHandler: () -> UIApplication? + + private let accessibilityHierarchyParser = AccessibilityHierarchyParser() + + // MARK: - Life Cycle + + init(_ amplitude: Amplitude, with handler: @escaping () -> UIApplication?) { + self.amplitude = amplitude + self.applicationHandler = handler + + globalUIPressGestureRecognizer = GlobalUIPressGestureRecognizer(for: { [weak self] in self }) + globalUISlideGestureRecognizer = GlobalUISlideGestureRecognizer(for: { [weak self] in self }) + globalUITextFieldGestureRecognizer = GlobalUITextFieldGestureRecognizer(for: { [weak self] in self }) + + UIApplication.swizzle + setupAXBundle() + } + + // MARK: - Private Methods + + private func setupAXBundle() { + // Load UIKit accessibility bundle (UIKit.axbundle). This enables accessibility + // metadata initialization, required for autocapture of UI element semantics. + guard + let axBundleURL = Bundle(identifier: "com.apple.UIKit")? + .bundleURL + .deletingLastPathComponent() // Remove "UIKit.framework" + .deletingLastPathComponent() // Remove "Frameworks" + .appendingPathComponent("AccessibilityBundles/UIKit.axbundle"), + let axBundle = Bundle(url: axBundleURL), + axBundle.load() + else { + amplitude?.logger?.error(message: "User interactions capture is not enabled. Accessibility bundle for UIKit was not loaded.") + return + } + } +} + +// MARK: - + +fileprivate extension UIApplication { + + // MARK: - Private Properties + + static let swizzle: Void = { + let applicationCls = UIApplication.self + + let originalSelector = #selector(sendEvent) + let swizzledSelector = #selector(swizzled_sendEvent) + + guard + let originalMethod = class_getInstanceMethod(applicationCls, originalSelector), + let swizzledMethod = class_getInstanceMethod(applicationCls, swizzledSelector) + else { + return + } + + let didAddMethod = class_addMethod( + applicationCls, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod)) + + if didAddMethod { + class_replaceMethod( + applicationCls, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod)) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + }() + + // MARK: - Swizzled Methods + + @objc dynamic func swizzled_sendEvent(_ event: UIEvent) { + swizzled_sendEvent(event) + + guard + let touches = event.allTouches, + let touch = touches.first + else { + return + } + + switch touch.phase { + case .began: + handleTouchesBegan(touches, with: event) + case .ended: + handleTouchesEnded(touches, with: event) + case .cancelled: + handleTouchesCancelled(touches, with: event) + case .moved: + handleTouchesCancelled(touches, with: event) + default: + break + } + } + + // MARK: - Private Methods + + private func handleTouchesBegan(_ touches: Set, with event: UIEvent) { + globalUIPressGestureRecognizer?.touchesBegan(touches, with: event) + globalUISlideGestureRecognizer?.touchesBegan(touches, with: event) + globalUITextFieldGestureRecognizer?.touchesBegan(touches, with: event) + } + + private func handleTouchesEnded(_ touches: Set, with event: UIEvent) { + globalUIPressGestureRecognizer?.touchesEnded(touches, with: event) + globalUISlideGestureRecognizer?.touchesEnded(touches, with: event) + globalUITextFieldGestureRecognizer?.touchesEnded(touches, with: event) + } + + private func handleTouchesCancelled(_ touches: Set, with event: UIEvent) { + globalUIPressGestureRecognizer?.touchesCancelled(touches, with: event) + globalUISlideGestureRecognizer?.touchesCancelled(touches, with: event) + globalUITextFieldGestureRecognizer?.touchesCancelled(touches, with: event) + } + + private func handleTouchesMoved(_ touches: Set, with event: UIEvent) { + globalUIPressGestureRecognizer?.touchesMoved(touches, with: event) + globalUISlideGestureRecognizer?.touchesMoved(touches, with: event) + globalUITextFieldGestureRecognizer?.touchesMoved(touches, with: event) + } +} + +#endif diff --git a/Sources/Amplitude/Utilities/AccessibilityHierarchyParser.swift b/Sources/Amplitude/Utilities/AccessibilityHierarchyParser.swift new file mode 100644 index 00000000..64ed4764 --- /dev/null +++ b/Sources/Amplitude/Utilities/AccessibilityHierarchyParser.swift @@ -0,0 +1,217 @@ +import Accessibility +import UIKit + +public struct AccessibilityTarget { + + // MARK: - Public Types + + public enum Shape { + + /// Accessibility frame, in the coordinate space of the view being processed. + case frame(CGRect) + + /// Accessibility path, in the coordinate space of the view being processed. + case path(UIBezierPath) + + public func contains(_ point: CGPoint) -> Bool { + switch self { + case .frame(let frame): + return frame.contains(point) + case .path(let path): + return path.contains(point) + } + } + + } + + // MARK: - Public Properties + + /// The label of the accessibility element similar to a description read by VoiceOver when the element is brought into + /// focus. + public var label: String? + + /// The value of the accessibility element similar to a description read by VoiceOver when the element is brought into + /// focus. + public var value: String? + + /// The type of the accessibility element similar to a description read by VoiceOver when the element is brought into + /// focus. + public var type: UIAccessibilityTraits + + /// The labels that will be used for user input. + public var userInputLabels: [String]? + + /// The shape that will be highlighted on screen while the element is in focus. + public var shape: Shape + + /// The object representing the accessibility node. + public var object: NSObject +} + +// MARK: - + +/// `AccessibilityHierarchyParser` replicates how assitive technologies such as VoiceOver traverse the accessibility hierarchy to +/// extract accessibility metadata. +public final class AccessibilityHierarchyParser { + + // MARK: - Life Cycle + + public init() {} + + // MARK: - Public Methods + + public func parseAccessibilityElements( + in root: UIView + ) -> [AccessibilityTarget] { + let accessibilityNodes = root.recursiveAccessibilityHierarchy() + + return accessibilityNodes.map { node in + let description = node.object.accessibilityDescriptions() + let (label, value, type) = (description.label, description.value, description.type) + + let userInputLabels: [String]? = { + guard + node.object.accessibilityRespondsToUserInteraction, + let userInputLabels = node.object.accessibilityUserInputLabels, + !userInputLabels.isEmpty + else { + return nil + } + + return userInputLabels + }() + + return AccessibilityTarget( + label: label, + value: value, + type: type, + userInputLabels: userInputLabels, + shape: accessibilityShape(for: node.object, in: root), + object: node.object + ) + } + } + + // MARK: - Private Methods + + /// Returns the shape of the accessibility element in the root view's coordinate space. + private func accessibilityShape(for element: NSObject, in root: UIView) -> AccessibilityTarget.Shape { + if let accessibilityPath = element.accessibilityPath { + return .path(root.convert(accessibilityPath, from: nil)) + + } else if let element = element as? UIAccessibilityElement, let container = element.accessibilityContainer, !element.accessibilityFrameInContainerSpace.isNull { + return .frame(container.convert(element.accessibilityFrameInContainerSpace, to: root)) + + } else { + return .frame(root.convert(element.accessibilityFrame, from: nil)) + } + } + +} + +// MARK: - + +private struct AccessibilityNode { + + /// Represents a single accessibility element. + var object: NSObject + +} + +// MARK: - + +private extension NSObject { + + /// Recursively parses the accessibility elements/containers on the screen. + func recursiveAccessibilityHierarchy() -> [AccessibilityNode] { + guard !accessibilityElementsHidden else { + return [] + } + + // Ignore elements that are views if they are not visible on the screen, either due to visibility, size, or + // alpha. VoiceOver actually has some very low alpha threshold at which it will still display an element + // (presumably to account for animations and/or rounding error). We use an alpha threshold of zero since that + // should fulfill the intent. + if let `self` = self as? UIView, self.isHidden || self.frame.size == .zero || self.alpha <= 0 { + return [] + } + + var recursiveAccessibilityHierarchy: [AccessibilityNode] = [] + + if isAccessibilityElement { + recursiveAccessibilityHierarchy.append(AccessibilityNode(object: self)) + + } else if let accessibilityElements = accessibilityElements as? [NSObject] { + for element in accessibilityElements { + recursiveAccessibilityHierarchy.append( + contentsOf: element.recursiveAccessibilityHierarchy() + ) + } + + } else if let `self` = self as? UIView { + // If there is at least one modal subview, the last modal is the only subview parsed in the accessibility + // hierarchy. Otherwise, parse all of the subviews. + let subviewsToParse: [UIView] + if let lastModalView = self.subviews.last(where: { $0.accessibilityViewIsModal }) { + subviewsToParse = [lastModalView] + } else { + subviewsToParse = self.subviews + } + + for subview in subviewsToParse { + recursiveAccessibilityHierarchy.append( + contentsOf: subview.recursiveAccessibilityHierarchy() + ) + } + + } + + return recursiveAccessibilityHierarchy + } + + func accessibilityDescriptions() -> AccessibilityInfo { + return AccessibilityInfo( + label: accessibilityLabel?.nonEmpty(), + value: accessibilityValue?.nonEmpty(), + type: accessibilityTraits + ) + } + +} + +// MARK: - + +private struct AccessibilityInfo { + let label: String? + let value: String? + let type: UIAccessibilityTraits +} + +// MARK: - + +extension UIView { + + func convert(_ path: UIBezierPath, from source: UIView?) -> UIBezierPath { + let offset = convert(CGPoint.zero, from: source) + let transform = CGAffineTransform(translationX: offset.x, y: offset.y) + + guard let newPath = path.copy() as? UIBezierPath else { + return UIBezierPath() + } + + newPath.apply(transform) + return newPath + } + +} + +// MARK: - + +extension String { + + /// Returns the string if it is non-empty, otherwise nil. + func nonEmpty() -> String? { + return isEmpty ? nil : self + } + +}