@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.
- package/README.md +29 -0
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- 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
|
+
}
|