@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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
@@ -40,20 +40,20 @@ import java.util.*
40
40
  * Android implementation aligned with iOS ReplayOrchestrator.swift
41
41
  */
42
42
  class ReplayOrchestrator private constructor(private val context: Context) {
43
-
43
+
44
44
  companion object {
45
45
  @Volatile
46
46
  private var instance: ReplayOrchestrator? = null
47
-
47
+
48
48
  fun getInstance(context: Context): ReplayOrchestrator {
49
49
  return instance ?: synchronized(this) {
50
50
  instance ?: ReplayOrchestrator(context.applicationContext).also { instance = it }
51
51
  }
52
52
  }
53
-
53
+
54
54
  val shared: ReplayOrchestrator?
55
55
  get() = instance
56
-
56
+
57
57
  // Process start time for app startup tracking
58
58
  private val processStartTime: Long by lazy {
59
59
  try {
@@ -72,13 +72,13 @@ class ReplayOrchestrator private constructor(private val context: Context) {
72
72
  }
73
73
  }
74
74
  }
75
-
75
+
76
76
  var apiToken: String? = null
77
77
  var replayId: String? = null
78
78
  var replayStartMs: Long = 0
79
79
  var deferredUploadMode = false
80
80
  var frameBundleSize: Int = 5
81
-
81
+
82
82
  var serverEndpoint: String
83
83
  get() = TelemetryPipeline.shared?.endpoint ?: "https://api.rejourney.co"
84
84
  set(value) {
@@ -86,8 +86,8 @@ class ReplayOrchestrator private constructor(private val context: Context) {
86
86
  SegmentDispatcher.shared.endpoint = value
87
87
  DeviceRegistrar.shared?.endpoint = value
88
88
  }
89
-
90
- var snapshotInterval: Double = 0.33
89
+
90
+ var snapshotInterval: Double = 1.0
91
91
  var compressionLevel: Double = 0.5
92
92
  var visualCaptureEnabled: Boolean = true
93
93
  var interactionCaptureEnabled: Boolean = true
@@ -99,7 +99,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
99
99
  var hierarchyCaptureInterval: Double = 2.0
100
100
  var currentScreenName: String? = null
101
101
  private set
102
-
102
+
103
103
  // Remote config from backend (set via setRemoteConfig before session start)
104
104
  var remoteRejourneyEnabled: Boolean = true
105
105
  private set
@@ -109,7 +109,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
109
109
  private set
110
110
  var remoteMaxRecordingMinutes: Int = 10
111
111
  private set
112
-
112
+
113
113
  // Network state tracking
114
114
  var currentNetworkType: String = "unknown"
115
115
  private set
@@ -119,11 +119,11 @@ class ReplayOrchestrator private constructor(private val context: Context) {
119
119
  private set
120
120
  var networkIsExpensive: Boolean = false
121
121
  private set
122
-
122
+
123
123
  private var networkCallback: ConnectivityManager.NetworkCallback? = null
124
124
  private var netReady = false
125
125
  private var live = false
126
-
126
+
127
127
  private var crashCount = 0
128
128
  private var freezeCount = 0
129
129
  private var errorCount = 0
@@ -140,20 +140,21 @@ class ReplayOrchestrator private constructor(private val context: Context) {
140
140
  private var hierarchyRunnable: Runnable? = null
141
141
  private var lastHierarchyHash: String? = null
142
142
  private var durationLimitRunnable: Runnable? = null
143
-
143
+ private val lifecycleContractVersion = 2
144
+
144
145
  private val mainHandler = Handler(Looper.getMainLooper())
145
-
146
+
146
147
  /**
147
148
  * Fast session start using existing credentials - skips credential fetch for faster restart
148
149
  */
149
150
  fun beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: Map<String, Any>? = null) {
150
151
  val perf = PerformanceSnapshot.capture()
151
152
  DiagnosticLog.debugSessionCreate("ORCHESTRATOR_FAST_INIT", "beginReplayFast with existing credential", perf)
152
-
153
+
153
154
  this.apiToken = apiToken
154
155
  this.serverEndpoint = serverEndpoint
155
156
  applySettings(captureSettings)
156
-
157
+
157
158
  // Set credentials AND endpoint directly without network fetch
158
159
  TelemetryPipeline.shared?.apiToken = apiToken
159
160
  TelemetryPipeline.shared?.credential = credential
@@ -161,51 +162,51 @@ class ReplayOrchestrator private constructor(private val context: Context) {
161
162
  SegmentDispatcher.shared.apiToken = apiToken
162
163
  SegmentDispatcher.shared.credential = credential
163
164
  SegmentDispatcher.shared.endpoint = serverEndpoint
164
-
165
+
165
166
  // Skip network monitoring, assume network is available since we just came from background
166
167
  mainHandler.post {
167
168
  beginRecording(apiToken)
168
169
  }
169
170
  }
170
-
171
+
171
172
  fun beginReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
172
- DiagnosticLog.notice("[ReplayOrchestrator] ★★★ beginReplay v2 ★★★")
173
+ DiagnosticLog.trace("[ReplayOrchestrator] beginReplay v2")
173
174
  val perf = PerformanceSnapshot.capture()
174
175
  DiagnosticLog.debugSessionCreate("ORCHESTRATOR_INIT", "beginReplay", perf)
175
- DiagnosticLog.notice("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
176
-
176
+ DiagnosticLog.trace("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
177
+
177
178
  this.apiToken = apiToken
178
179
  this.serverEndpoint = serverEndpoint
179
180
  applySettings(captureSettings)
180
-
181
+
181
182
  DiagnosticLog.debugSessionCreate("CREDENTIAL_START", "Requesting device credential")
182
- DiagnosticLog.notice("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
183
-
183
+ DiagnosticLog.trace("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
184
+
184
185
  DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
185
- DiagnosticLog.notice("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
186
+ DiagnosticLog.trace("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
186
187
  if (!ok) {
187
188
  DiagnosticLog.debugSessionCreate("CREDENTIAL_FAIL", "Failed")
188
189
  DiagnosticLog.caution("[ReplayOrchestrator] Credential fetch FAILED - recording cannot start")
189
190
  return@obtainCredential
190
191
  }
191
-
192
+
192
193
  TelemetryPipeline.shared?.apiToken = apiToken
193
194
  TelemetryPipeline.shared?.credential = cred
194
195
  SegmentDispatcher.shared.apiToken = apiToken
195
196
  SegmentDispatcher.shared.credential = cred
196
-
197
- DiagnosticLog.notice("[ReplayOrchestrator] Credential OK, calling monitorNetwork")
197
+
198
+ DiagnosticLog.trace("[ReplayOrchestrator] Credential OK, calling monitorNetwork")
198
199
  monitorNetwork(apiToken)
199
200
  }
200
201
  }
201
-
202
+
202
203
  fun beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
203
204
  this.apiToken = apiToken
204
205
  this.serverEndpoint = serverEndpoint
205
206
  deferredUploadMode = true
206
-
207
+
207
208
  applySettings(captureSettings)
208
-
209
+
209
210
  DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
210
211
  if (!ok) return@obtainCredential
211
212
  TelemetryPipeline.shared?.apiToken = apiToken
@@ -213,47 +214,55 @@ class ReplayOrchestrator private constructor(private val context: Context) {
213
214
  SegmentDispatcher.shared.apiToken = apiToken
214
215
  SegmentDispatcher.shared.credential = cred
215
216
  }
216
-
217
+
217
218
  initSession()
218
219
  TelemetryPipeline.shared?.activateDeferredMode()
219
-
220
- val renderCfg = computeRender(3, "standard")
221
-
220
+
221
+ val renderCfg = computeRender(1, "standard")
222
+
222
223
  if (visualCaptureEnabled) {
223
224
  VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
224
225
  VisualCapture.shared?.beginCapture(replayStartMs)
225
226
  VisualCapture.shared?.activateDeferredMode()
226
227
  }
227
-
228
+
228
229
  if (interactionCaptureEnabled) InteractionRecorder.shared?.activate()
229
230
  if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
230
-
231
+
231
232
  live = true
232
233
  }
233
-
234
+
234
235
  fun commitDeferredReplay() {
235
236
  deferredUploadMode = false
236
237
  TelemetryPipeline.shared?.commitDeferredData()
237
238
  VisualCapture.shared?.commitDeferredData()
238
239
  TelemetryPipeline.shared?.activate()
239
240
  }
240
-
241
+
241
242
  fun endReplay(completion: ((Boolean, Boolean) -> Unit)? = null) {
243
+ endReplayInternal("unspecified", completion)
244
+ }
245
+
246
+ fun endReplayWithReason(endReason: String, completion: ((Boolean, Boolean) -> Unit)? = null) {
247
+ endReplayInternal(endReason, completion)
248
+ }
249
+
250
+ private fun endReplayInternal(endReason: String, completion: ((Boolean, Boolean) -> Unit)? = null) {
242
251
  if (!live) {
243
252
  completion?.invoke(false, false)
244
253
  return
245
254
  }
246
255
  live = false
247
-
256
+
248
257
  val sid = replayId ?: ""
249
258
  val termMs = System.currentTimeMillis()
250
259
  val elapsed = ((termMs - replayStartMs) / 1000).toInt()
251
-
260
+
252
261
  unregisterNetworkCallback()
253
262
  stopHierarchyCapture()
254
263
  stopDurationLimitTimer()
255
264
  detachLifecycle()
256
-
265
+
257
266
  val metrics = mapOf(
258
267
  "crashCount" to crashCount,
259
268
  "anrCount" to freezeCount,
@@ -267,40 +276,50 @@ class ReplayOrchestrator private constructor(private val context: Context) {
267
276
  "screensVisited" to visitedScreens.toList(),
268
277
  "screenCount" to visitedScreens.toSet().size
269
278
  )
270
-
271
- SegmentDispatcher.shared.evaluateReplayRetention(sid, metrics) { retain, reason ->
272
- // UI operations MUST run on main thread
273
- mainHandler.post {
274
- TelemetryPipeline.shared?.shutdown()
275
- VisualCapture.shared?.halt()
276
- InteractionRecorder.shared?.deactivate()
277
- StabilityMonitor.shared?.deactivate()
278
- AnrSentinel.shared?.deactivate()
279
- }
280
-
281
- SegmentDispatcher.shared.shipPending()
282
-
283
- if (finalized) {
284
- clearRecovery()
285
- completion?.invoke(true, true)
286
- return@evaluateReplayRetention
287
- }
288
- finalized = true
289
-
290
- SegmentDispatcher.shared.concludeReplay(sid, termMs, bgTimeMs, metrics) { ok ->
279
+ val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
280
+
281
+ // Do local teardown immediately so lifecycle rollover never depends on network latency.
282
+ mainHandler.post {
283
+ TelemetryPipeline.shared?.shutdown()
284
+ VisualCapture.shared?.halt()
285
+ InteractionRecorder.shared?.deactivate()
286
+ StabilityMonitor.shared?.deactivate()
287
+ AnrSentinel.shared?.deactivate()
288
+ }
289
+ SegmentDispatcher.shared.shipPending()
290
+
291
+ if (finalized) {
292
+ clearRecovery()
293
+ completion?.invoke(true, true)
294
+ replayId = null
295
+ replayStartMs = 0
296
+ return
297
+ }
298
+ finalized = true
299
+
300
+ SegmentDispatcher.shared.evaluateReplayRetention(sid, metrics) { _, _ ->
301
+ SegmentDispatcher.shared.concludeReplay(
302
+ sid,
303
+ termMs,
304
+ bgTimeMs,
305
+ metrics,
306
+ queueDepthAtFinalize,
307
+ endReason = endReason,
308
+ lifecycleVersion = lifecycleContractVersion
309
+ ) { ok ->
291
310
  if (ok) clearRecovery()
292
311
  completion?.invoke(true, ok)
293
312
  }
294
313
  }
295
-
314
+
296
315
  replayId = null
297
316
  replayStartMs = 0
298
317
  }
299
-
318
+
300
319
  fun redactView(view: View) {
301
320
  VisualCapture.shared?.registerRedaction(view)
302
321
  }
303
-
322
+
304
323
  /**
305
324
  * Set remote configuration from backend
306
325
  * Called by JS side before startSession to apply server-side settings
@@ -315,88 +334,142 @@ class ReplayOrchestrator private constructor(private val context: Context) {
315
334
  this.remoteRecordingEnabled = recordingEnabled
316
335
  this.remoteSampleRate = sampleRate
317
336
  this.remoteMaxRecordingMinutes = maxRecordingMinutes
318
-
337
+
319
338
  // Set isSampledIn for server-side enforcement
320
339
  // recordingEnabled=false means either dashboard disabled OR session sampled out by JS
321
340
  TelemetryPipeline.shared?.isSampledIn = recordingEnabled
322
-
341
+
323
342
  // Apply recording settings immediately
324
343
  // If recording is disabled, disable visual capture
325
344
  if (!recordingEnabled) {
326
345
  visualCaptureEnabled = false
327
- DiagnosticLog.notice("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
346
+ DiagnosticLog.trace("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
328
347
  }
329
-
348
+
330
349
  // If already recording, restart the duration limit timer with updated config
331
350
  if (live) {
332
351
  startDurationLimitTimer()
333
352
  }
334
-
335
- DiagnosticLog.notice("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
353
+
354
+ DiagnosticLog.trace("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
336
355
  }
337
-
356
+
338
357
  fun unredactView(view: View) {
339
358
  VisualCapture.shared?.unregisterRedaction(view)
340
359
  }
341
-
360
+
342
361
  fun attachAttribute(key: String, value: String) {
343
362
  TelemetryPipeline.shared?.recordAttribute(key, value)
344
363
  }
345
-
364
+
346
365
  fun recordCustomEvent(name: String, payload: String?) {
347
366
  TelemetryPipeline.shared?.recordCustomEvent(name, payload ?: "")
348
367
  }
349
-
368
+
350
369
  fun associateUser(userId: String) {
351
370
  TelemetryPipeline.shared?.recordUserAssociation(userId)
352
371
  }
353
-
372
+
354
373
  fun currentReplayId(): String {
355
374
  return replayId ?: ""
356
375
  }
357
-
376
+
358
377
  fun activateGestureRecording() {
359
378
  // Gesture recording activation - handled by InteractionRecorder
360
379
  }
361
-
380
+
362
381
  fun recoverInterruptedReplay(completion: (String?) -> Unit) {
363
382
  val recoveryFile = File(context.filesDir, "rejourney_recovery.json")
364
-
383
+
365
384
  if (!recoveryFile.exists()) {
366
385
  completion(null)
367
386
  return
368
387
  }
369
-
388
+
370
389
  try {
371
390
  val data = recoveryFile.readText()
372
391
  val checkpoint = JSONObject(data)
373
392
  val recId = checkpoint.optString("replayId", null)
374
-
393
+
375
394
  if (recId == null) {
395
+ clearRecovery()
376
396
  completion(null)
377
397
  return
378
398
  }
379
-
399
+
380
400
  val origStart = checkpoint.optLong("startMs", 0)
381
401
  val nowMs = System.currentTimeMillis()
382
-
402
+
403
+ DiagnosticLog.notice("[ReplayOrchestrator] Recovering interrupted session: $recId")
404
+
383
405
  checkpoint.optString("apiToken", null)?.let { SegmentDispatcher.shared.apiToken = it }
384
406
  checkpoint.optString("endpoint", null)?.let { SegmentDispatcher.shared.endpoint = it }
385
-
386
- val crashMetrics = mapOf(
387
- "crashCount" to 1,
388
- "durationSeconds" to ((nowMs - origStart) / 1000).toInt()
389
- )
390
-
391
- SegmentDispatcher.shared.concludeReplay(recId, nowMs, 0, crashMetrics) { ok ->
392
- clearRecovery()
393
- completion(if (ok) recId else null)
407
+ checkpoint.optString("credential", null)?.let { SegmentDispatcher.shared.credential = it }
408
+ SegmentDispatcher.shared.currentReplayId = recId
409
+ SegmentDispatcher.shared.activate()
410
+ TelemetryPipeline.shared?.currentReplayId = recId
411
+ val hasCrashIncident = hasStoredCrashIncidentForSession(recId)
412
+
413
+ val finalizeRecoveredSession = {
414
+ val crashMetrics = mapOf(
415
+ "crashCount" to if (hasCrashIncident) 1 else 0,
416
+ "durationSeconds" to ((nowMs - origStart) / 1000).toInt()
417
+ )
418
+ val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
419
+
420
+ SegmentDispatcher.shared.concludeReplay(
421
+ recId,
422
+ nowMs,
423
+ 0,
424
+ crashMetrics,
425
+ queueDepthAtFinalize,
426
+ endReason = "recovery_finalize",
427
+ lifecycleVersion = lifecycleContractVersion
428
+ ) { ok ->
429
+ DiagnosticLog.notice("[ReplayOrchestrator] Crash recovery finalize: success=$ok, sessionId=$recId")
430
+ if (ok) {
431
+ clearRecovery()
432
+ }
433
+ completion(if (ok) recId else null)
434
+ }
435
+ }
436
+
437
+ val visualCapture = VisualCapture.shared
438
+ if (visualCapture == null) {
439
+ finalizeRecoveredSession()
440
+ } else {
441
+ visualCapture.uploadPendingFrames(recId) { framesUploaded ->
442
+ if (!framesUploaded) {
443
+ DiagnosticLog.caution("[ReplayOrchestrator] Crash recovery postponed: pending frame upload failed for session $recId")
444
+ completion(null)
445
+ return@uploadPendingFrames
446
+ }
447
+ finalizeRecoveredSession()
448
+ }
394
449
  }
395
450
  } catch (e: Exception) {
451
+ DiagnosticLog.fault("[ReplayOrchestrator] Crash recovery failed: ${e.message}")
396
452
  completion(null)
397
453
  }
398
454
  }
399
-
455
+
456
+ private fun hasStoredCrashIncidentForSession(sessionId: String): Boolean {
457
+ val incidentFile = File(context.cacheDir, "rj_incidents.json")
458
+ if (!incidentFile.exists()) return false
459
+
460
+ return try {
461
+ val incident = JSONObject(incidentFile.readText())
462
+ val incidentSessionId = incident.optString("sessionId", "")
463
+ val category = incident.optString("category", "").lowercase()
464
+ val crashLikeCategory = category == "signal" || category == "exception" || category == "crash"
465
+ val hasSignalDetail = incident.optString("identifier", "").isNotBlank()
466
+ || incident.optString("detail", "").isNotBlank()
467
+ crashLikeCategory && incidentSessionId == sessionId && hasSignalDetail
468
+ } catch (_: Exception) {
469
+ false
470
+ }
471
+ }
472
+
400
473
  // Tally methods
401
474
  fun incrementFaultTally() { crashCount++ }
402
475
  fun incrementStalledTally() { freezeCount++ }
@@ -406,21 +479,21 @@ class ReplayOrchestrator private constructor(private val context: Context) {
406
479
  fun incrementGestureTally() { gestureCount++ }
407
480
  fun incrementRageTapTally() { rageCount++ }
408
481
  fun incrementDeadTapTally() { deadTapCount++ }
409
-
482
+
410
483
  fun logScreenView(screenId: String) {
411
484
  if (screenId.isEmpty()) return
412
485
  visitedScreens.add(screenId)
413
486
  currentScreenName = screenId
414
487
  if (hierarchyCaptureEnabled) captureHierarchy()
415
488
  }
416
-
489
+
417
490
  private fun initSession() {
418
491
  replayStartMs = System.currentTimeMillis()
419
492
  // Always generate a fresh session ID - never reuse stale IDs
420
493
  val uuidPart = UUID.randomUUID().toString().replace("-", "").lowercase()
421
494
  replayId = "session_${replayStartMs}_$uuidPart"
422
495
  finalized = false
423
-
496
+
424
497
  crashCount = 0
425
498
  freezeCount = 0
426
499
  errorCount = 0
@@ -432,27 +505,27 @@ class ReplayOrchestrator private constructor(private val context: Context) {
432
505
  visitedScreens.clear()
433
506
  bgTimeMs = 0
434
507
  bgStartMs = null
435
-
508
+
436
509
  TelemetryPipeline.shared?.currentReplayId = replayId
437
510
  SegmentDispatcher.shared.currentReplayId = replayId
438
511
  StabilityMonitor.shared?.currentSessionId = replayId
439
-
512
+
440
513
  attachLifecycle()
441
514
  saveRecovery()
442
-
515
+
443
516
  recordAppStartup()
444
517
  }
445
-
518
+
446
519
  private fun recordAppStartup() {
447
520
  val nowMs = System.currentTimeMillis()
448
521
  val startupDurationMs = nowMs - processStartTime
449
-
522
+
450
523
  // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
451
524
  if (startupDurationMs > 0 && startupDurationMs < 60000) {
452
525
  TelemetryPipeline.shared?.recordAppStartup(startupDurationMs)
453
526
  }
454
527
  }
455
-
528
+
456
529
  private fun applySettings(cfg: Map<String, Any>?) {
457
530
  if (cfg == null) return
458
531
  snapshotInterval = (cfg["captureRate"] as? Double) ?: 0.33
@@ -465,43 +538,43 @@ class ReplayOrchestrator private constructor(private val context: Context) {
465
538
  wifiRequired = (cfg["wifiOnly"] as? Boolean) ?: false
466
539
  frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?: 5
467
540
  }
468
-
541
+
469
542
  private fun monitorNetwork(token: String) {
470
- DiagnosticLog.notice("[ReplayOrchestrator] monitorNetwork called")
543
+ DiagnosticLog.trace("[ReplayOrchestrator] monitorNetwork called")
471
544
  val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
472
545
  if (connectivityManager == null) {
473
- DiagnosticLog.notice("[ReplayOrchestrator] No ConnectivityManager, starting recording directly")
546
+ DiagnosticLog.trace("[ReplayOrchestrator] No ConnectivityManager, starting recording directly")
474
547
  beginRecording(token)
475
548
  return
476
549
  }
477
-
550
+
478
551
  networkCallback = object : ConnectivityManager.NetworkCallback() {
479
552
  override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
480
553
  handleNetworkChange(capabilities, token)
481
554
  }
482
-
555
+
483
556
  override fun onLost(network: Network) {
484
557
  currentNetworkType = "none"
485
558
  netReady = false
486
559
  }
487
560
  }
488
-
561
+
489
562
  val request = NetworkRequest.Builder()
490
563
  .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
491
564
  .build()
492
-
565
+
493
566
  try {
494
567
  connectivityManager.registerNetworkCallback(request, networkCallback!!)
495
-
568
+
496
569
  // Check current network state immediately (callback only fires on CHANGES)
497
570
  val activeNetwork = connectivityManager.activeNetwork
498
571
  val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
499
- DiagnosticLog.notice("[ReplayOrchestrator] Network check: activeNetwork=${activeNetwork != null}, capabilities=${capabilities != null}")
572
+ DiagnosticLog.trace("[ReplayOrchestrator] Network check: activeNetwork=${activeNetwork != null}, capabilities=${capabilities != null}")
500
573
  if (capabilities != null) {
501
574
  handleNetworkChange(capabilities, token)
502
575
  } else {
503
576
  // No active network - start recording anyway, uploads will retry when network available
504
- DiagnosticLog.notice("[ReplayOrchestrator] No active network, starting recording anyway")
577
+ DiagnosticLog.trace("[ReplayOrchestrator] No active network, starting recording anyway")
505
578
  mainHandler.post { beginRecording(token) }
506
579
  }
507
580
  } catch (e: Exception) {
@@ -509,31 +582,31 @@ class ReplayOrchestrator private constructor(private val context: Context) {
509
582
  beginRecording(token)
510
583
  }
511
584
  }
512
-
585
+
513
586
  private fun handleNetworkChange(capabilities: NetworkCapabilities, token: String) {
514
587
  val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
515
588
  val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
516
589
  val isEthernet = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
517
-
590
+
518
591
  networkIsExpensive = !isWifi && !isEthernet
519
592
  networkIsConstrained = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
520
593
  !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
521
594
  } else {
522
595
  networkIsExpensive
523
596
  }
524
-
597
+
525
598
  currentNetworkType = when {
526
599
  isWifi -> "wifi"
527
600
  isCellular -> "cellular"
528
601
  isEthernet -> "wired"
529
602
  else -> "other"
530
603
  }
531
-
604
+
532
605
  val canProceed = when {
533
606
  wifiRequired && !isWifi -> false
534
607
  else -> true
535
608
  }
536
-
609
+
537
610
  mainHandler.post {
538
611
  netReady = canProceed
539
612
  if (canProceed && !live) {
@@ -541,108 +614,109 @@ class ReplayOrchestrator private constructor(private val context: Context) {
541
614
  }
542
615
  }
543
616
  }
544
-
617
+
545
618
  private fun beginRecording(token: String) {
546
- DiagnosticLog.notice("[ReplayOrchestrator] beginRecording called, live=$live")
619
+ DiagnosticLog.trace("[ReplayOrchestrator] beginRecording called, live=$live")
547
620
  if (live) {
548
- DiagnosticLog.notice("[ReplayOrchestrator] Already live, skipping")
621
+ DiagnosticLog.trace("[ReplayOrchestrator] Already live, skipping")
549
622
  return
550
623
  }
551
624
  live = true
552
-
625
+
553
626
  this.apiToken = token
554
627
  initSession()
555
- DiagnosticLog.notice("[ReplayOrchestrator] Session initialized: replayId=$replayId")
556
-
628
+ DiagnosticLog.trace("[ReplayOrchestrator] Session initialized: replayId=$replayId")
629
+
557
630
  // Reactivate the dispatcher in case it was halted from a previous session
558
631
  SegmentDispatcher.shared.activate()
559
632
  TelemetryPipeline.shared?.activate()
560
-
561
- val renderCfg = computeRender(3, "high")
562
- DiagnosticLog.notice("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
633
+
634
+ val renderCfg = computeRender(1, "standard")
635
+ DiagnosticLog.trace("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
563
636
  VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
564
-
637
+
565
638
  if (visualCaptureEnabled) {
566
- DiagnosticLog.notice("[ReplayOrchestrator] Starting VisualCapture")
639
+ DiagnosticLog.trace("[ReplayOrchestrator] Starting VisualCapture")
567
640
  VisualCapture.shared?.beginCapture(replayStartMs)
568
641
  }
569
642
  if (interactionCaptureEnabled) InteractionRecorder.shared?.activate()
570
643
  if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
571
644
  if (responsivenessCaptureEnabled) AnrSentinel.shared?.activate()
572
645
  if (hierarchyCaptureEnabled) startHierarchyCapture()
573
-
646
+
574
647
  // Start duration limit timer based on remote config
575
648
  startDurationLimitTimer()
576
-
577
- DiagnosticLog.notice("[ReplayOrchestrator] beginRecording completed")
649
+
650
+ DiagnosticLog.trace("[ReplayOrchestrator] beginRecording completed")
578
651
  }
579
-
652
+
580
653
  // MARK: - Duration Limit Timer
581
-
654
+
582
655
  private fun startDurationLimitTimer() {
583
656
  stopDurationLimitTimer()
584
-
657
+
585
658
  val maxMinutes = remoteMaxRecordingMinutes
586
659
  if (maxMinutes <= 0) return
587
-
660
+
588
661
  val maxMs = maxMinutes.toLong() * 60 * 1000
589
662
  val now = System.currentTimeMillis()
590
663
  val elapsed = now - replayStartMs
591
664
  val remaining = if (maxMs > elapsed) maxMs - elapsed else 0L
592
-
665
+
593
666
  if (remaining <= 0) {
594
- DiagnosticLog.notice("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
595
- endReplay()
667
+ DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
668
+ endReplayWithReason("duration_limit")
596
669
  return
597
670
  }
598
-
671
+
599
672
  durationLimitRunnable = Runnable {
600
673
  if (!live) return@Runnable
601
- DiagnosticLog.notice("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
602
- endReplay()
674
+ DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
675
+ endReplayWithReason("duration_limit")
603
676
  }
604
677
  mainHandler.postDelayed(durationLimitRunnable!!, remaining)
605
-
606
- DiagnosticLog.notice("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
678
+
679
+ DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
607
680
  }
608
-
681
+
609
682
  private fun stopDurationLimitTimer() {
610
683
  durationLimitRunnable?.let { mainHandler.removeCallbacks(it) }
611
684
  durationLimitRunnable = null
612
685
  }
613
-
686
+
614
687
  private fun saveRecovery() {
615
688
  val sid = replayId ?: return
616
689
  val token = apiToken ?: return
617
-
690
+
618
691
  val checkpoint = JSONObject().apply {
619
692
  put("replayId", sid)
620
693
  put("apiToken", token)
621
694
  put("startMs", replayStartMs)
622
695
  put("endpoint", serverEndpoint)
696
+ SegmentDispatcher.shared.credential?.let { put("credential", it) }
623
697
  }
624
-
698
+
625
699
  try {
626
700
  File(context.filesDir, "rejourney_recovery.json").writeText(checkpoint.toString())
627
701
  } catch (_: Exception) { }
628
702
  }
629
-
703
+
630
704
  private fun clearRecovery() {
631
705
  try {
632
706
  File(context.filesDir, "rejourney_recovery.json").delete()
633
707
  } catch (_: Exception) { }
634
708
  }
635
-
709
+
636
710
  private fun attachLifecycle() {
637
711
  val app = context as? Application ?: return
638
712
  app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
639
713
  }
640
-
714
+
641
715
  private fun detachLifecycle() {
642
716
  val app = context as? Application ?: return
643
717
  app.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
644
718
  }
645
-
719
+
646
720
  private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
647
721
  override fun onActivityResumed(activity: Activity) {
648
722
  bgStartMs?.let { start ->
@@ -650,19 +724,24 @@ class ReplayOrchestrator private constructor(private val context: Context) {
650
724
  bgTimeMs += (now - start)
651
725
  }
652
726
  bgStartMs = null
727
+
728
+ if (responsivenessCaptureEnabled) {
729
+ AnrSentinel.shared.activate()
730
+ }
653
731
  }
654
-
732
+
655
733
  override fun onActivityPaused(activity: Activity) {
656
734
  bgStartMs = System.currentTimeMillis()
735
+ AnrSentinel.shared.deactivate()
657
736
  }
658
-
737
+
659
738
  override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
660
739
  override fun onActivityStarted(activity: Activity) {}
661
740
  override fun onActivityStopped(activity: Activity) {}
662
741
  override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
663
742
  override fun onActivityDestroyed(activity: Activity) {}
664
743
  }
665
-
744
+
666
745
  private fun unregisterNetworkCallback() {
667
746
  networkCallback?.let { callback ->
668
747
  try {
@@ -672,10 +751,10 @@ class ReplayOrchestrator private constructor(private val context: Context) {
672
751
  }
673
752
  networkCallback = null
674
753
  }
675
-
754
+
676
755
  private fun startHierarchyCapture() {
677
756
  stopHierarchyCapture()
678
-
757
+
679
758
  hierarchyHandler = Handler(Looper.getMainLooper())
680
759
  hierarchyRunnable = object : Runnable {
681
760
  override fun run() {
@@ -684,38 +763,45 @@ class ReplayOrchestrator private constructor(private val context: Context) {
684
763
  }
685
764
  }
686
765
  hierarchyHandler?.postDelayed(hierarchyRunnable!!, (hierarchyCaptureInterval * 1000).toLong())
687
-
766
+
688
767
  // Initial capture after 500ms
689
768
  hierarchyHandler?.postDelayed({ captureHierarchy() }, 500)
690
769
  }
691
-
770
+
692
771
  private fun stopHierarchyCapture() {
693
772
  hierarchyRunnable?.let { hierarchyHandler?.removeCallbacks(it) }
694
773
  hierarchyHandler = null
695
774
  hierarchyRunnable = null
696
775
  }
697
-
776
+
698
777
  private fun captureHierarchy() {
699
778
  if (!live) return
700
779
  val sid = replayId ?: return
701
-
780
+
702
781
  if (Looper.myLooper() != Looper.getMainLooper()) {
703
782
  mainHandler.post { captureHierarchy() }
704
783
  return
705
784
  }
706
-
785
+
786
+ // Throttle hierarchy capture when map is visible and animating —
787
+ // ViewHierarchyScanner traverses the full view tree including map's
788
+ // deep SurfaceView/TextureView children, adding main-thread pressure.
789
+ if (SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle) {
790
+ return
791
+ }
792
+
707
793
  val hierarchy = ViewHierarchyScanner.shared?.captureHierarchy() ?: return
708
-
794
+
709
795
  val hash = hierarchyHash(hierarchy)
710
796
  if (hash == lastHierarchyHash) return
711
797
  lastHierarchyHash = hash
712
-
798
+
713
799
  val json = JSONObject(hierarchy).toString().toByteArray(Charsets.UTF_8)
714
800
  val ts = System.currentTimeMillis()
715
-
801
+
716
802
  SegmentDispatcher.shared.transmitHierarchy(sid, json, ts, null)
717
803
  }
718
-
804
+
719
805
  private fun hierarchyHash(h: Map<String, Any>): String {
720
806
  val screen = currentScreenName ?: "unknown"
721
807
  var childCount = 0
@@ -729,12 +815,12 @@ class ReplayOrchestrator private constructor(private val context: Context) {
729
815
  }
730
816
 
731
817
  private fun computeRender(fps: Int, tier: String): Pair<Double, Double> {
732
- val interval = 1.0 / fps.coerceIn(1, 99)
733
- val quality = when (tier.lowercase()) {
734
- "low" -> 0.4
735
- "standard" -> 0.5
736
- "high" -> 0.6
737
- else -> 0.5
738
- }
739
- return Pair(interval, quality)
818
+ val tierLower = tier.lowercase()
819
+ return when (tierLower) {
820
+ "minimal" -> Pair(2.0, 0.4) // 0.5 fps for maximum size reduction
821
+ "low" -> Pair(1.0 / fps.coerceIn(1, 99), 0.4)
822
+ "standard" -> Pair(1.0 / fps.coerceIn(1, 99), 0.5)
823
+ "high" -> Pair(1.0 / fps.coerceIn(1, 99), 0.55)
824
+ else -> Pair(1.0 / fps.coerceIn(1, 99), 0.5)
825
+ }
740
826
  }