@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.
- package/README.md +77 -3
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
- package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +204 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +216 -0
- package/ios/Recording/ReplayOrchestrator.swift +207 -144
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +45 -2
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +79 -29
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/DataCompression.swift +2 -2
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/expoRouterTracking.js +137 -0
- package/lib/commonjs/index.js +204 -34
- package/lib/commonjs/sdk/autoTracking.js +262 -100
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/expoRouterTracking.js +135 -0
- package/lib/module/index.js +203 -28
- package/lib/module/sdk/autoTracking.js +260 -100
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/expoRouterTracking.d.ts +14 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/sdk/autoTracking.d.ts +14 -1
- package/lib/typescript/types/index.d.ts +56 -5
- package/package.json +23 -3
- package/src/NativeRejourney.ts +8 -5
- package/src/expoRouterTracking.ts +167 -0
- package/src/index.ts +221 -35
- package/src/sdk/autoTracking.ts +286 -114
- package/src/sdk/networkInterceptor.ts +110 -1
- 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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|