@rejourneyco/react-native 1.0.8 → 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 (42) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +89 -8
  2. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  3. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  4. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  5. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  6. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  7. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +222 -145
  8. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +4 -0
  9. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  10. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +13 -0
  11. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  12. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  13. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  14. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  15. package/ios/Engine/DeviceRegistrar.swift +13 -3
  16. package/ios/Engine/RejourneyImpl.swift +199 -115
  17. package/ios/Recording/AnrSentinel.swift +58 -25
  18. package/ios/Recording/InteractionRecorder.swift +1 -0
  19. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  20. package/ios/Recording/ReplayOrchestrator.swift +204 -143
  21. package/ios/Recording/SegmentDispatcher.swift +8 -0
  22. package/ios/Recording/StabilityMonitor.swift +40 -32
  23. package/ios/Recording/TelemetryPipeline.swift +17 -0
  24. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  25. package/ios/Recording/VisualCapture.swift +54 -8
  26. package/ios/Rejourney.mm +27 -8
  27. package/ios/Utility/ImageBlur.swift +0 -1
  28. package/lib/commonjs/index.js +28 -15
  29. package/lib/commonjs/sdk/autoTracking.js +162 -11
  30. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  31. package/lib/module/index.js +28 -15
  32. package/lib/module/sdk/autoTracking.js +162 -11
  33. package/lib/module/sdk/networkInterceptor.js +84 -4
  34. package/lib/typescript/NativeRejourney.d.ts +5 -2
  35. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  36. package/lib/typescript/types/index.d.ts +14 -2
  37. package/package.json +4 -4
  38. package/src/NativeRejourney.ts +8 -5
  39. package/src/index.ts +37 -19
  40. package/src/sdk/autoTracking.ts +176 -11
  41. package/src/sdk/networkInterceptor.ts +110 -1
  42. package/src/types/index.ts +15 -3
@@ -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,7 +86,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
86
86
  SegmentDispatcher.shared.endpoint = value
87
87
  DeviceRegistrar.shared?.endpoint = value
88
88
  }
89
-
89
+
90
90
  var snapshotInterval: Double = 1.0
91
91
  var compressionLevel: Double = 0.5
92
92
  var visualCaptureEnabled: 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,26 +162,26 @@ 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
173
  DiagnosticLog.trace("[ReplayOrchestrator] beginReplay v2")
173
174
  val perf = PerformanceSnapshot.capture()
174
175
  DiagnosticLog.debugSessionCreate("ORCHESTRATOR_INIT", "beginReplay", perf)
