@rejourneyco/react-native 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import UIKit
18
+ import ObjectiveC
19
+
20
+ @objc(InteractionRecorder)
21
+ public final class InteractionRecorder: NSObject {
22
+
23
+ @objc public static let shared = InteractionRecorder()
24
+
25
+ @objc public private(set) var isTracking = false
26
+
27
+ private var _gestureAggregator: GestureAggregator?
28
+ private var _inputObservers = NSMapTable<UITextField, AnyObject>.weakToStrongObjects()
29
+ private var _navigationStack: [String] = []
30
+ private let _coalesceWindow: TimeInterval = 0.3
31
+
32
+ private override init() {
33
+ super.init()
34
+ }
35
+
36
+ @objc public func activate() {
37
+ guard !isTracking else { return }
38
+ isTracking = true
39
+ _gestureAggregator = GestureAggregator(delegate: self)
40
+ _installSendEventHook()
41
+ }
42
+
43
+ @objc public func deactivate() {
44
+ guard isTracking else { return }
45
+ isTracking = false
46
+ // The sendEvent swizzle stays installed (one-time global hook), but
47
+ // the isTracking guard in processRawTouches prevents event processing.
48
+ _gestureAggregator = nil
49
+ _inputObservers.removeAllObjects()
50
+ _navigationStack.removeAll()
51
+ }
52
+
53
+ @objc public func observeTextField(_ field: UITextField) {
54
+ guard _inputObservers.object(forKey: field) == nil else { return }
55
+ let observer = InputEndObserver(recorder: self, field: field)
56
+ _inputObservers.setObject(observer, forKey: field)
57
+ }
58
+
59
+ @objc public func pushScreen(_ identifier: String) {
60
+ _navigationStack.append(identifier)
61
+ TelemetryPipeline.shared.recordViewTransition(viewId: identifier, viewLabel: identifier, entering: true)
62
+ ReplayOrchestrator.shared.logScreenView(identifier)
63
+ }
64
+
65
+ @objc public func popScreen() {
66
+ guard let last = _navigationStack.popLast() else { return }
67
+ TelemetryPipeline.shared.recordViewTransition(viewId: last, viewLabel: last, entering: false)
68
+ }
69
+
70
+ private static var _sendEventSwizzled = false
71
+
72
+ /// Install a UIWindow.sendEvent swizzle to passively observe all touch events.
73
+ /// Unlike gesture recognizers, this does NOT participate in the iOS gesture
74
+ /// resolution system, so it never triggers "System gesture gate timed out"
75
+ /// and never delays text input focus or keyboard appearance.
76
+ /// This is the same approach used by Datadog, Sentry, and FullStory SDKs.
77
+ private func _installSendEventHook() {
78
+ guard !InteractionRecorder._sendEventSwizzled else { return }
79
+ InteractionRecorder._sendEventSwizzled = true
80
+ ObjCRuntimeUtils.hotswapSafely(
81
+ cls: UIWindow.self,
82
+ original: #selector(UIWindow.sendEvent(_:)),
83
+ replacement: #selector(UIWindow.rj_sendEvent(_:))
84
+ )
85
+ }
86
+
87
+ /// Called from the swizzled UIWindow.sendEvent to process raw touch events.
88
+ @objc public func processRawTouches(_ event: UIEvent, in window: UIWindow) {
89
+ guard isTracking, let agg = _gestureAggregator else { return }
90
+ guard let touches = event.allTouches else { return }
91
+ for touch in touches {
92
+ agg.processTouch(touch, in: window)
93
+ }
94
+ }
95
+
96
+ fileprivate func reportTap(location: CGPoint, target: String, isInteractive: Bool) {
97
+ TelemetryPipeline.shared.recordTapEvent(label: target, x: UInt64(max(0, location.x)), y: UInt64(max(0, location.y)), isInteractive: isInteractive)
98
+ ReplayOrchestrator.shared.incrementTapTally()
99
+ }
100
+
101
+ fileprivate func reportSwipe(location: CGPoint, direction: SwipeVector, target: String) {
102
+ TelemetryPipeline.shared.recordSwipeEvent(
103
+ label: target,
104
+ x: UInt64(max(0, location.x)),
105
+ y: UInt64(max(0, location.y)),
106
+ direction: direction.label
107
+ )
108
+ ReplayOrchestrator.shared.incrementGestureTally()
109
+ }
110
+
111
+ fileprivate func reportScroll(location: CGPoint, target: String) {
112
+ TelemetryPipeline.shared.recordScrollEvent(
113
+ label: target,
114
+ x: UInt64(max(0, location.x)),
115
+ y: UInt64(max(0, location.y)),
116
+ direction: "vertical"
117
+ )
118
+ ReplayOrchestrator.shared.incrementGestureTally()
119
+ }
120
+
121
+ fileprivate func reportPan(location: CGPoint, target: String) {
122
+ TelemetryPipeline.shared.recordPanEvent(
123
+ label: target,
124
+ x: UInt64(max(0, location.x)),
125
+ y: UInt64(max(0, location.y))
126
+ )
127
+ }
128
+
129
+ fileprivate func reportLongPress(location: CGPoint, target: String) {
130
+ TelemetryPipeline.shared.recordLongPressEvent(
131
+ label: target,
132
+ x: UInt64(max(0, location.x)),
133
+ y: UInt64(max(0, location.y))
134
+ )
135
+ ReplayOrchestrator.shared.incrementGestureTally()
136
+ }
137
+
138
+ fileprivate func reportPinch(location: CGPoint, scale: CGFloat, target: String) {
139
+ TelemetryPipeline.shared.recordPinchEvent(
140
+ label: target,
141
+ x: UInt64(max(0, location.x)),
142
+ y: UInt64(max(0, location.y)),
143
+ scale: Double(scale)
144
+ )
145
+ ReplayOrchestrator.shared.incrementGestureTally()
146
+ }
147
+
148
+ fileprivate func reportRotation(location: CGPoint, angle: CGFloat, target: String) {
149
+ TelemetryPipeline.shared.recordRotationEvent(
150
+ label: target,
151
+ x: UInt64(max(0, location.x)),
152
+ y: UInt64(max(0, location.y)),
153
+ angle: Double(angle)
154
+ )
155
+ ReplayOrchestrator.shared.incrementGestureTally()
156
+ }
157
+
158
+ fileprivate func reportRageTap(location: CGPoint, count: Int, target: String) {
159
+ TelemetryPipeline.shared.recordRageTapEvent(
160
+ label: target,
161
+ x: UInt64(max(0, location.x)),
162
+ y: UInt64(max(0, location.y)),
163
+ count: count
164
+ )
165
+ ReplayOrchestrator.shared.incrementRageTapTally()
166
+ }
167
+
168
+ fileprivate func reportDeadTap(location: CGPoint, target: String) {
169
+ TelemetryPipeline.shared.recordDeadTapEvent(
170
+ label: target,
171
+ x: UInt64(max(0, location.x)),
172
+ y: UInt64(max(0, location.y))
173
+ )
174
+ ReplayOrchestrator.shared.incrementDeadTapTally()
175
+ }
176
+
177
+ fileprivate func reportInput(value: String, masked: Bool, hint: String) {
178
+ TelemetryPipeline.shared.recordInputEvent(value: value, redacted: masked, label: hint)
179
+ }
180
+ }
181
+
182
+ private final class GestureAggregator: NSObject {
183
+
184
+ weak var recorder: InteractionRecorder?
185
+
186
+ // Per-touch state for raw touch processing (replaces UIGestureRecognizer)
187
+ private struct TouchState {
188
+ let startLocation: CGPoint
189
+ let startTime: CFAbsoluteTime
190
+ var lastReportTime: CFAbsoluteTime
191
+ var isPanning: Bool
192
+ var maxDistance: CGFloat
193
+ }
194
+
195
+ private var _activeTouches: [ObjectIdentifier: TouchState] = [:]
196
+
197
+ // Gesture detection thresholds
198
+ private let _tapMaxDuration: CFAbsoluteTime = 0.3
199
+ private let _tapMaxDistance: CGFloat = 10
200
+ private let _panStartThreshold: CGFloat = 10
201
+ private let _longPressMinDuration: CFAbsoluteTime = 0.5
202
+
203
+ // Rage tap detection
204
+ private var _recentTaps: [(location: CGPoint, time: CFAbsoluteTime)] = []
205
+ private let _rageTapThreshold = 3
206
+ private let _rageTapWindow: CFAbsoluteTime = 1.0
207
+ private let _rageTapRadius: CGFloat = 50
208
+
209
+ // Throttle pan events to avoid flooding
210
+ private var _lastPanTime: CFAbsoluteTime = 0
211
+ private let _panThrottleInterval: CFAbsoluteTime = 0.1
212
+
213
+ init(delegate: InteractionRecorder) {
214
+ self.recorder = delegate
215
+ super.init()
216
+ }
217
+
218
+ /// Process a raw touch event from UIWindow.sendEvent swizzle.
219
+ /// This replaces all UIGestureRecognizer-based detection. No recognizers are
220
+ /// installed on any window, so iOS's system gesture gate is never triggered
221
+ /// and text input focus / keyboard appearance is never delayed.
222
+ func processTouch(_ touch: UITouch, in window: UIWindow) {
223
+ let touchId = ObjectIdentifier(touch)
224
+ let location = touch.location(in: window)
225
+ let now = CFAbsoluteTimeGetCurrent()
226
+
227
+ switch touch.phase {
228
+ case .began:
229
+ _activeTouches[touchId] = TouchState(
230
+ startLocation: location,
231
+ startTime: now,
232
+ lastReportTime: 0,
233
+ isPanning: false,
234
+ maxDistance: 0
235
+ )
236
+
237
+ case .moved:
238
+ guard var state = _activeTouches[touchId] else { return }
239
+ let distance = location.distance(to: state.startLocation)
240
+ state.maxDistance = max(state.maxDistance, distance)
241
+
242
+ if !state.isPanning && distance > _panStartThreshold {
243
+ state.isPanning = true
244
+ }
245
+
246
+ if state.isPanning && (now - state.lastReportTime) >= _panThrottleInterval {
247
+ state.lastReportTime = now
248
+ let (target, _) = _resolveTarget(at: location, in: window)
249
+ recorder?.reportPan(location: location, target: target)
250
+ }
251
+
252
+ _activeTouches[touchId] = state
253
+
254
+ case .ended:
255
+ guard let state = _activeTouches.removeValue(forKey: touchId) else { return }
256
+ let duration = now - state.startTime
257
+
258
+ if state.isPanning {
259
+ // Calculate velocity for swipe vs scroll detection
260
+ let dt = max(duration, 0.001)
261
+ let dx = location.x - state.startLocation.x
262
+ let dy = location.y - state.startLocation.y
263
+ let velocity = CGPoint(x: dx / dt, y: dy / dt)
264
+
265
+ let (target, _) = _resolveTarget(at: location, in: window)
266
+ let vec = SwipeVector.from(velocity: velocity)
267
+ if vec != .none {
268
+ recorder?.reportSwipe(location: location, direction: vec, target: target)
269
+ } else {
270
+ recorder?.reportScroll(location: location, target: target)
271
+ }
272
+ ReplayOrchestrator.shared.logScrollAction()
273
+ } else if duration < _tapMaxDuration && state.maxDistance < _tapMaxDistance {
274
+ // Tap — short duration, small movement
275
+ let (target, isInteractive) = _resolveTarget(at: location, in: window)
276
+
277
+ _recentTaps.append((location: location, time: now))
278
+ _pruneOldTaps(now: now)
279
+
280
+ let nearby = _recentTaps.filter { $0.location.distance(to: location) < _rageTapRadius }
281
+ if nearby.count >= _rageTapThreshold {
282
+ recorder?.reportRageTap(location: location, count: nearby.count, target: target)
283
+ _recentTaps.removeAll()
284
+ } else {
285
+ recorder?.reportTap(location: location, target: target, isInteractive: isInteractive)
286
+ }
287
+ } else if duration >= _longPressMinDuration && state.maxDistance < _tapMaxDistance {
288
+ // Long press — held without significant movement
289
+ let (target, _) = _resolveTarget(at: location, in: window)
290
+ recorder?.reportLongPress(location: location, target: target)
291
+ }
292
+
293
+ case .cancelled:
294
+ _activeTouches.removeValue(forKey: touchId)
295
+
296
+ default:
297
+ break
298
+ }
299
+ }
300
+
301
+ private func _pruneOldTaps(now: CFAbsoluteTime) {
302
+ let cutoff = now - _rageTapWindow
303
+ _recentTaps.removeAll { $0.time < cutoff }
304
+ }
305
+
306
+ private func _resolveTarget(at point: CGPoint, in window: UIWindow) -> (label: String, isInteractive: Bool) {
307
+ guard let hit = window.hitTest(point, with: nil) else { return ("window", false) }
308
+
309
+ let label = hit.accessibilityIdentifier ?? hit.accessibilityLabel ?? String(describing: type(of: hit))
310
+ let isInteractive = _isViewInteractive(hit)
311
+
312
+ return (label, isInteractive)
313
+ }
314
+
315
+ /// Check if a view is interactive (buttons, touchables, controls, etc.)
316
+ ///
317
+ /// In React Native Fabric, all view components render as RCTViewComponentView,
318
+ /// so class name heuristics don't work. Instead we rely on:
319
+ /// • UIControl (native buttons/switches/sliders)
320
+ /// • isAccessibilityElement — RN sets this to true for Pressable,
321
+ /// TouchableOpacity, and Button (via `accessible` prop, default true).
322
+ /// Plain View defaults to false.
323
+ /// • accessibilityTraits containing .button or .link
324
+ /// We walk up to 8 ancestors because hitTest returns the deepest child
325
+ /// (e.g. Text inside a Pressable), not the Pressable itself.
326
+ private func _isViewInteractive(_ view: UIView) -> Bool {
327
+ if _isSingleViewInteractive(view) { return true }
328
+
329
+ // Walk ancestor chain — tap inside <Pressable><Text>...</Text></Pressable>
330
+ // hits the Text, but the Pressable parent is the interactive element.
331
+ var ancestor = view.superview
332
+ var depth = 0
333
+ while let parent = ancestor, depth < 8 {
334
+ if _isSingleViewInteractive(parent) { return true }
335
+ ancestor = parent.superview
336
+ depth += 1
337
+ }
338
+
339
+ return false
340
+ }
341
+
342
+ private func _isSingleViewInteractive(_ view: UIView) -> Bool {
343
+ // Native UIControls (UIButton, UISwitch, UISlider, etc.)
344
+ if view is UIControl { return true }
345
+
346
+ // Text inputs
347
+ if view is UITextField || view is UITextView { return true }
348
+
349
+ // React Native Pressable / TouchableOpacity / Button set accessible={true}
350
+ // which maps to isAccessibilityElement = true. Plain View defaults to false.
351
+ if view.isAccessibilityElement {
352
+ return true
353
+ }
354
+
355
+ // Explicit accessibility role indicating interactivity
356
+ let traits = view.accessibilityTraits
357
+ if traits.contains(.button) || traits.contains(.link) {
358
+ return true
359
+ }
360
+
361
+ return false
362
+ }
363
+ }
364
+
365
+ private enum SwipeVector {
366
+ case up, down, left, right, none
367
+
368
+ var label: String {
369
+ switch self {
370
+ case .up: return "up"
371
+ case .down: return "down"
372
+ case .left: return "left"
373
+ case .right: return "right"
374
+ case .none: return "none"
375
+ }
376
+ }
377
+
378
+ static func from(velocity: CGPoint) -> SwipeVector {
379
+ let threshold: CGFloat = 200
380
+ if abs(velocity.x) > abs(velocity.y) {
381
+ if velocity.x > threshold { return .right }
382
+ if velocity.x < -threshold { return .left }
383
+ } else {
384
+ if velocity.y > threshold { return .down }
385
+ if velocity.y < -threshold { return .up }
386
+ }
387
+ return .none
388
+ }
389
+ }
390
+
391
+ private final class InputEndObserver: NSObject {
392
+ weak var recorder: InteractionRecorder?
393
+ weak var field: UITextField?
394
+
395
+ init(recorder: InteractionRecorder, field: UITextField) {
396
+ self.recorder = recorder
397
+ self.field = field
398
+ super.init()
399
+ field.addTarget(self, action: #selector(editingEnded), for: .editingDidEnd)
400
+ }
401
+
402
+ @objc private func editingEnded() {
403
+ guard let f = field else { return }
404
+ let value = f.isSecureTextEntry ? "***" : (f.text ?? "")
405
+ recorder?.reportInput(value: value, masked: f.isSecureTextEntry, hint: f.placeholder ?? "")
406
+ }
407
+ }
408
+
409
+ private extension CGPoint {
410
+ func distance(to other: CGPoint) -> CGFloat {
411
+ sqrt(pow(x - other.x, 2) + pow(y - other.y, 2))
412
+ }
413
+ }
414
+
415
+ // MARK: - UIWindow sendEvent Swizzle
416
+
417
+ extension UIWindow {
418
+ /// Swizzled sendEvent that passively observes touch events for session replay.
419
+ /// After ObjCRuntimeUtils.hotswapSafely swaps the IMP pointers, calling
420
+ /// rj_sendEvent actually invokes the ORIGINAL UIWindow.sendEvent.
421
+ @objc func rj_sendEvent(_ event: UIEvent) {
422
+ if event.type == .touches {
423
+ InteractionRecorder.shared.processRawTouches(event, in: self)
424
+ }
425
+ // Call original sendEvent (this IS the original after swizzle)
426
+ rj_sendEvent(event)
427
+ }
428
+ }