@rejourneyco/react-native 1.0.7 → 1.0.8

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 (29) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +20 -18
  3. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +28 -0
  4. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +42 -33
  5. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +242 -34
  6. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  7. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +6 -4
  8. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +156 -64
  9. package/ios/Engine/RejourneyImpl.swift +3 -18
  10. package/ios/Recording/InteractionRecorder.swift +28 -0
  11. package/ios/Recording/ReplayOrchestrator.swift +50 -17
  12. package/ios/Recording/SegmentDispatcher.swift +147 -13
  13. package/ios/Recording/SpecialCases.swift +614 -0
  14. package/ios/Recording/StabilityMonitor.swift +2 -2
  15. package/ios/Recording/TelemetryPipeline.swift +21 -3
  16. package/ios/Recording/VisualCapture.swift +50 -20
  17. package/lib/commonjs/index.js +4 -5
  18. package/lib/commonjs/sdk/constants.js +2 -2
  19. package/lib/commonjs/sdk/utils.js +1 -1
  20. package/lib/module/index.js +4 -5
  21. package/lib/module/sdk/constants.js +2 -2
  22. package/lib/module/sdk/utils.js +1 -1
  23. package/lib/typescript/sdk/constants.d.ts +2 -2
  24. package/lib/typescript/types/index.d.ts +1 -6
  25. package/package.json +2 -2
  26. package/src/index.ts +9 -10
  27. package/src/sdk/constants.ts +2 -2
  28. package/src/sdk/utils.ts +1 -1
  29. package/src/types/index.ts +1 -6
@@ -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
+ }
@@ -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.notice("[TelemetryPipeline] submitFrameBundle: $frameCount frames, ${payload.size} bytes, deferredMode=$deferredMode")
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 immediately
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(500)
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.notice("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
216
+ DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
215
217
 
216
218
  SegmentDispatcher.shared.transmitFrameBundle(
217
219
  payload = next.payload,