175
176
  DiagnosticLog.trace("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
176
-
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
183
  DiagnosticLog.trace("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
183
-
184
+
184
185
  DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
185
186
  DiagnosticLog.trace("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
186
187
  if (!ok) {
@@ -188,24 +189,24 @@ class ReplayOrchestrator private constructor(private val context: Context) {
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
+
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
+
220
221
  val renderCfg = computeRender(1, "standard")
221
-
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,
@@ -268,40 +277,49 @@ class ReplayOrchestrator private constructor(private val context: Context) {
268
277
  "screenCount" to visitedScreens.toSet().size
269
278
  )
270
279
  val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
271
-
272
- SegmentDispatcher.shared.evaluateReplayRetention(sid, metrics) { retain, reason ->
273
- // UI operations MUST run on main thread
274
- mainHandler.post {
275
- TelemetryPipeline.shared?.shutdown()
276
- VisualCapture.shared?.halt()
277
- InteractionRecorder.shared?.deactivate()
278
- StabilityMonitor.shared?.deactivate()
279
- AnrSentinel.shared?.deactivate()
280
- }
281
-
282
- SegmentDispatcher.shared.shipPending()
283
-
284
- if (finalized) {
285
- clearRecovery()
286
- completion?.invoke(true, true)
287
- return@evaluateReplayRetention
288
- }
289
- finalized = true
290
-
291
- SegmentDispatcher.shared.concludeReplay(sid, termMs, bgTimeMs, metrics, queueDepthAtFinalize) { ok ->
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 ->
292
310
  if (ok) clearRecovery()
293
311
  completion?.invoke(true, ok)
294
312
  }
295
313
  }
296
-
314
+
297
315
  replayId = null
298
316
  replayStartMs = 0
299
317
  }
300
-
318
+
301
319
  fun redactView(view: View) {
302
320
  VisualCapture.shared?.registerRedaction(view)
303
321
  }
304
-
322
+
305
323
  /**
306
324
  * Set remote configuration from backend
307
325
  * Called by JS side before startSession to apply server-side settings
@@ -316,89 +334,142 @@ class ReplayOrchestrator private constructor(private val context: Context) {
316
334
  this.remoteRecordingEnabled = recordingEnabled
317
335
  this.remoteSampleRate = sampleRate
318
336
  this.remoteMaxRecordingMinutes = maxRecordingMinutes
319
-
337
+
320
338
  // Set isSampledIn for server-side enforcement
321
339
  // recordingEnabled=false means either dashboard disabled OR session sampled out by JS
322
340
  TelemetryPipeline.shared?.isSampledIn = recordingEnabled
323
-
341
+
324
342
  // Apply recording settings immediately
325
343
  // If recording is disabled, disable visual capture
326
344
  if (!recordingEnabled) {
327
345
  visualCaptureEnabled = false
328
346
  DiagnosticLog.trace("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
329
347
  }
330
-
348
+
331
349
  // If already recording, restart the duration limit timer with updated config
332
350
  if (live) {
333
351
  startDurationLimitTimer()
334
352
  }
335
-
353
+
336
354
  DiagnosticLog.trace("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
337
355
  }
338
-
356
+
339
357
  fun unredactView(view: View) {
340
358
  VisualCapture.shared?.unregisterRedaction(view)
341
359
  }
342
-
360
+
343
361
  fun attachAttribute(key: String, value: String) {
344
362
  TelemetryPipeline.shared?.recordAttribute(key, value)
345
363
  }
346
-
364
+
347
365
  fun recordCustomEvent(name: String, payload: String?) {
348
366
  TelemetryPipeline.shared?.recordCustomEvent(name, payload ?: "")
349
367
  }
350
-
368
+
351
369
  fun associateUser(userId: String) {
352
370
  TelemetryPipeline.shared?.recordUserAssociation(userId)
353
371
  }
354
-
372
+
355
373
  fun currentReplayId(): String {
356
374
  return replayId ?: ""
357
375
  }
358
-
376
+
359
377
  fun activateGestureRecording() {
360
378
  // Gesture recording activation - handled by InteractionRecorder
361
379
  }
362
-
380
+
363
381
  fun recoverInterruptedReplay(completion: (String?) -> Unit) {
364
382
  val recoveryFile = File(context.filesDir, "rejourney_recovery.json")
365
-
383
+
366
384
  if (!recoveryFile.exists()) {
367
385
  completion(null)
368
386
  return
369
387
  }
370
-
388
+
371
389
  try {
372
390
  val data = recoveryFile.readText()
373
391
  val checkpoint = JSONObject(data)
374
392
  val recId = checkpoint.optString("replayId", null)
375
-
393
+
376
394
  if (recId == null) {
395
+ clearRecovery()
377
396
  completion(null)
378
397
  return
379
398
  }
380
-
399
+
381
400
  val origStart = checkpoint.optLong("startMs", 0)
382
401
  val nowMs = System.currentTimeMillis()
383
-
402
+
403
+ DiagnosticLog.notice("[ReplayOrchestrator] Recovering interrupted session: $recId")
404
+
384
405
  checkpoint.optString("apiToken", null)?.let { SegmentDispatcher.shared.apiToken = it }
385
406
  checkpoint.optString("endpoint", null)?.let { SegmentDispatcher.shared.endpoint = it }
386
-
387
- val crashMetrics = mapOf(
388
- "crashCount" to 1,
389
- "durationSeconds" to ((nowMs - origStart) / 1000).toInt()
390
- )
391
- val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
392
-
393
- SegmentDispatcher.shared.concludeReplay(recId, nowMs, 0, crashMetrics, queueDepthAtFinalize) { ok ->
394
- clearRecovery()
395
- 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
+ }
396
449
  }
397
450
  } catch (e: Exception) {
451
+ DiagnosticLog.fault("[ReplayOrchestrator] Crash recovery failed: ${e.message}")
398
452
  completion(null)
399
453
  }
400
454
  }
401
-
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
+
402
473
  // Tally methods
403
474
  fun incrementFaultTally() { crashCount++ }
404
475
  fun incrementStalledTally() { freezeCount++ }
@@ -408,21 +479,21 @@ class ReplayOrchestrator private constructor(private val context: Context) {
408
479
  fun incrementGestureTally() { gestureCount++ }
409
480
  fun incrementRageTapTally() { rageCount++ }
410
481
  fun incrementDeadTapTally() { deadTapCount++ }
411
-
482
+
412
483
  fun logScreenView(screenId: String) {
413
484
  if (screenId.isEmpty()) return
414
485
  visitedScreens.add(screenId)
415
486
  currentScreenName = screenId
416
487
  if (hierarchyCaptureEnabled) captureHierarchy()
417
488
  }
418
-
489
+
419
490
  private fun initSession() {
420
491
  replayStartMs = System.currentTimeMillis()
421
492
  // Always generate a fresh session ID - never reuse stale IDs
422
493
  val uuidPart = UUID.randomUUID().toString().replace("-", "").lowercase()
423
494
  replayId = "session_${replayStartMs}_$uuidPart"
424
495
  finalized = false
425
-
496
+
426
497
  crashCount = 0
427
498
  freezeCount = 0
428
499
  errorCount = 0
@@ -434,27 +505,27 @@ class ReplayOrchestrator private constructor(private val context: Context) {
434
505
  visitedScreens.clear()
435
506
  bgTimeMs = 0
436
507
  bgStartMs = null
437
-
508
+
438
509
  TelemetryPipeline.shared?.currentReplayId = replayId
439
510
  SegmentDispatcher.shared.currentReplayId = replayId
440
511
  StabilityMonitor.shared?.currentSessionId = replayId
441
-
512
+
442
513
  attachLifecycle()
443
514
  saveRecovery()
444
-
515
+
445
516
  recordAppStartup()
446
517
  }
447
-
518
+
448
519
  private fun recordAppStartup() {
449
520
  val nowMs = System.currentTimeMillis()
450
521
  val startupDurationMs = nowMs - processStartTime
451
-
522
+
452
523
  // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
453
524
  if (startupDurationMs > 0 && startupDurationMs < 60000) {
454
525
  TelemetryPipeline.shared?.recordAppStartup(startupDurationMs)
455
526
  }
456
527
  }
457
-
528
+
458
529
  private fun applySettings(cfg: Map<String, Any>?) {
459
530
  if (cfg == null) return
460
531
  snapshotInterval = (cfg["captureRate"] as? Double) ?: 0.33
@@ -467,7 +538,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
467
538
  wifiRequired = (cfg["wifiOnly"] as? Boolean) ?: false
468
539
  frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?: 5
469
540
  }
470
-
541
+
471
542
  private fun monitorNetwork(token: String) {
472
543
  DiagnosticLog.trace("[ReplayOrchestrator] monitorNetwork called")
473
544
  val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
@@ -476,25 +547,25 @@ class ReplayOrchestrator private constructor(private val context: Context) {
476
547
  beginRecording(token)
477
548
  return
478
549
  }
479
-
550
+
480
551
  networkCallback = object : ConnectivityManager.NetworkCallback() {
481
552
  override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
482
553
  handleNetworkChange(capabilities, token)
483
554
  }
484
-
555
+
485
556
  override fun onLost(network: Network) {
486
557
  currentNetworkType = "none"
487
558
  netReady = false
488
559
  }
489
560
  }
490
-
561
+
491
562
  val request = NetworkRequest.Builder()
492
563
  .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
493
564
  .build()
494
-
565
+
495
566
  try {
496
567
  connectivityManager.registerNetworkCallback(request, networkCallback!!)
497
-
568
+
498
569
  // Check current network state immediately (callback only fires on CHANGES)
499
570
  val activeNetwork = connectivityManager.activeNetwork
500
571
  val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
@@ -511,31 +582,31 @@ class ReplayOrchestrator private constructor(private val context: Context) {
511
582
  beginRecording(token)
512
583
  }
513
584
  }
514
-
585
+
515
586
  private fun handleNetworkChange(capabilities: NetworkCapabilities, token: String) {
516
587
  val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
517
588
  val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
518
589
  val isEthernet = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
519
-
590
+
520
591
  networkIsExpensive = !isWifi && !isEthernet
521
592
  networkIsConstrained = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
522
593
  !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
523
594
  } else {
524
595
  networkIsExpensive
525
596
  }
526
-
597
+
527
598
  currentNetworkType = when {
528
599
  isWifi -> "wifi"
529
600
  isCellular -> "cellular"
530
601
  isEthernet -> "wired"
531
602
  else -> "other"
532
603
  }
533
-
604
+
534
605
  val canProceed = when {
535
606
  wifiRequired && !isWifi -> false
536
607
  else -> true
537
608
  }
538
-
609
+
539
610
  mainHandler.post {
540
611
  netReady = canProceed
541
612
  if (canProceed && !live) {
@@ -543,7 +614,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
543
614
  }
544
615
  }
545
616
  }
546
-
617
+
547
618
  private fun beginRecording(token: String) {
548
619
  DiagnosticLog.trace("[ReplayOrchestrator] beginRecording called, live=$live")
549
620
  if (live) {
@@ -551,19 +622,19 @@ class ReplayOrchestrator private constructor(private val context: Context) {
551
622
  return
552
623
  }
553
624
  live = true
554
-
625
+
555
626
  this.apiToken = token
556
627
  initSession()
557
628
  DiagnosticLog.trace("[ReplayOrchestrator] Session initialized: replayId=$replayId")
558
-
629
+
559
630
  // Reactivate the dispatcher in case it was halted from a previous session
560
631
  SegmentDispatcher.shared.activate()
561
632
  TelemetryPipeline.shared?.activate()
562
-
633
+
563
634
  val renderCfg = computeRender(1, "standard")
564
635
  DiagnosticLog.trace("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
565
636
  VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
566
-
637
+
567
638
  if (visualCaptureEnabled) {
568
639
  DiagnosticLog.trace("[ReplayOrchestrator] Starting VisualCapture")
569
640
  VisualCapture.shared?.beginCapture(replayStartMs)
@@ -572,79 +643,80 @@ class ReplayOrchestrator private constructor(private val context: Context) {
572
643
  if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
573
644
  if (responsivenessCaptureEnabled) AnrSentinel.shared?.activate()
574
645
  if (hierarchyCaptureEnabled) startHierarchyCapture()
575
-
646
+
576
647
  // Start duration limit timer based on remote config
577
648
  startDurationLimitTimer()
578
-
649
+
579
650
  DiagnosticLog.trace("[ReplayOrchestrator] beginRecording completed")
580
651
  }
581
-
652
+
582
653
  // MARK: - Duration Limit Timer
583
-
654
+
584
655
  private fun startDurationLimitTimer() {
585
656
  stopDurationLimitTimer()
586
-
657
+
587
658
  val maxMinutes = remoteMaxRecordingMinutes
588
659
  if (maxMinutes <= 0) return
589
-
660
+
590
661
  val maxMs = maxMinutes.toLong() * 60 * 1000
591
662
  val now = System.currentTimeMillis()
592
663
  val elapsed = now - replayStartMs
593
664
  val remaining = if (maxMs > elapsed) maxMs - elapsed else 0L
594
-
665
+
595
666
  if (remaining <= 0) {
596
667
  DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
597
- endReplay()
668
+ endReplayWithReason("duration_limit")
598
669
  return
599
670
  }
600
-
671
+
601
672
  durationLimitRunnable = Runnable {
602
673
  if (!live) return@Runnable
603
674
  DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
604
- endReplay()
675
+ endReplayWithReason("duration_limit")
605
676
  }
606
677
  mainHandler.postDelayed(durationLimitRunnable!!, remaining)
607
-
678
+
608
679
  DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
609
680
  }
610
-
681
+
611
682
  private fun stopDurationLimitTimer() {
612
683
  durationLimitRunnable?.let { mainHandler.removeCallbacks(it) }
613
684
  durationLimitRunnable = null
614
685
  }
615
-
686
+
616
687
  private fun saveRecovery() {
617
688
  val sid = replayId ?: return
618
689
  val token = apiToken ?: return
619
-
690
+
620
691
  val checkpoint = JSONObject().apply {
621
692
  put("replayId", sid)
622
693
  put("apiToken", token)
623
694
  put("startMs", replayStartMs)
624
695
  put("endpoint", serverEndpoint)
696
+ SegmentDispatcher.shared.credential?.let { put("credential", it) }
625
697
  }
626
-
698
+
627
699
  try {
628
700
  File(context.filesDir, "rejourney_recovery.json").writeText(checkpoint.toString())
629
701
  } catch (_: Exception) { }
630
702
  }
631
-
703
+
632
704
  private fun clearRecovery() {
633
705
  try {
634
706
  File(context.filesDir, "rejourney_recovery.json").delete()
635
707
  } catch (_: Exception) { }
636
708
  }
637
-
709
+
638
710
  private fun attachLifecycle() {
639
711
  val app = context as? Application ?: return
640
712
  app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
641
713
  }
642
-
714
+
643
715
  private fun detachLifecycle() {
644
716
  val app = context as? Application ?: return
645
717
  app.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
646
718
  }
647
-
719
+
648
720
  private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
649
721
  override fun onActivityResumed(activity: Activity) {
650
722
  bgStartMs?.let { start ->
@@ -652,19 +724,24 @@ class ReplayOrchestrator private constructor(private val context: Context) {
652
724
  bgTimeMs += (now - start)
653
725
  }
654
726
  bgStartMs = null
727
+
728
+ if (responsivenessCaptureEnabled) {
729
+ AnrSentinel.shared.activate()
730
+ }
655
731
  }
656
-
732
+
657
733
  override fun onActivityPaused(activity: Activity) {
658
734
  bgStartMs = System.currentTimeMillis()
735
+ AnrSentinel.shared.deactivate()
659
736
  }
660
-
737
+
661
738
  override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
662
739
  override fun onActivityStarted(activity: Activity) {}
663
740
  override fun onActivityStopped(activity: Activity) {}
664
741
  override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
665
742
  override fun onActivityDestroyed(activity: Activity) {}
666
743
  }
667
-
744
+
668
745
  private fun unregisterNetworkCallback() {
669
746
  networkCallback?.let { callback ->
670
747
  try {
@@ -674,10 +751,10 @@ class ReplayOrchestrator private constructor(private val context: Context) {
674
751
  }
675
752
  networkCallback = null
676
753
  }
677
-
754
+
678
755
  private fun startHierarchyCapture() {
679
756
  stopHierarchyCapture()
680
-
757
+
681
758
  hierarchyHandler = Handler(Looper.getMainLooper())
682
759
  hierarchyRunnable = object : Runnable {
683
760
  override fun run() {
@@ -686,45 +763,45 @@ class ReplayOrchestrator private constructor(private val context: Context) {
686
763
  }
687
764
  }
688
765
  hierarchyHandler?.postDelayed(hierarchyRunnable!!, (hierarchyCaptureInterval * 1000).toLong())
689
-
766
+
690
767
  // Initial capture after 500ms
691
768
  hierarchyHandler?.postDelayed({ captureHierarchy() }, 500)
692
769
  }
693
-
770
+
694
771
  private fun stopHierarchyCapture() {
695
772
  hierarchyRunnable?.let { hierarchyHandler?.removeCallbacks(it) }
696
773
  hierarchyHandler = null
697
774
  hierarchyRunnable = null
698
775
  }
699
-
776
+
700
777
  private fun captureHierarchy() {
701
778
  if (!live) return
702
779
  val sid = replayId ?: return
703
-
780
+
704
781
  if (Looper.myLooper() != Looper.getMainLooper()) {
705
782
  mainHandler.post { captureHierarchy() }
706
783
  return
707
784
  }
708
-
785
+
709
786
  // Throttle hierarchy capture when map is visible and animating —
710
787
  // ViewHierarchyScanner traverses the full view tree including map's
711
788
  // deep SurfaceView/TextureView children, adding main-thread pressure.
712
789
  if (SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle) {
713
790
  return
714
791
  }
715
-
792
+
716
793
  val hierarchy = ViewHierarchyScanner.shared?.captureHierarchy() ?: return
717
-
794
+
718
795
  val hash = hierarchyHash(hierarchy)
719
796
  if (hash == lastHierarchyHash) return
720
797
  lastHierarchyHash = hash
721
-
798
+
722
799
  val json = JSONObject(hierarchy).toString().toByteArray(Charsets.UTF_8)
723
800
  val ts = System.currentTimeMillis()
724
-
801
+
725
802
  SegmentDispatcher.shared.transmitHierarchy(sid, json, ts, null)
726
803
  }
727
-
804
+
728
805
  private fun hierarchyHash(h: Map<String, Any>): String {
729
806
  val screen = currentScreenName ?: "unknown"
730
807
  var childCount = 0