@rejourneyco/react-native 1.0.8 → 1.0.10

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