@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,519 @@
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
+ package com.rejourney.recording
18
+
19
+ import android.app.Activity
20
+ import android.content.Context
21
+ import android.view.GestureDetector
22
+ import android.view.MotionEvent
23
+ import android.view.ScaleGestureDetector
24
+ import android.view.View
25
+ import android.view.ViewGroup
26
+ import android.view.Window
27
+ import android.widget.Button
28
+ import android.widget.EditText
29
+ import android.widget.ImageButton
30
+ import java.lang.ref.WeakReference
31
+ import java.util.concurrent.CopyOnWriteArrayList
32
+ import kotlin.math.abs
33
+ import kotlin.math.sqrt
34
+
35
+ /**
36
+ * Touch and gesture recording
37
+ * Android implementation aligned with iOS InteractionRecorder.swift
38
+ */
39
+ class InteractionRecorder private constructor(private val context: Context) {
40
+
41
+ companion object {
42
+ @Volatile
43
+ private var instance: InteractionRecorder? = null
44
+
45
+ fun getInstance(context: Context): InteractionRecorder {
46
+ return instance ?: synchronized(this) {
47
+ instance ?: InteractionRecorder(context.applicationContext).also { instance = it }
48
+ }
49
+ }
50
+
51
+ val shared: InteractionRecorder?
52
+ get() = instance
53
+ }
54
+
55
+ var isTracking = false
56
+ private set
57
+
58
+ private var gestureAggregator: GestureAggregator? = null
59
+ private val inputObservers = CopyOnWriteArrayList<WeakReference<EditText>>()
60
+ private val navigationStack = mutableListOf<String>()
61
+ private val coalesceWindow: Long = 300 // ms
62
+
63
+ internal var currentActivity: WeakReference<Activity>? = null
64
+
65
+ fun setCurrentActivity(activity: Activity?) {
66
+ val oldActivity = currentActivity?.get()
67
+ currentActivity = if (activity != null) WeakReference(activity) else null
68
+ // Re-install the touch listener when the activity changes while tracking
69
+ if (isTracking && activity != null && activity !== oldActivity) {
70
+ removeGlobalTouchListener()
71
+ installGlobalTouchListener()
72
+ }
73
+ }
74
+
75
+ fun activate() {
76
+ if (isTracking) return
77
+ isTracking = true
78
+ gestureAggregator = GestureAggregator(this, context)
79
+ installGlobalTouchListener()
80
+ }
81
+
82
+ fun deactivate() {
83
+ if (!isTracking) return
84
+ isTracking = false
85
+ removeGlobalTouchListener()
86
+ gestureAggregator = null
87
+ inputObservers.clear()
88
+ navigationStack.clear()
89
+ }
90
+
91
+ fun observeTextField(field: EditText) {
92
+ if (inputObservers.any { it.get() === field }) return
93
+ inputObservers.add(WeakReference(field))
94
+ }
95
+
96
+ fun pushScreen(identifier: String) {
97
+ navigationStack.add(identifier)
98
+ TelemetryPipeline.shared?.recordViewTransition(identifier, identifier, true)
99
+ ReplayOrchestrator.shared?.logScreenView(identifier)
100
+ }
101
+
102
+ fun popScreen() {
103
+ val last = navigationStack.removeLastOrNull() ?: return
104
+ TelemetryPipeline.shared?.recordViewTransition(last, last, false)
105
+ }
106
+
107
+ private var originalWindowCallback: Window.Callback? = null
108
+ private var installedWindow: WeakReference<Window>? = null
109
+
110
+ private fun installGlobalTouchListener() {
111
+ val activity = currentActivity?.get() ?: return
112
+ val window = activity.window ?: return
113
+ val original = window.callback ?: return
114
+
115
+ // Don't double-install on the same window
116
+ if (installedWindow?.get() === window && originalWindowCallback != null) return
117
+
118
+ originalWindowCallback = original
119
+ installedWindow = WeakReference(window)
120
+ val agg = gestureAggregator ?: return
121
+
122
+ window.callback = object : Window.Callback by original {
123
+ override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
124
+ if (event != null) {
125
+ agg.processTouchEvent(event)
126
+ }
127
+ return original.dispatchTouchEvent(event)
128
+ }
129
+ }
130
+ }
131
+
132
+ private fun removeGlobalTouchListener() {
133
+ val window = installedWindow?.get()
134
+ if (window != null) {
135
+ originalWindowCallback?.let { window.callback = it }
136
+ }
137
+ originalWindowCallback = null
138
+ installedWindow = null
139
+ }
140
+
141
+ // Report methods (called by GestureAggregator)
142
+
143
+ internal fun reportTap(location: PointF, target: String, isInteractive: Boolean = false) {
144
+ TelemetryPipeline.shared?.recordTapEvent(target, location.x.toLong().coerceAtLeast(0), location.y.toLong().coerceAtLeast(0), isInteractive)
145
+ ReplayOrchestrator.shared?.incrementTapTally()
146
+ }
147
+
148
+ internal fun reportSwipe(location: PointF, direction: SwipeDirection, target: String) {
149
+ TelemetryPipeline.shared?.recordSwipeEvent(
150
+ target,
151
+ location.x.toLong().coerceAtLeast(0),
152
+ location.y.toLong().coerceAtLeast(0),
153
+ direction.label
154
+ )
155
+ ReplayOrchestrator.shared?.incrementGestureTally()
156
+ }
157
+
158
+ internal fun reportScroll(location: PointF, target: String) {
159
+ TelemetryPipeline.shared?.recordScrollEvent(
160
+ target,
161
+ location.x.toLong().coerceAtLeast(0),
162
+ location.y.toLong().coerceAtLeast(0),
163
+ "vertical"
164
+ )
165
+ ReplayOrchestrator.shared?.incrementGestureTally()
166
+ }
167
+
168
+ internal fun reportPan(location: PointF, target: String) {
169
+ TelemetryPipeline.shared?.recordPanEvent(
170
+ target,
171
+ location.x.toLong().coerceAtLeast(0),
172
+ location.y.toLong().coerceAtLeast(0)
173
+ )
174
+ }
175
+
176
+ internal fun reportPinch(location: PointF, scale: Double, target: String) {
177
+ TelemetryPipeline.shared?.recordPinchEvent(
178
+ target,
179
+ location.x.toLong().coerceAtLeast(0),
180
+ location.y.toLong().coerceAtLeast(0),
181
+ scale
182
+ )
183
+ ReplayOrchestrator.shared?.incrementGestureTally()
184
+ }
185
+
186
+ internal fun reportRotation(location: PointF, angle: Double, target: String) {
187
+ TelemetryPipeline.shared?.recordRotationEvent(
188
+ target,
189
+ location.x.toLong().coerceAtLeast(0),
190
+ location.y.toLong().coerceAtLeast(0),
191
+ angle
192
+ )
193
+ ReplayOrchestrator.shared?.incrementGestureTally()
194
+ }
195
+
196
+ internal fun reportLongPress(location: PointF, target: String) {
197
+ TelemetryPipeline.shared?.recordLongPressEvent(
198
+ target,
199
+ location.x.toLong().coerceAtLeast(0),
200
+ location.y.toLong().coerceAtLeast(0)
201
+ )
202
+ ReplayOrchestrator.shared?.incrementGestureTally()
203
+ }
204
+
205
+ internal fun reportRageTap(location: PointF, count: Int, target: String) {
206
+ TelemetryPipeline.shared?.recordRageTapEvent(
207
+ target,
208
+ location.x.toLong().coerceAtLeast(0),
209
+ location.y.toLong().coerceAtLeast(0),
210
+ count
211
+ )
212
+ ReplayOrchestrator.shared?.incrementRageTapTally()
213
+ }
214
+
215
+ internal fun reportDeadTap(location: PointF, target: String) {
216
+ TelemetryPipeline.shared?.recordDeadTapEvent(
217
+ target,
218
+ location.x.toLong().coerceAtLeast(0),
219
+ location.y.toLong().coerceAtLeast(0)
220
+ )
221
+ ReplayOrchestrator.shared?.incrementDeadTapTally()
222
+ }
223
+
224
+ internal fun reportInput(value: String, masked: Boolean, hint: String) {
225
+ TelemetryPipeline.shared?.recordInputEvent(value, masked, hint)
226
+ }
227
+ }
228
+
229
+ data class PointF(val x: Float, val y: Float) {
230
+ fun distance(to: PointF): Float {
231
+ val dx = x - to.x
232
+ val dy = y - to.y
233
+ return sqrt(dx * dx + dy * dy)
234
+ }
235
+ }
236
+
237
+ enum class SwipeDirection(val label: String) {
238
+ UP("up"),
239
+ DOWN("down"),
240
+ LEFT("left"),
241
+ RIGHT("right")
242
+ }
243
+
244
+ private class GestureAggregator(
245
+ private val recorder: InteractionRecorder,
246
+ context: Context
247
+ ) {
248
+ private val gestureDetector: GestureDetector
249
+ private val scaleDetector: ScaleGestureDetector
250
+
251
+ private val recentTaps = mutableListOf<Pair<PointF, Long>>()
252
+ private val rageTapThreshold = 3
253
+ private val rageTapWindow: Long = 1000
254
+ private val rageTapRadius: Float = 50f
255
+
256
+ // Throttle pan/pinch/rotation events
257
+ private var lastThrottleTime: Long = 0
258
+ private val throttleInterval: Long = 100
259
+
260
+ // Track scroll → swipe classification on ACTION_UP
261
+ private var isScrolling = false
262
+ private var lastScrollLocation: PointF? = null
263
+ private var flingDetected = false
264
+
265
+ // Track multi-touch for rotation
266
+ private var previousAngle: Double? = null
267
+ private var isMultiTouch = false
268
+
269
+ init {
270
+ gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
271
+ override fun onDown(e: MotionEvent): Boolean {
272
+ // Must return true so onSingleTapUp / onFling / etc. fire
273
+ isScrolling = false
274
+ flingDetected = false
275
+ return true
276
+ }
277
+
278
+ override fun onSingleTapUp(e: MotionEvent): Boolean {
279
+ val loc = PointF(e.rawX, e.rawY)
280
+ val target = resolveTarget(loc)
281
+ handleTap(loc, target)
282
+ return true
283
+ }
284
+
285
+ override fun onLongPress(e: MotionEvent) {
286
+ val loc = PointF(e.rawX, e.rawY)
287
+ val target = resolveTarget(loc)
288
+ recorder.reportLongPress(loc, target)
289
+ }
290
+
291
+ override fun onScroll(
292
+ e1: MotionEvent?,
293
+ e2: MotionEvent,
294
+ distanceX: Float,
295
+ distanceY: Float
296
+ ): Boolean {
297
+ isScrolling = true
298
+ val loc = PointF(e2.rawX, e2.rawY)
299
+ lastScrollLocation = loc
300
+ val now = System.currentTimeMillis()
301
+ if (now - lastThrottleTime >= throttleInterval) {
302
+ lastThrottleTime = now
303
+ val target = resolveTarget(loc)
304
+ recorder.reportPan(loc, target)
305
+ }
306
+ return true
307
+ }
308
+
309
+ override fun onFling(
310
+ e1: MotionEvent?,
311
+ e2: MotionEvent,
312
+ velocityX: Float,
313
+ velocityY: Float
314
+ ): Boolean {
315
+ flingDetected = true
316
+ val loc = PointF(e2.rawX, e2.rawY)
317
+ val target = resolveTarget(loc)
318
+ val direction = classifyDirection(velocityX, velocityY)
319
+ recorder.reportSwipe(loc, direction, target)
320
+ return true
321
+ }
322
+ })
323
+ gestureDetector.setIsLongpressEnabled(true)
324
+
325
+ scaleDetector = ScaleGestureDetector(context,
326
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
327
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
328
+ val loc = PointF(detector.focusX, detector.focusY)
329
+ val now = System.currentTimeMillis()
330
+ if (now - lastThrottleTime >= throttleInterval) {
331
+ lastThrottleTime = now
332
+ val target = resolveTarget(loc)
333
+ recorder.reportPinch(loc, detector.scaleFactor.toDouble(), target)
334
+ }
335
+ return true
336
+ }
337
+
338
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
339
+ val loc = PointF(detector.focusX, detector.focusY)
340
+ val target = resolveTarget(loc)
341
+ recorder.reportPinch(loc, detector.scaleFactor.toDouble(), target)
342
+ }
343
+ }
344
+ )
345
+ }
346
+
347
+ fun processTouchEvent(event: MotionEvent) {
348
+ scaleDetector.onTouchEvent(event)
349
+ gestureDetector.onTouchEvent(event)
350
+ processRotation(event)
351
+
352
+ when (event.actionMasked) {
353
+ MotionEvent.ACTION_UP -> {
354
+ // If we were scrolling but no fling (swipe) was detected, emit scroll
355
+ if (isScrolling && !flingDetected) {
356
+ val loc = lastScrollLocation ?: PointF(event.rawX, event.rawY)
357
+ val target = resolveTarget(loc)
358
+ recorder.reportScroll(loc, target)
359
+ }
360
+ resetState()
361
+ }
362
+ MotionEvent.ACTION_CANCEL -> resetState()
363
+ }
364
+ }
365
+
366
+ private fun resetState() {
367
+ isScrolling = false
368
+ flingDetected = false
369
+ lastScrollLocation = null
370
+ previousAngle = null
371
+ isMultiTouch = false
372
+ }
373
+
374
+ // --- Multi-touch rotation (no built-in Android detector) ---
375
+
376
+ private fun processRotation(event: MotionEvent) {
377
+ when (event.actionMasked) {
378
+ MotionEvent.ACTION_POINTER_DOWN -> {
379
+ if (event.pointerCount == 2) {
380
+ isMultiTouch = true
381
+ previousAngle = computeAngle(event)
382
+ }
383
+ }
384
+ MotionEvent.ACTION_MOVE -> {
385
+ if (isMultiTouch && event.pointerCount >= 2) {
386
+ val angle = computeAngle(event)
387
+ val prev = previousAngle
388
+ if (prev != null) {
389
+ val delta = angle - prev
390
+ if (abs(delta) > 0.01) {
391
+ val cx = (event.getX(0) + event.getX(1)) / 2
392
+ val cy = (event.getY(0) + event.getY(1)) / 2
393
+ val loc = PointF(cx, cy)
394
+ val now = System.currentTimeMillis()
395
+ if (now - lastThrottleTime >= throttleInterval) {
396
+ lastThrottleTime = now
397
+ val target = resolveTarget(loc)
398
+ recorder.reportRotation(loc, delta, target)
399
+ }
400
+ }
401
+ }
402
+ previousAngle = angle
403
+ }
404
+ }
405
+ MotionEvent.ACTION_POINTER_UP -> {
406
+ if (event.pointerCount <= 2) {
407
+ isMultiTouch = false
408
+ previousAngle = null
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ private fun computeAngle(event: MotionEvent): Double {
415
+ val dx = event.getX(1) - event.getX(0)
416
+ val dy = event.getY(1) - event.getY(0)
417
+ return Math.atan2(dy.toDouble(), dx.toDouble())
418
+ }
419
+
420
+ // --- Tap / rage-tap ---
421
+
422
+ private fun handleTap(location: PointF, target: String) {
423
+ val now = System.currentTimeMillis()
424
+ recentTaps.add(Pair(location, now))
425
+ pruneOldTaps()
426
+
427
+ val nearby = recentTaps.filter { it.first.distance(location) < rageTapRadius }
428
+ if (nearby.size >= rageTapThreshold) {
429
+ recorder.reportRageTap(location, nearby.size, target)
430
+ recentTaps.clear()
431
+ } else {
432
+ val isInteractive = isViewInteractive(location)
433
+ recorder.reportTap(location, target, isInteractive)
434
+ }
435
+ }
436
+
437
+ private fun pruneOldTaps() {
438
+ val cutoff = System.currentTimeMillis() - rageTapWindow
439
+ recentTaps.removeIf { it.second < cutoff }
440
+ }
441
+
442
+ // --- Helpers ---
443
+
444
+ private fun classifyDirection(velocityX: Float, velocityY: Float): SwipeDirection {
445
+ return if (abs(velocityX) > abs(velocityY)) {
446
+ if (velocityX > 0) SwipeDirection.RIGHT else SwipeDirection.LEFT
447
+ } else {
448
+ if (velocityY > 0) SwipeDirection.DOWN else SwipeDirection.UP
449
+ }
450
+ }
451
+
452
+ private fun resolveTarget(location: PointF): String {
453
+ return "view_${location.x.toInt()}_${location.y.toInt()}"
454
+ }
455
+
456
+ /**
457
+ * Check if the view at a given screen location is interactive.
458
+ *
459
+ * In React Native, Pressable/TouchableOpacity set view.isClickable = true
460
+ * on the native Android ReactViewGroup. Plain View defaults to isClickable = false.
461
+ * We walk up to 8 ancestors because the deepest hit view may be a child
462
+ * (e.g. TextView inside a Pressable), not the clickable Pressable itself.
463
+ */
464
+ private fun isViewInteractive(location: PointF): Boolean {
465
+ val activity = recorder.currentActivity?.get() ?: return false
466
+ val decorView = activity.window?.decorView ?: return false
467
+ val hit = findViewAt(decorView, location.x.toInt(), location.y.toInt()) ?: return false
468
+
469
+ // Check the hit view itself
470
+ if (isSingleViewInteractive(hit)) return true
471
+
472
+ // Walk ancestor chain — the hit view may be a child (e.g. TextView)
473
+ // inside a Pressable/TouchableOpacity.
474
+ var ancestor = hit.parent
475
+ var depth = 0
476
+ while (ancestor is View && depth < 8) {
477
+ if (isSingleViewInteractive(ancestor)) return true
478
+ ancestor = (ancestor as View).parent
479
+ depth++
480
+ }
481
+
482
+ return false
483
+ }
484
+
485
+ private fun isSingleViewInteractive(view: View): Boolean {
486
+ // React Native's Pressable/TouchableOpacity set accessible={true} by default,
487
+ // which maps to importantForAccessibility = YES on Android.
488
+ // Plain View defaults to accessible={false} → importantForAccessibility = AUTO.
489
+ if (view.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_YES) return true
490
+
491
+ // Also check contentDescription — RN sets this from accessibilityLabel,
492
+ // which Pressable often has (e.g. accessibilityLabel="Go to Details")
493
+ if (!view.contentDescription.isNullOrEmpty()) return true
494
+
495
+ // Native isClickable (set by native Android buttons, switches, etc.)
496
+ if (view.isClickable || view.isLongClickable) return true
497
+
498
+ // Native input
499
+ if (view is EditText) return true
500
+
501
+ return false
502
+ }
503
+
504
+ private fun findViewAt(root: View, x: Int, y: Int): View? {
505
+ if (root !is ViewGroup) return root
506
+ // Traverse children in reverse order (topmost first)
507
+ for (i in root.childCount - 1 downTo 0) {
508
+ val child = root.getChildAt(i)
509
+ if (child.visibility != View.VISIBLE) continue
510
+ val loc = IntArray(2)
511
+ child.getLocationOnScreen(loc)
512
+ if (x >= loc[0] && x < loc[0] + child.width &&
513
+ y >= loc[1] && y < loc[1] + child.height) {
514
+ return findViewAt(child, x, y) ?: child
515
+ }
516
+ }
517
+ return root
518
+ }
519
+ }