@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,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
|
+
}
|