@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.
- package/README.md +29 -0
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- 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
|
+
}
|