@rejourneyco/react-native 1.0.7

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 (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ package com.rejourney.recording
18
+
19
+ import android.app.Activity
20
+ import android.app.Application
21
+ import android.content.Context
22
+ import android.net.ConnectivityManager
23
+ import android.net.Network
24
+ import android.net.NetworkCapabilities
25
+ import android.net.NetworkRequest
26
+ import android.os.Build
27
+ import android.os.Bundle
28
+ import android.os.Handler
29
+ import android.os.Looper
30
+ import android.view.View
31
+ import com.rejourney.engine.DeviceRegistrar
32
+ import com.rejourney.engine.DiagnosticLog
33
+ import com.rejourney.engine.PerformanceSnapshot
34
+ import org.json.JSONObject
35
+ import java.io.File
36
+ import java.util.*
37
+
38
+ /**
39
+ * Session orchestration and lifecycle management
40
+ * Android implementation aligned with iOS ReplayOrchestrator.swift
41
+ */
42
+ class ReplayOrchestrator private constructor(private val context: Context) {
43
+
44
+ companion object {
45
+ @Volatile
46
+ private var instance: ReplayOrchestrator? = null
47
+
48
+ fun getInstance(context: Context): ReplayOrchestrator {
49
+ return instance ?: synchronized(this) {
50
+ instance ?: ReplayOrchestrator(context.applicationContext).also { instance = it }
51
+ }
52
+ }
53
+
54
+ val shared: ReplayOrchestrator?
55
+ get() = instance
56
+
57
+ // Process start time for app startup tracking
58
+ private val processStartTime: Long by lazy {
59
+ try {
60
+ // Read process start time from /proc/self/stat
61
+ val stat = File("/proc/self/stat").readText()
62
+ val parts = stat.split(" ")
63
+ if (parts.size > 21) {
64
+ val startTimeTicks = parts[21].toLongOrNull() ?: 0
65
+ val ticksPerSecond = 100L // Standard on most Linux systems
66
+ System.currentTimeMillis() - (android.os.SystemClock.elapsedRealtime() - (startTimeTicks * 1000 / ticksPerSecond))
67
+ } else {
68
+ System.currentTimeMillis()
69
+ }
70
+ } catch (e: Exception) {
71
+ System.currentTimeMillis()
72
+ }
73
+ }
74
+ }
75
+
76
+ var apiToken: String? = null
77
+ var replayId: String? = null
78
+ var replayStartMs: Long = 0
79
+ var deferredUploadMode = false
80
+ var frameBundleSize: Int = 5
81
+
82
+ var serverEndpoint: String
83
+ get() = TelemetryPipeline.shared?.endpoint ?: "https://api.rejourney.co"
84
+ set(value) {
85
+ TelemetryPipeline.shared?.endpoint = value
86
+ SegmentDispatcher.shared.endpoint = value
87
+ DeviceRegistrar.shared?.endpoint = value
88
+ }
89
+
90
+ var snapshotInterval: Double = 0.33
91
+ var compressionLevel: Double = 0.5
92
+ var visualCaptureEnabled: Boolean = true
93
+ var interactionCaptureEnabled: Boolean = true
94
+ var faultTrackingEnabled: Boolean = true
95
+ var responsivenessCaptureEnabled: Boolean = true
96
+ var consoleCaptureEnabled: Boolean = true
97
+ var wifiRequired: Boolean = false
98
+ var hierarchyCaptureEnabled: Boolean = true
99
+ var hierarchyCaptureInterval: Double = 2.0
100
+ var currentScreenName: String? = null
101
+ private set
102
+
103
+ // Remote config from backend (set via setRemoteConfig before session start)
104
+ var remoteRejourneyEnabled: Boolean = true
105
+ private set
106
+ var remoteRecordingEnabled: Boolean = true
107
+ private set
108
+ var remoteSampleRate: Int = 100
109
+ private set
110
+ var remoteMaxRecordingMinutes: Int = 10
111
+ private set
112
+
113
+ // Network state tracking
114
+ var currentNetworkType: String = "unknown"
115
+ private set
116
+ var currentCellularGeneration: String = "unknown"
117
+ private set
118
+ var networkIsConstrained: Boolean = false
119
+ private set
120
+ var networkIsExpensive: Boolean = false
121
+ private set
122
+
123
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
124
+ private var netReady = false
125
+ private var live = false
126
+
127
+ private var crashCount = 0
128
+ private var freezeCount = 0
129
+ private var errorCount = 0
130
+ private var tapCount = 0
131
+ private var scrollCount = 0
132
+ private var gestureCount = 0
133
+ private var rageCount = 0
134
+ private var deadTapCount = 0
135
+ private val visitedScreens = mutableListOf<String>()
136
+ private var bgTimeMs: Long = 0
137
+ private var bgStartMs: Long? = null
138
+ private var finalized = false
139
+ private var hierarchyHandler: Handler? = null
140
+ private var hierarchyRunnable: Runnable? = null
141
+ private var lastHierarchyHash: String? = null
142
+ private var durationLimitRunnable: Runnable? = null
143
+
144
+ private val mainHandler = Handler(Looper.getMainLooper())
145
+
146
+ /**
147
+ * Fast session start using existing credentials - skips credential fetch for faster restart
148
+ */
149
+ fun beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: Map<String, Any>? = null) {
150
+ val perf = PerformanceSnapshot.capture()
151
+ DiagnosticLog.debugSessionCreate("ORCHESTRATOR_FAST_INIT", "beginReplayFast with existing credential", perf)
152
+
153
+ this.apiToken = apiToken
154
+ this.serverEndpoint = serverEndpoint
155
+ applySettings(captureSettings)
156
+
157
+ // Set credentials AND endpoint directly without network fetch
158
+ TelemetryPipeline.shared?.apiToken = apiToken
159
+ TelemetryPipeline.shared?.credential = credential
160
+ TelemetryPipeline.shared?.endpoint = serverEndpoint
161
+ SegmentDispatcher.shared.apiToken = apiToken
162
+ SegmentDispatcher.shared.credential = credential
163
+ SegmentDispatcher.shared.endpoint = serverEndpoint
164
+
165
+ // Skip network monitoring, assume network is available since we just came from background
166
+ mainHandler.post {
167
+ beginRecording(apiToken)
168
+ }
169
+ }
170
+
171
+ fun beginReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
172
+ DiagnosticLog.notice("[ReplayOrchestrator] ★★★ beginReplay v2 ★★★")
173
+ val perf = PerformanceSnapshot.capture()
174
+ DiagnosticLog.debugSessionCreate("ORCHESTRATOR_INIT", "beginReplay", perf)
175
+ DiagnosticLog.notice("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
176
+
177
+ this.apiToken = apiToken
178
+ this.serverEndpoint = serverEndpoint
179
+ applySettings(captureSettings)
180
+
181
+ DiagnosticLog.debugSessionCreate("CREDENTIAL_START", "Requesting device credential")
182
+ DiagnosticLog.notice("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
183
+
184
+ DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
185
+ DiagnosticLog.notice("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
186
+ if (!ok) {
187
+ DiagnosticLog.debugSessionCreate("CREDENTIAL_FAIL", "Failed")
188
+ DiagnosticLog.caution("[ReplayOrchestrator] Credential fetch FAILED - recording cannot start")
189
+ return@obtainCredential
190
+ }
191
+
192
+ TelemetryPipeline.shared?.apiToken = apiToken
193
+ TelemetryPipeline.shared?.credential = cred
194
+ SegmentDispatcher.shared.apiToken = apiToken
195
+ SegmentDispatcher.shared.credential = cred
196
+
197
+ DiagnosticLog.notice("[ReplayOrchestrator] Credential OK, calling monitorNetwork")
198
+ monitorNetwork(apiToken)
199
+ }
200
+ }
201
+
202
+ fun beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
203
+ this.apiToken = apiToken
204
+ this.serverEndpoint = serverEndpoint
205
+ deferredUploadMode = true
206
+
207
+ applySettings(captureSettings)
208
+
209
+ DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
210
+ if (!ok) return@obtainCredential
211
+ TelemetryPipeline.shared?.apiToken = apiToken
212
+ TelemetryPipeline.shared?.credential = cred
213
+ SegmentDispatcher.shared.apiToken = apiToken
214
+ SegmentDispatcher.shared.credential = cred
215
+ }
216
+
217
+ initSession()
218
+ TelemetryPipeline.shared?.activateDeferredMode()
219
+
220
+ val renderCfg = computeRender(3, "standard")
221
+
222
+ if (visualCaptureEnabled) {
223
+ VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
224
+ VisualCapture.shared?.beginCapture(replayStartMs)
225
+ VisualCapture.shared?.activateDeferredMode()
226
+ }
227
+
228
+ if (interactionCaptureEnabled) InteractionRecorder.shared?.activate()
229
+ if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
230
+
231
+ live = true
232
+ }
233
+
234
+ fun commitDeferredReplay() {
235
+ deferredUploadMode = false
236
+ TelemetryPipeline.shared?.commitDeferredData()
237
+ VisualCapture.shared?.commitDeferredData()
238
+ TelemetryPipeline.shared?.activate()
239
+ }
240
+
241
+ fun endReplay(completion: ((Boolean, Boolean) -> Unit)? = null) {
242
+ if (!live) {
243
+ completion?.invoke(false, false)
244
+ return
245
+ }
246
+ live = false
247
+
248
+ val sid = replayId ?: ""
249
+ val termMs = System.currentTimeMillis()
250
+ val elapsed = ((termMs - replayStartMs) / 1000).toInt()
251
+
252
+ unregisterNetworkCallback()
253
+ stopHierarchyCapture()
254
+ stopDurationLimitTimer()
255
+ detachLifecycle()
256
+
257
+ val metrics = mapOf(
258
+ "crashCount" to crashCount,
259
+ "anrCount" to freezeCount,
260
+ "errorCount" to errorCount,
261
+ "durationSeconds" to elapsed,
262
+ "touchCount" to tapCount,
263
+ "scrollCount" to scrollCount,
264
+ "gestureCount" to gestureCount,
265
+ "rageTapCount" to rageCount,
266
+ "deadTapCount" to deadTapCount,
267
+ "screensVisited" to visitedScreens.toList(),
268
+ "screenCount" to visitedScreens.toSet().size
269
+ )
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 ->
291
+ if (ok) clearRecovery()
292
+ completion?.invoke(true, ok)
293
+ }
294
+ }
295
+
296
+ replayId = null
297
+ replayStartMs = 0
298
+ }
299
+
300
+ fun redactView(view: View) {
301
+ VisualCapture.shared?.registerRedaction(view)
302
+ }
303
+
304
+ /**
305
+ * Set remote configuration from backend
306
+ * Called by JS side before startSession to apply server-side settings
307
+ */
308
+ fun setRemoteConfig(
309
+ rejourneyEnabled: Boolean,
310
+ recordingEnabled: Boolean,
311
+ sampleRate: Int,
312
+ maxRecordingMinutes: Int
313
+ ) {
314
+ this.remoteRejourneyEnabled = rejourneyEnabled
315
+ this.remoteRecordingEnabled = recordingEnabled
316
+ this.remoteSampleRate = sampleRate
317
+ this.remoteMaxRecordingMinutes = maxRecordingMinutes
318
+
319
+ // Set isSampledIn for server-side enforcement
320
+ // recordingEnabled=false means either dashboard disabled OR session sampled out by JS
321
+ TelemetryPipeline.shared?.isSampledIn = recordingEnabled
322
+
323
+ // Apply recording settings immediately
324
+ // If recording is disabled, disable visual capture
325
+ if (!recordingEnabled) {
326
+ visualCaptureEnabled = false
327
+ DiagnosticLog.notice("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
328
+ }
329
+
330
+ // If already recording, restart the duration limit timer with updated config
331
+ if (live) {
332
+ startDurationLimitTimer()
333
+ }
334
+
335
+ DiagnosticLog.notice("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
336
+ }
337
+
338
+ fun unredactView(view: View) {
339
+ VisualCapture.shared?.unregisterRedaction(view)
340
+ }
341
+
342
+ fun attachAttribute(key: String, value: String) {
343
+ TelemetryPipeline.shared?.recordAttribute(key, value)
344
+ }
345
+
346
+ fun recordCustomEvent(name: String, payload: String?) {
347
+ TelemetryPipeline.shared?.recordCustomEvent(name, payload ?: "")
348
+ }
349
+
350
+ fun associateUser(userId: String) {
351
+ TelemetryPipeline.shared?.recordUserAssociation(userId)
352
+ }
353
+
354
+ fun currentReplayId(): String {
355
+ return replayId ?: ""
356
+ }
357
+
358
+ fun activateGestureRecording() {
359
+ // Gesture recording activation - handled by InteractionRecorder
360
+ }
361
+
362
+ fun recoverInterruptedReplay(completion: (String?) -> Unit) {
363
+ val recoveryFile = File(context.filesDir, "rejourney_recovery.json")
364
+
365
+ if (!recoveryFile.exists()) {
366
+ completion(null)
367
+ return
368
+ }
369
+
370
+ try {
371
+ val data = recoveryFile.readText()
372
+ val checkpoint = JSONObject(data)
373
+ val recId = checkpoint.optString("replayId", null)
374
+
375
+ if (recId == null) {
376
+ completion(null)
377
+ return
378
+ }
379
+
380
+ val origStart = checkpoint.optLong("startMs", 0)
381
+ val nowMs = System.currentTimeMillis()
382
+
383
+ checkpoint.optString("apiToken", null)?.let { SegmentDispatcher.shared.apiToken = it }
384
+ 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)
394
+ }
395
+ } catch (e: Exception) {
396
+ completion(null)
397
+ }
398
+ }
399
+
400
+ // Tally methods
401
+ fun incrementFaultTally() { crashCount++ }
402
+ fun incrementStalledTally() { freezeCount++ }
403
+ fun incrementExceptionTally() { errorCount++ }
404
+ fun incrementTapTally() { tapCount++ }
405
+ fun logScrollAction() { scrollCount++ }
406
+ fun incrementGestureTally() { gestureCount++ }
407
+ fun incrementRageTapTally() { rageCount++ }
408
+ fun incrementDeadTapTally() { deadTapCount++ }
409
+
410
+ fun logScreenView(screenId: String) {
411
+ if (screenId.isEmpty()) return
412
+ visitedScreens.add(screenId)
413
+ currentScreenName = screenId
414
+ if (hierarchyCaptureEnabled) captureHierarchy()
415
+ }
416
+
417
+ private fun initSession() {
418
+ replayStartMs = System.currentTimeMillis()
419
+ // Always generate a fresh session ID - never reuse stale IDs
420
+ val uuidPart = UUID.randomUUID().toString().replace("-", "").lowercase()
421
+ replayId = "session_${replayStartMs}_$uuidPart"
422
+ finalized = false
423
+
424
+ crashCount = 0
425
+ freezeCount = 0
426
+ errorCount = 0
427
+ tapCount = 0
428
+ scrollCount = 0
429
+ gestureCount = 0
430
+ rageCount = 0
431
+ deadTapCount = 0
432
+ visitedScreens.clear()
433
+ bgTimeMs = 0
434
+ bgStartMs = null
435
+
436
+ TelemetryPipeline.shared?.currentReplayId = replayId
437
+ SegmentDispatcher.shared.currentReplayId = replayId
438
+ StabilityMonitor.shared?.currentSessionId = replayId
439
+
440
+ attachLifecycle()
441
+ saveRecovery()
442
+
443
+ recordAppStartup()
444
+ }
445
+
446
+ private fun recordAppStartup() {
447
+ val nowMs = System.currentTimeMillis()
448
+ val startupDurationMs = nowMs - processStartTime
449
+
450
+ // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
451
+ if (startupDurationMs > 0 && startupDurationMs < 60000) {
452
+ TelemetryPipeline.shared?.recordAppStartup(startupDurationMs)
453
+ }
454
+ }
455
+
456
+ private fun applySettings(cfg: Map<String, Any>?) {
457
+ if (cfg == null) return
458
+ snapshotInterval = (cfg["captureRate"] as? Double) ?: 0.33
459
+ compressionLevel = (cfg["imgCompression"] as? Double) ?: 0.5
460
+ visualCaptureEnabled = (cfg["captureScreen"] as? Boolean) ?: true
461
+ interactionCaptureEnabled = (cfg["captureAnalytics"] as? Boolean) ?: true
462
+ faultTrackingEnabled = (cfg["captureCrashes"] as? Boolean) ?: true
463
+ responsivenessCaptureEnabled = (cfg["captureANR"] as? Boolean) ?: true
464
+ consoleCaptureEnabled = (cfg["captureLogs"] as? Boolean) ?: true
465
+ wifiRequired = (cfg["wifiOnly"] as? Boolean) ?: false
466
+ frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?: 5
467
+ }
468
+
469
+ private fun monitorNetwork(token: String) {
470
+ DiagnosticLog.notice("[ReplayOrchestrator] monitorNetwork called")
471
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
472
+ if (connectivityManager == null) {
473
+ DiagnosticLog.notice("[ReplayOrchestrator] No ConnectivityManager, starting recording directly")
474
+ beginRecording(token)
475
+ return
476
+ }
477
+
478
+ networkCallback = object : ConnectivityManager.NetworkCallback() {
479
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
480
+ handleNetworkChange(capabilities, token)
481
+ }
482
+
483
+ override fun onLost(network: Network) {
484
+ currentNetworkType = "none"
485
+ netReady = false
486
+ }
487
+ }
488
+
489
+ val request = NetworkRequest.Builder()
490
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
491
+ .build()
492
+
493
+ try {
494
+ connectivityManager.registerNetworkCallback(request, networkCallback!!)
495
+
496
+ // Check current network state immediately (callback only fires on CHANGES)
497
+ val activeNetwork = connectivityManager.activeNetwork
498
+ val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
499
+ DiagnosticLog.notice("[ReplayOrchestrator] Network check: activeNetwork=${activeNetwork != null}, capabilities=${capabilities != null}")
500
+ if (capabilities != null) {
501
+ handleNetworkChange(capabilities, token)
502
+ } else {
503
+ // No active network - start recording anyway, uploads will retry when network available
504
+ DiagnosticLog.notice("[ReplayOrchestrator] No active network, starting recording anyway")
505
+ mainHandler.post { beginRecording(token) }
506
+ }
507
+ } catch (e: Exception) {
508
+ // Fallback: start anyway
509
+ beginRecording(token)
510
+ }
511
+ }
512
+
513
+ private fun handleNetworkChange(capabilities: NetworkCapabilities, token: String) {
514
+ val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
515
+ val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
516
+ val isEthernet = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
517
+
518
+ networkIsExpensive = !isWifi && !isEthernet
519
+ networkIsConstrained = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
520
+ !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
521
+ } else {
522
+ networkIsExpensive
523
+ }
524
+
525
+ currentNetworkType = when {
526
+ isWifi -> "wifi"
527
+ isCellular -> "cellular"
528
+ isEthernet -> "wired"
529
+ else -> "other"
530
+ }
531
+
532
+ val canProceed = when {
533
+ wifiRequired && !isWifi -> false
534
+ else -> true
535
+ }
536
+
537
+ mainHandler.post {
538
+ netReady = canProceed
539
+ if (canProceed && !live) {
540
+ beginRecording(token)
541
+ }
542
+ }
543
+ }
544
+
545
+ private fun beginRecording(token: String) {
546
+ DiagnosticLog.notice("[ReplayOrchestrator] beginRecording called, live=$live")
547
+ if (live) {
548
+ DiagnosticLog.notice("[ReplayOrchestrator] Already live, skipping")
549
+ return
550
+ }
551
+ live = true
552
+
553
+ this.apiToken = token
554
+ initSession()
555
+ DiagnosticLog.notice("[ReplayOrchestrator] Session initialized: replayId=$replayId")
556
+
557
+ // Reactivate the dispatcher in case it was halted from a previous session
558
+ SegmentDispatcher.shared.activate()
559
+ TelemetryPipeline.shared?.activate()
560
+
561
+ val renderCfg = computeRender(3, "high")
562
+ DiagnosticLog.notice("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
563
+ VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
564
+
565
+ if (visualCaptureEnabled) {
566
+ DiagnosticLog.notice("[ReplayOrchestrator] Starting VisualCapture")
567
+ VisualCapture.shared?.beginCapture(replayStartMs)
568
+ }
569
+ if (interactionCaptureEnabled) InteractionRecorder.shared?.activate()
570
+ if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
571
+ if (responsivenessCaptureEnabled) AnrSentinel.shared?.activate()
572
+ if (hierarchyCaptureEnabled) startHierarchyCapture()
573
+
574
+ // Start duration limit timer based on remote config
575
+ startDurationLimitTimer()
576
+
577
+ DiagnosticLog.notice("[ReplayOrchestrator] beginRecording completed")
578
+ }
579
+
580
+ // MARK: - Duration Limit Timer
581
+
582
+ private fun startDurationLimitTimer() {
583
+ stopDurationLimitTimer()
584
+
585
+ val maxMinutes = remoteMaxRecordingMinutes
586
+ if (maxMinutes <= 0) return
587
+
588
+ val maxMs = maxMinutes.toLong() * 60 * 1000
589
+ val now = System.currentTimeMillis()
590
+ val elapsed = now - replayStartMs
591
+ val remaining = if (maxMs > elapsed) maxMs - elapsed else 0L
592
+
593
+ if (remaining <= 0) {
594
+ DiagnosticLog.notice("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
595
+ endReplay()
596
+ return
597
+ }
598
+
599
+ durationLimitRunnable = Runnable {
600
+ if (!live) return@Runnable
601
+ DiagnosticLog.notice("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
602
+ endReplay()
603
+ }
604
+ mainHandler.postDelayed(durationLimitRunnable!!, remaining)
605
+
606
+ DiagnosticLog.notice("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
607
+ }
608
+
609
+ private fun stopDurationLimitTimer() {
610
+ durationLimitRunnable?.let { mainHandler.removeCallbacks(it) }
611
+ durationLimitRunnable = null
612
+ }
613
+
614
+ private fun saveRecovery() {
615
+ val sid = replayId ?: return
616
+ val token = apiToken ?: return
617
+
618
+ val checkpoint = JSONObject().apply {
619
+ put("replayId", sid)
620
+ put("apiToken", token)
621
+ put("startMs", replayStartMs)
622
+ put("endpoint", serverEndpoint)
623
+ }
624
+
625
+ try {
626
+ File(context.filesDir, "rejourney_recovery.json").writeText(checkpoint.toString())
627
+ } catch (_: Exception) { }
628
+ }
629
+
630
+ private fun clearRecovery() {
631
+ try {
632
+ File(context.filesDir, "rejourney_recovery.json").delete()
633
+ } catch (_: Exception) { }
634
+ }
635
+
636
+ private fun attachLifecycle() {
637
+ val app = context as? Application ?: return
638
+ app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
639
+ }
640
+
641
+ private fun detachLifecycle() {
642
+ val app = context as? Application ?: return
643
+ app.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
644
+ }
645
+
646
+ private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
647
+ override fun onActivityResumed(activity: Activity) {
648
+ bgStartMs?.let { start ->
649
+ val now = System.currentTimeMillis()
650
+ bgTimeMs += (now - start)
651
+ }
652
+ bgStartMs = null
653
+ }
654
+
655
+ override fun onActivityPaused(activity: Activity) {
656
+ bgStartMs = System.currentTimeMillis()
657
+ }
658
+
659
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
660
+ override fun onActivityStarted(activity: Activity) {}
661
+ override fun onActivityStopped(activity: Activity) {}
662
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
663
+ override fun onActivityDestroyed(activity: Activity) {}
664
+ }
665
+
666
+ private fun unregisterNetworkCallback() {
667
+ networkCallback?.let { callback ->
668
+ try {
669
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
670
+ cm?.unregisterNetworkCallback(callback)
671
+ } catch (_: Exception) { }
672
+ }
673
+ networkCallback = null
674
+ }
675
+
676
+ private fun startHierarchyCapture() {
677
+ stopHierarchyCapture()
678
+
679
+ hierarchyHandler = Handler(Looper.getMainLooper())
680
+ hierarchyRunnable = object : Runnable {
681
+ override fun run() {
682
+ captureHierarchy()
683
+ hierarchyHandler?.postDelayed(this, (hierarchyCaptureInterval * 1000).toLong())
684
+ }
685
+ }
686
+ hierarchyHandler?.postDelayed(hierarchyRunnable!!, (hierarchyCaptureInterval * 1000).toLong())
687
+
688
+ // Initial capture after 500ms
689
+ hierarchyHandler?.postDelayed({ captureHierarchy() }, 500)
690
+ }
691
+
692
+ private fun stopHierarchyCapture() {
693
+ hierarchyRunnable?.let { hierarchyHandler?.removeCallbacks(it) }
694
+ hierarchyHandler = null
695
+ hierarchyRunnable = null
696
+ }
697
+
698
+ private fun captureHierarchy() {
699
+ if (!live) return
700
+ val sid = replayId ?: return
701
+
702
+ if (Looper.myLooper() != Looper.getMainLooper()) {
703
+ mainHandler.post { captureHierarchy() }
704
+ return
705
+ }
706
+
707
+ val hierarchy = ViewHierarchyScanner.shared?.captureHierarchy() ?: return
708
+
709
+ val hash = hierarchyHash(hierarchy)
710
+ if (hash == lastHierarchyHash) return
711
+ lastHierarchyHash = hash
712
+
713
+ val json = JSONObject(hierarchy).toString().toByteArray(Charsets.UTF_8)
714
+ val ts = System.currentTimeMillis()
715
+
716
+ SegmentDispatcher.shared.transmitHierarchy(sid, json, ts, null)
717
+ }
718
+
719
+ private fun hierarchyHash(h: Map<String, Any>): String {
720
+ val screen = currentScreenName ?: "unknown"
721
+ var childCount = 0
722
+ (h["root"] as? Map<*, *>)?.let { root ->
723
+ (root["children"] as? List<*>)?.let { children ->
724
+ childCount = children.size
725
+ }
726
+ }
727
+ return "$screen:$childCount"
728
+ }
729
+ }
730
+
731
+ 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)
740
+ }