@rejourneyco/react-native 1.0.7 → 1.0.9
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 +1 -1
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
- package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +202 -133
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +29 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +241 -147
- package/ios/Recording/SegmentDispatcher.swift +155 -13
- package/ios/Recording/SpecialCases.swift +614 -0
- package/ios/Recording/StabilityMonitor.swift +42 -34
- package/ios/Recording/TelemetryPipeline.swift +38 -3
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +104 -28
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +32 -20
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/constants.js +2 -2
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/commonjs/sdk/utils.js +1 -1
- package/lib/module/index.js +32 -20
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/constants.js +2 -2
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/module/sdk/utils.js +1 -1
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/sdk/constants.d.ts +2 -2
- package/lib/typescript/types/index.d.ts +15 -8
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +46 -29
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/constants.ts +2 -2
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/sdk/utils.ts +1 -1
- package/src/types/index.ts +16 -9
|
@@ -0,0 +1,572 @@
|
|
|
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.os.Handler
|
|
21
|
+
import android.os.Looper
|
|
22
|
+
import android.view.View
|
|
23
|
+
import android.view.ViewGroup
|
|
24
|
+
import com.rejourney.engine.DiagnosticLog
|
|
25
|
+
import java.lang.ref.WeakReference
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detected map SDK type on Android.
|
|
29
|
+
*/
|
|
30
|
+
enum class MapSDKType {
|
|
31
|
+
GOOGLE_MAPS, // com.google.android.gms.maps.MapView / SupportMapFragment
|
|
32
|
+
MAPBOX // com.mapbox.maps.MapView (v10+) / com.mapbox.mapboxsdk.maps.MapView (v9)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Centralised map-view detection and idle-state management for Android.
|
|
37
|
+
*
|
|
38
|
+
* All map class name checks and SDK-specific idle hooks live here so the
|
|
39
|
+
* rest of the recording pipeline only calls into this module.
|
|
40
|
+
*
|
|
41
|
+
* Safety: every reflective call is wrapped in try/catch. We never throw,
|
|
42
|
+
* never crash the host app. If any hook fails we fall back to
|
|
43
|
+
* [mapIdle] = true so capture is never permanently blocked.
|
|
44
|
+
*/
|
|
45
|
+
class SpecialCases private constructor() {
|
|
46
|
+
|
|
47
|
+
companion object {
|
|
48
|
+
@JvmStatic
|
|
49
|
+
val shared = SpecialCases()
|
|
50
|
+
|
|
51
|
+
// Expo Router + React Navigation nests navigators 3+ levels deep,
|
|
52
|
+
// each adding ~8 depth levels. The deepest screen content can be
|
|
53
|
+
// at depth 25+ before the actual map view. 40 handles any
|
|
54
|
+
// reasonable nesting. The walk is cheap (~200 views) at 1 Hz.
|
|
55
|
+
private const val MAX_SCAN_DEPTH = 40
|
|
56
|
+
|
|
57
|
+
// Fully-qualified class names we look for
|
|
58
|
+
private val GOOGLE_MAP_VIEW_CLASSES = setOf(
|
|
59
|
+
"com.google.android.gms.maps.MapView",
|
|
60
|
+
"com.google.android.gms.maps.SupportMapFragment"
|
|
61
|
+
)
|
|
62
|
+
private val MAPBOX_V10_CLASS = "com.mapbox.maps.MapView"
|
|
63
|
+
private val MAPBOX_V9_CLASS = "com.mapbox.mapboxsdk.maps.MapView"
|
|
64
|
+
|
|
65
|
+
// @rnmapbox/maps React Native wrapper (FrameLayout, not a MapView subclass)
|
|
66
|
+
private val RNMBX_MAPVIEW_CLASS = "com.rnmapbox.rnmbx.components.mapview.RNMBXMapView"
|
|
67
|
+
|
|
68
|
+
// Touch-based idle debounce delay (ms).
|
|
69
|
+
// Mapbox uses UIScrollView.DecelerationRate.normal (0.998/ms).
|
|
70
|
+
// At 2s after a 500pt/s flick, residual velocity is ~9pt/s (barely visible).
|
|
71
|
+
private const val TOUCH_DEBOUNCE_MS = 2000L
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// -- Public state --------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** True when the current activity's decor view contains a known map view. */
|
|
77
|
+
@Volatile
|
|
78
|
+
var mapVisible: Boolean = false
|
|
79
|
+
private set
|
|
80
|
+
|
|
81
|
+
/** True when the map camera has settled (no gesture, no animation).
|
|
82
|
+
* Defaults to true so if hooking fails capture is never blocked. */
|
|
83
|
+
@Volatile
|
|
84
|
+
var mapIdle: Boolean = true
|
|
85
|
+
private set
|
|
86
|
+
|
|
87
|
+
/** Set mapIdle and trigger an immediate frame capture on idle transition. */
|
|
88
|
+
private fun setMapIdle(idle: Boolean) {
|
|
89
|
+
val wasIdle = mapIdle
|
|
90
|
+
mapIdle = idle
|
|
91
|
+
DiagnosticLog.trace("[SpecialCases] mapIdle=$idle (was $wasIdle)")
|
|
92
|
+
if (idle && !wasIdle) {
|
|
93
|
+
// Map just settled — capture a frame immediately instead of
|
|
94
|
+
// waiting up to 1s for the next timer tick.
|
|
95
|
+
try { VisualCapture.shared?.snapshotNow() } catch (_: Exception) {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The detected SDK, or null if no map is present. */
|
|
100
|
+
@Volatile
|
|
101
|
+
var detectedSDK: MapSDKType? = null
|
|
102
|
+
private set
|
|
103
|
+
|
|
104
|
+
// -- Internals -----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
107
|
+
private var hookedMapView: WeakReference<View>? = null
|
|
108
|
+
|
|
109
|
+
/** When true, idle detection is driven by touch events from
|
|
110
|
+
* InteractionRecorder rather than SDK listener hooks.
|
|
111
|
+
* Used as a fallback when reflection-based hooking fails. */
|
|
112
|
+
@Volatile
|
|
113
|
+
private var usesTouchBasedIdle = false
|
|
114
|
+
|
|
115
|
+
/** Runnable posted with TOUCH_DEBOUNCE_MS delay for touch-based idle. */
|
|
116
|
+
private var touchDebounceRunnable: Runnable? = null
|
|
117
|
+
|
|
118
|
+
// -- Map detection (shallow walk) ----------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Scan the activity's decor view for a supported map view.
|
|
122
|
+
* Call from the capture timer (~1 Hz, main thread).
|
|
123
|
+
*/
|
|
124
|
+
fun refreshMapState(activity: Activity?) {
|
|
125
|
+
if (activity == null) {
|
|
126
|
+
clearMapState()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
val decorView = try { activity.window?.decorView } catch (_: Exception) { null }
|
|
130
|
+
if (decorView == null) {
|
|
131
|
+
clearMapState()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
refreshMapState(decorView)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun refreshMapState(root: View) {
|
|
138
|
+
val result = findMapView(root, depth = 0)
|
|
139
|
+
if (result != null) {
|
|
140
|
+
val (mapView, sdk) = result
|
|
141
|
+
val wasVisible = mapVisible
|
|
142
|
+
mapVisible = true
|
|
143
|
+
detectedSDK = sdk
|
|
144
|
+
|
|
145
|
+
// Only hook once per map view instance
|
|
146
|
+
val prev = hookedMapView?.get()
|
|
147
|
+
if (prev == null || prev !== mapView) {
|
|
148
|
+
hookedMapView = WeakReference(mapView)
|
|
149
|
+
hookIdleCallbacks(mapView, sdk)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!wasVisible) {
|
|
153
|
+
// Capture an initial frame the moment we detect the map so
|
|
154
|
+
// the replay always has a starting frame of the map screen.
|
|
155
|
+
try { VisualCapture.shared?.snapshotNow() } catch (_: Exception) {}
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
clearMapState()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -- Hierarchy search ----------------------------------------------------
|
|
163
|
+
|
|
164
|
+
private fun findMapView(view: View, depth: Int): Pair<View, MapSDKType>? {
|
|
165
|
+
if (depth >= MAX_SCAN_DEPTH) return null
|
|
166
|
+
|
|
167
|
+
// Walk the entire class inheritance chain — react-native-maps uses
|
|
168
|
+
// AirMapView (subclass of com.google.android.gms.maps.MapView) and
|
|
169
|
+
// similar wrappers for Mapbox. Checking only the runtime class misses these.
|
|
170
|
+
val sdk = classifyByInheritance(view)
|
|
171
|
+
if (sdk != null) {
|
|
172
|
+
return Pair(view, sdk)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (view is ViewGroup) {
|
|
176
|
+
for (i in 0 until view.childCount) {
|
|
177
|
+
try {
|
|
178
|
+
val child = view.getChildAt(i) ?: continue
|
|
179
|
+
val found = findMapView(child, depth + 1)
|
|
180
|
+
if (found != null) return found
|
|
181
|
+
} catch (_: Exception) {
|
|
182
|
+
// ViewGroup.getChildAt may throw in rare concurrent-modification scenarios
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Walk the superclass chain and return the map SDK type if any
|
|
191
|
+
* ancestor is a known map base class.
|
|
192
|
+
*/
|
|
193
|
+
private fun classifyByInheritance(view: View): MapSDKType? {
|
|
194
|
+
var cls: Class<*>? = view.javaClass
|
|
195
|
+
while (cls != null && cls != View::class.java && cls != Any::class.java) {
|
|
196
|
+
val name = cls.name
|
|
197
|
+
if (name in GOOGLE_MAP_VIEW_CLASSES) return MapSDKType.GOOGLE_MAPS
|
|
198
|
+
if (name == MAPBOX_V10_CLASS) return MapSDKType.MAPBOX
|
|
199
|
+
if (name == MAPBOX_V9_CLASS) return MapSDKType.MAPBOX
|
|
200
|
+
// @rnmapbox/maps wrapper (FrameLayout subclass, not a MapView subclass)
|
|
201
|
+
if (name == RNMBX_MAPVIEW_CLASS) return MapSDKType.MAPBOX
|
|
202
|
+
cls = cls.superclass
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// -- Idle hooks ----------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
private fun hookIdleCallbacks(mapView: View, sdk: MapSDKType) {
|
|
210
|
+
// Reset to safe default before attempting hook
|
|
211
|
+
mapIdle = true
|
|
212
|
+
|
|
213
|
+
when (sdk) {
|
|
214
|
+
MapSDKType.GOOGLE_MAPS -> hookGoogleMaps(mapView)
|
|
215
|
+
MapSDKType.MAPBOX -> hookMapbox(mapView)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- Google Maps -------------------------------------------------------
|
|
220
|
+
// GoogleMap.setOnCameraIdleListener -> idle
|
|
221
|
+
// GoogleMap.setOnCameraMoveStartedListener -> not idle
|
|
222
|
+
|
|
223
|
+
private fun hookGoogleMaps(mapView: View) {
|
|
224
|
+
try {
|
|
225
|
+
// MapView.getMapAsync(OnMapReadyCallback) gives us the GoogleMap instance
|
|
226
|
+
val getMapAsync = mapView.javaClass.getMethod(
|
|
227
|
+
"getMapAsync",
|
|
228
|
+
Class.forName("com.google.android.gms.maps.OnMapReadyCallback")
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// Create an OnMapReadyCallback via a dynamic proxy
|
|
232
|
+
val callbackClass = Class.forName("com.google.android.gms.maps.OnMapReadyCallback")
|
|
233
|
+
val proxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
234
|
+
mapView.javaClass.classLoader,
|
|
235
|
+
arrayOf(callbackClass)
|
|
236
|
+
) { _, method, args ->
|
|
237
|
+
if (method.name == "onMapReady" && args != null && args.isNotEmpty()) {
|
|
238
|
+
val googleMap = args[0] ?: return@newProxyInstance null
|
|
239
|
+
attachGoogleMapListeners(googleMap)
|
|
240
|
+
}
|
|
241
|
+
null
|
|
242
|
+
}
|
|
243
|
+
getMapAsync.invoke(mapView, proxy)
|
|
244
|
+
DiagnosticLog.trace("[SpecialCases] Google Maps getMapAsync invoked")
|
|
245
|
+
} catch (e: Exception) {
|
|
246
|
+
DiagnosticLog.trace("[SpecialCases] Google Maps hook failed: ${e.message}")
|
|
247
|
+
// Leave mapIdle = true so capture is never blocked
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun attachGoogleMapListeners(googleMap: Any) {
|
|
252
|
+
try {
|
|
253
|
+
// setOnCameraIdleListener
|
|
254
|
+
val idleListenerClass = Class.forName(
|
|
255
|
+
"com.google.android.gms.maps.GoogleMap\$OnCameraIdleListener"
|
|
256
|
+
)
|
|
257
|
+
val idleProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
258
|
+
googleMap.javaClass.classLoader,
|
|
259
|
+
arrayOf(idleListenerClass)
|
|
260
|
+
) { _, method, _ ->
|
|
261
|
+
if (method.name == "onCameraIdle") {
|
|
262
|
+
setMapIdle(true)
|
|
263
|
+
}
|
|
264
|
+
null
|
|
265
|
+
}
|
|
266
|
+
googleMap.javaClass.getMethod("setOnCameraIdleListener", idleListenerClass)
|
|
267
|
+
.invoke(googleMap, idleProxy)
|
|
268
|
+
|
|
269
|
+
// setOnCameraMoveStartedListener
|
|
270
|
+
val moveListenerClass = Class.forName(
|
|
271
|
+
"com.google.android.gms.maps.GoogleMap\$OnCameraMoveStartedListener"
|
|
272
|
+
)
|
|
273
|
+
val moveProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
274
|
+
googleMap.javaClass.classLoader,
|
|
275
|
+
arrayOf(moveListenerClass)
|
|
276
|
+
) { _, method, _ ->
|
|
277
|
+
if (method.name == "onCameraMoveStarted") {
|
|
278
|
+
setMapIdle(false)
|
|
279
|
+
}
|
|
280
|
+
null
|
|
281
|
+
}
|
|
282
|
+
googleMap.javaClass.getMethod("setOnCameraMoveStartedListener", moveListenerClass)
|
|
283
|
+
.invoke(googleMap, moveProxy)
|
|
284
|
+
|
|
285
|
+
DiagnosticLog.trace("[SpecialCases] Google Maps idle/move listeners attached")
|
|
286
|
+
} catch (e: Exception) {
|
|
287
|
+
DiagnosticLog.trace("[SpecialCases] Google Maps listener attach failed: ${e.message}")
|
|
288
|
+
mapIdle = true
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- Mapbox ------------------------------------------------------------
|
|
293
|
+
// v10+: MapboxMap.subscribeMapIdle / subscribeCameraChanged
|
|
294
|
+
// v9: MapboxMap.addOnMapIdleListener / addOnCameraMoveStartedListener
|
|
295
|
+
|
|
296
|
+
private fun hookMapbox(mapView: View) {
|
|
297
|
+
// The detected view might be the RNMBXMapView wrapper (FrameLayout),
|
|
298
|
+
// not the actual com.mapbox.maps.MapView. Find the real MapView child.
|
|
299
|
+
val actualMapView = findActualMapboxMapView(mapView) ?: mapView
|
|
300
|
+
|
|
301
|
+
// Try v10 first, then v9, then fall back to touch-based
|
|
302
|
+
if (!tryHookMapboxV10(actualMapView)) {
|
|
303
|
+
if (!tryHookMapboxV9(actualMapView)) {
|
|
304
|
+
// All reflection-based hooking failed — fall back to touch-based
|
|
305
|
+
usesTouchBasedIdle = true
|
|
306
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox: using touch-based idle detection")
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Returns the actual Mapbox MapView for snapshot capture, or null.
|
|
313
|
+
* Used by VisualCapture to call MapView.snapshot() and composite the result
|
|
314
|
+
* (decorView.draw() renders SurfaceView as black).
|
|
315
|
+
*/
|
|
316
|
+
fun getMapboxMapViewForSnapshot(root: View): View? {
|
|
317
|
+
val result = findMapView(root, depth = 0) ?: return null
|
|
318
|
+
if (result.second != MapSDKType.MAPBOX) return null
|
|
319
|
+
return findActualMapboxMapView(result.first)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* If the detected view is the RNMBXMapView wrapper, find the actual
|
|
324
|
+
* com.mapbox.maps.MapView inside it (immediate children).
|
|
325
|
+
*/
|
|
326
|
+
private fun findActualMapboxMapView(view: View): View? {
|
|
327
|
+
// If this view itself is a com.mapbox.maps.MapView, use it directly
|
|
328
|
+
var cls: Class<*>? = view.javaClass
|
|
329
|
+
while (cls != null && cls != View::class.java) {
|
|
330
|
+
if (cls.name == MAPBOX_V10_CLASS || cls.name == MAPBOX_V9_CLASS) return view
|
|
331
|
+
cls = cls.superclass
|
|
332
|
+
}
|
|
333
|
+
// Search immediate children
|
|
334
|
+
if (view is ViewGroup) {
|
|
335
|
+
for (i in 0 until view.childCount) {
|
|
336
|
+
val child = try { view.getChildAt(i) } catch (_: Exception) { null } ?: continue
|
|
337
|
+
var childCls: Class<*>? = child.javaClass
|
|
338
|
+
while (childCls != null && childCls != View::class.java) {
|
|
339
|
+
if (childCls.name == MAPBOX_V10_CLASS || childCls.name == MAPBOX_V9_CLASS) return child
|
|
340
|
+
childCls = childCls.superclass
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// One more level deep
|
|
344
|
+
for (i in 0 until view.childCount) {
|
|
345
|
+
val child = try { view.getChildAt(i) } catch (_: Exception) { null } ?: continue
|
|
346
|
+
if (child is ViewGroup) {
|
|
347
|
+
for (j in 0 until child.childCount) {
|
|
348
|
+
val grandchild = try { child.getChildAt(j) } catch (_: Exception) { null } ?: continue
|
|
349
|
+
var gcCls: Class<*>? = grandchild.javaClass
|
|
350
|
+
while (gcCls != null && gcCls != View::class.java) {
|
|
351
|
+
if (gcCls.name == MAPBOX_V10_CLASS || gcCls.name == MAPBOX_V9_CLASS) return grandchild
|
|
352
|
+
gcCls = gcCls.superclass
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Mapbox Maps SDK v10+
|
|
363
|
+
* MapView.getMapboxMap() -> MapboxMap
|
|
364
|
+
* MapboxMap.subscribeMapIdle { ... }
|
|
365
|
+
* MapboxMap.subscribeCameraChanged { ... }
|
|
366
|
+
*/
|
|
367
|
+
private fun tryHookMapboxV10(mapView: View): Boolean {
|
|
368
|
+
return try {
|
|
369
|
+
val getMapboxMap = mapView.javaClass.getMethod("getMapboxMap")
|
|
370
|
+
val mapboxMap = getMapboxMap.invoke(mapView) ?: return false
|
|
371
|
+
|
|
372
|
+
// subscribeMapIdle -> idle
|
|
373
|
+
try {
|
|
374
|
+
val subscribeIdle = mapboxMap.javaClass.getMethod(
|
|
375
|
+
"subscribeMapIdle",
|
|
376
|
+
Class.forName("com.mapbox.maps.plugin.delegates.listeners.OnMapIdleListener")
|
|
377
|
+
)
|
|
378
|
+
val idleListenerClass = Class.forName(
|
|
379
|
+
"com.mapbox.maps.plugin.delegates.listeners.OnMapIdleListener"
|
|
380
|
+
)
|
|
381
|
+
val idleProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
382
|
+
mapboxMap.javaClass.classLoader,
|
|
383
|
+
arrayOf(idleListenerClass)
|
|
384
|
+
) { _, method, _ ->
|
|
385
|
+
if (method.name == "onMapIdle") {
|
|
386
|
+
setMapIdle(true)
|
|
387
|
+
}
|
|
388
|
+
null
|
|
389
|
+
}
|
|
390
|
+
subscribeIdle.invoke(mapboxMap, idleProxy)
|
|
391
|
+
} catch (_: Exception) {
|
|
392
|
+
// v11 uses a different API shape — try lambda-based subscribe
|
|
393
|
+
tryHookMapboxV11Idle(mapboxMap)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// subscribeCameraChanged -> not idle
|
|
397
|
+
try {
|
|
398
|
+
val subscribeCam = mapboxMap.javaClass.getMethod(
|
|
399
|
+
"subscribeCameraChanged",
|
|
400
|
+
Class.forName("com.mapbox.maps.plugin.delegates.listeners.OnCameraChangeListener")
|
|
401
|
+
)
|
|
402
|
+
val camListenerClass = Class.forName(
|
|
403
|
+
"com.mapbox.maps.plugin.delegates.listeners.OnCameraChangeListener"
|
|
404
|
+
)
|
|
405
|
+
val camProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
406
|
+
mapboxMap.javaClass.classLoader,
|
|
407
|
+
arrayOf(camListenerClass)
|
|
408
|
+
) { _, method, _ ->
|
|
409
|
+
if (method.name == "onCameraChanged") {
|
|
410
|
+
setMapIdle(false)
|
|
411
|
+
}
|
|
412
|
+
null
|
|
413
|
+
}
|
|
414
|
+
subscribeCam.invoke(mapboxMap, camProxy)
|
|
415
|
+
} catch (_: Exception) {
|
|
416
|
+
// Camera-changed hook is best-effort; idle hook is enough
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v10 idle hooks attached")
|
|
420
|
+
true
|
|
421
|
+
} catch (_: Exception) {
|
|
422
|
+
false
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Mapbox v11+ uses addOnMapIdleListener instead of subscribeMapIdle.
|
|
428
|
+
*/
|
|
429
|
+
private fun tryHookMapboxV11Idle(mapboxMap: Any) {
|
|
430
|
+
try {
|
|
431
|
+
val addIdleListener = mapboxMap.javaClass.getMethod(
|
|
432
|
+
"addOnMapIdleListener",
|
|
433
|
+
Class.forName("com.mapbox.maps.plugin.delegates.listeners.OnMapIdleListener")
|
|
434
|
+
)
|
|
435
|
+
val idleListenerClass = Class.forName(
|
|
436
|
+
"com.mapbox.maps.plugin.delegates.listeners.OnMapIdleListener"
|
|
437
|
+
)
|
|
438
|
+
val idleProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
439
|
+
mapboxMap.javaClass.classLoader,
|
|
440
|
+
arrayOf(idleListenerClass)
|
|
441
|
+
) { _, method, _ ->
|
|
442
|
+
if (method.name == "onMapIdle") {
|
|
443
|
+
setMapIdle(true)
|
|
444
|
+
}
|
|
445
|
+
null
|
|
446
|
+
}
|
|
447
|
+
addIdleListener.invoke(mapboxMap, idleProxy)
|
|
448
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v11 idle listener attached")
|
|
449
|
+
} catch (e: Exception) {
|
|
450
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v11 idle hook failed: ${e.message}")
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Mapbox Maps SDK v9 (legacy)
|
|
456
|
+
* MapView.getMapAsync(OnMapReadyCallback) -> MapboxMap
|
|
457
|
+
* MapboxMap.addOnMapIdleListener(...)
|
|
458
|
+
* MapboxMap.addOnCameraMoveStartedListener(...)
|
|
459
|
+
*/
|
|
460
|
+
private fun tryHookMapboxV9(mapView: View): Boolean {
|
|
461
|
+
return try {
|
|
462
|
+
val callbackClassName = "com.mapbox.mapboxsdk.maps.OnMapReadyCallback"
|
|
463
|
+
val callbackClass = Class.forName(callbackClassName)
|
|
464
|
+
val getMapAsync = mapView.javaClass.getMethod("getMapAsync", callbackClass)
|
|
465
|
+
|
|
466
|
+
val proxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
467
|
+
mapView.javaClass.classLoader,
|
|
468
|
+
arrayOf(callbackClass)
|
|
469
|
+
) { _, method, args ->
|
|
470
|
+
if (method.name == "onMapReady" && args != null && args.isNotEmpty()) {
|
|
471
|
+
val mapboxMap = args[0] ?: return@newProxyInstance null
|
|
472
|
+
attachMapboxV9Listeners(mapboxMap)
|
|
473
|
+
}
|
|
474
|
+
null
|
|
475
|
+
}
|
|
476
|
+
getMapAsync.invoke(mapView, proxy)
|
|
477
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v9 getMapAsync invoked")
|
|
478
|
+
true
|
|
479
|
+
} catch (e: Exception) {
|
|
480
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v9 hook failed: ${e.message}")
|
|
481
|
+
false
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private fun attachMapboxV9Listeners(mapboxMap: Any) {
|
|
486
|
+
try {
|
|
487
|
+
// addOnMapIdleListener -> idle
|
|
488
|
+
val idleListenerClass = Class.forName(
|
|
489
|
+
"com.mapbox.mapboxsdk.maps.MapboxMap\$OnMapIdleListener"
|
|
490
|
+
)
|
|
491
|
+
val idleProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
492
|
+
mapboxMap.javaClass.classLoader,
|
|
493
|
+
arrayOf(idleListenerClass)
|
|
494
|
+
) { _, method, _ ->
|
|
495
|
+
if (method.name == "onMapIdle") {
|
|
496
|
+
setMapIdle(true)
|
|
497
|
+
}
|
|
498
|
+
null
|
|
499
|
+
}
|
|
500
|
+
mapboxMap.javaClass.getMethod("addOnMapIdleListener", idleListenerClass)
|
|
501
|
+
.invoke(mapboxMap, idleProxy)
|
|
502
|
+
|
|
503
|
+
// addOnCameraMoveStartedListener -> not idle
|
|
504
|
+
val moveListenerClass = Class.forName(
|
|
505
|
+
"com.mapbox.mapboxsdk.maps.MapboxMap\$OnCameraMoveStartedListener"
|
|
506
|
+
)
|
|
507
|
+
val moveProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
508
|
+
mapboxMap.javaClass.classLoader,
|
|
509
|
+
arrayOf(moveListenerClass)
|
|
510
|
+
) { _, method, _ ->
|
|
511
|
+
if (method.name == "onCameraMoveStarted") {
|
|
512
|
+
setMapIdle(false)
|
|
513
|
+
}
|
|
514
|
+
null
|
|
515
|
+
}
|
|
516
|
+
mapboxMap.javaClass.getMethod("addOnCameraMoveStartedListener", moveListenerClass)
|
|
517
|
+
.invoke(mapboxMap, moveProxy)
|
|
518
|
+
|
|
519
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v9 idle/move listeners attached")
|
|
520
|
+
} catch (e: Exception) {
|
|
521
|
+
DiagnosticLog.trace("[SpecialCases] Mapbox v9 listener attach failed: ${e.message}")
|
|
522
|
+
mapIdle = true
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// -- Touch-based idle detection (fallback) --------------------------------
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Called by InteractionRecorder when a touch begins while a map is visible.
|
|
530
|
+
* Sets mapIdle to false immediately. Always used for "map moving" detection
|
|
531
|
+
* because SDK camera-change hooks (subscribeCameraChanged etc.) often fail
|
|
532
|
+
* or use different APIs across Mapbox v10/v11.
|
|
533
|
+
*/
|
|
534
|
+
fun notifyTouchBegan() {
|
|
535
|
+
if (!mapVisible) return
|
|
536
|
+
touchDebounceRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
537
|
+
touchDebounceRunnable = null
|
|
538
|
+
if (mapIdle) {
|
|
539
|
+
setMapIdle(false)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Called by InteractionRecorder when a touch ends/cancels while a map is visible.
|
|
545
|
+
* Starts a debounce timer; when it fires, mapIdle becomes true (accounting
|
|
546
|
+
* for momentum scrolling/deceleration after the finger lifts).
|
|
547
|
+
*/
|
|
548
|
+
fun notifyTouchEnded() {
|
|
549
|
+
if (!usesTouchBasedIdle || !mapVisible) return
|
|
550
|
+
touchDebounceRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
551
|
+
val runnable = Runnable {
|
|
552
|
+
touchDebounceRunnable = null
|
|
553
|
+
if (!mapIdle) {
|
|
554
|
+
setMapIdle(true)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
touchDebounceRunnable = runnable
|
|
558
|
+
mainHandler.postDelayed(runnable, TOUCH_DEBOUNCE_MS)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// -- Cleanup -------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
private fun clearMapState() {
|
|
564
|
+
mapVisible = false
|
|
565
|
+
mapIdle = true
|
|
566
|
+
detectedSDK = null
|
|
567
|
+
hookedMapView = null
|
|
568
|
+
usesTouchBasedIdle = false
|
|
569
|
+
touchDebounceRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
570
|
+
touchDebounceRunnable = null
|
|
571
|
+
}
|
|
572
|
+
}
|
|
@@ -173,6 +173,9 @@ class StabilityMonitor private constructor(private val context: Context) {
|
|
|
173
173
|
ReplayOrchestrator.shared?.incrementFaultTally()
|
|
174
174
|
persistIncident(incident)
|
|
175
175
|
|
|
176
|
+
// Flush visual frames to disk for crash safety
|
|
177
|
+
try { VisualCapture.shared?.flushToDisk() } catch (_: Exception) { }
|
|
178
|
+
|
|
176
179
|
// Give time to write
|
|
177
180
|
Thread.sleep(150)
|
|
178
181
|
}
|
|
@@ -155,7 +155,7 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
fun submitFrameBundle(payload: ByteArray, filename: String, startMs: Long, endMs: Long, frameCount: Int) {
|
|
158
|
-
DiagnosticLog.
|
|
158
|
+
DiagnosticLog.trace("[TelemetryPipeline] submitFrameBundle: $frameCount frames, ${payload.size} bytes, deferredMode=$deferredMode")
|
|
159
159
|
serialWorker.execute {
|
|
160
160
|
val bundle = PendingFrameBundle(filename, payload, startMs, endMs, frameCount)
|
|
161
161
|
frameQueue.enqueue(bundle)
|
|
@@ -178,15 +178,17 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
178
178
|
if (draining) return
|
|
179
179
|
draining = true
|
|
180
180
|
|
|
181
|
-
// Flush visual frames
|
|
181
|
+
// Flush visual frames to disk for crash safety
|
|
182
182
|
VisualCapture.shared?.flushToDisk()
|
|
183
|
+
// Submit any buffered frames to the upload pipeline (even if below batch threshold)
|
|
184
|
+
VisualCapture.shared?.flushBufferToNetwork()
|
|
183
185
|
|
|
184
186
|
// Try to upload pending data
|
|
185
187
|
serialWorker.execute {
|
|
186
188
|
shipPendingEvents()
|
|
187
189
|
shipPendingFrames()
|
|
188
190
|
|
|
189
|
-
Thread.sleep(
|
|
191
|
+
Thread.sleep(2000)
|
|
190
192
|
draining = false
|
|
191
193
|
}
|
|
192
194
|
}
|
|
@@ -211,7 +213,7 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
211
213
|
return
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
DiagnosticLog.
|
|
216
|
+
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
|
|
215
217
|
|
|
216
218
|
SegmentDispatcher.shared.transmitFrameBundle(
|
|
217
219
|
payload = next.payload,
|
|
@@ -318,6 +320,15 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
318
320
|
))
|
|
319
321
|
}
|
|
320
322
|
|
|
323
|
+
fun recordConsoleLogEvent(level: String, message: String) {
|
|
324
|
+
enqueue(mapOf(
|
|
325
|
+
"type" to "log",
|
|
326
|
+
"timestamp" to ts(),
|
|
327
|
+
"level" to level,
|
|
328
|
+
"message" to message
|
|
329
|
+
))
|
|
330
|
+
}
|
|
331
|
+
|
|
321
332
|
fun recordJSErrorEvent(name: String, message: String, stack: String?) {
|
|
322
333
|
val event = mutableMapOf<String, Any>(
|
|
323
334
|
"type" to "error",
|
|
@@ -329,6 +340,8 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
329
340
|
event["stack"] = stack
|
|
330
341
|
}
|
|
331
342
|
enqueue(event)
|
|
343
|
+
// Prioritize JS error delivery to reduce loss on fatal terminations.
|
|
344
|
+
serialWorker.execute { shipPendingEvents() }
|
|
332
345
|
}
|
|
333
346
|
|
|
334
347
|
fun recordAnrEvent(durationMs: Long, stack: String?) {
|
|
@@ -342,6 +355,8 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
342
355
|
event["stack"] = stack
|
|
343
356
|
}
|
|
344
357
|
enqueue(event)
|
|
358
|
+
// Prioritize ANR delivery while the process is still alive.
|
|
359
|
+
serialWorker.execute { shipPendingEvents() }
|
|
345
360
|
}
|
|
346
361
|
|
|
347
362
|
fun recordUserAssociation(userId: String) {
|
|
@@ -203,6 +203,14 @@ class ViewHierarchyScanner private constructor() {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
private fun isSensitive(view: View): Boolean {
|
|
206
|
+
if (view.contentDescription?.toString() == "rejourney_occlude") return true
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// Check for React Native accessibility hint tag
|
|
210
|
+
val hint = view.getTag(com.facebook.react.R.id.accessibility_hint) as? String
|
|
211
|
+
if (hint == "rejourney_occlude") return true
|
|
212
|
+
} catch (_: Exception) { }
|
|
213
|
+
|
|
206
214
|
if (view is EditText) {
|
|
207
215
|
val inputType = view.inputType
|
|
208
216
|
// Check for password input types
|