@rejourneyco/react-native 1.0.1 → 1.0.3
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/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +72 -391
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +11 -113
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +1 -15
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +1 -61
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +3 -1
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +1 -22
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +3 -26
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +0 -2
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +7 -93
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +5 -41
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +2 -58
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +4 -4
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +36 -7
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +7 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +9 -0
- package/ios/Capture/RJCaptureEngine.m +3 -34
- package/ios/Capture/RJVideoEncoder.m +0 -26
- package/ios/Capture/RJViewHierarchyScanner.m +68 -51
- package/ios/Core/RJLifecycleManager.m +0 -14
- package/ios/Core/Rejourney.mm +53 -129
- package/ios/Network/RJDeviceAuthManager.m +0 -2
- package/ios/Network/RJUploadManager.h +8 -0
- package/ios/Network/RJUploadManager.m +45 -0
- package/ios/Privacy/RJPrivacyMask.m +5 -31
- package/ios/Rejourney.h +0 -14
- package/ios/Touch/RJTouchInterceptor.m +21 -15
- package/ios/Utils/RJEventBuffer.m +57 -69
- package/ios/Utils/RJPerfTiming.m +0 -5
- package/ios/Utils/RJWindowUtils.m +87 -87
- package/lib/commonjs/components/Mask.js +1 -6
- package/lib/commonjs/index.js +46 -117
- package/lib/commonjs/sdk/autoTracking.js +39 -313
- package/lib/commonjs/sdk/constants.js +2 -13
- package/lib/commonjs/sdk/errorTracking.js +1 -29
- package/lib/commonjs/sdk/metricsTracking.js +3 -24
- package/lib/commonjs/sdk/navigation.js +3 -42
- package/lib/commonjs/sdk/networkInterceptor.js +7 -60
- package/lib/commonjs/sdk/utils.js +73 -19
- package/lib/module/components/Mask.js +1 -6
- package/lib/module/index.js +45 -121
- package/lib/module/sdk/autoTracking.js +39 -314
- package/lib/module/sdk/constants.js +2 -13
- package/lib/module/sdk/errorTracking.js +1 -29
- package/lib/module/sdk/index.js +0 -2
- package/lib/module/sdk/metricsTracking.js +3 -24
- package/lib/module/sdk/navigation.js +3 -42
- package/lib/module/sdk/networkInterceptor.js +7 -60
- package/lib/module/sdk/utils.js +73 -19
- package/lib/typescript/NativeRejourney.d.ts +1 -0
- package/lib/typescript/sdk/autoTracking.d.ts +4 -4
- package/lib/typescript/sdk/utils.d.ts +31 -1
- package/lib/typescript/types/index.d.ts +0 -1
- package/package.json +17 -11
- package/src/NativeRejourney.ts +2 -0
- package/src/components/Mask.tsx +0 -3
- package/src/index.ts +43 -92
- package/src/sdk/autoTracking.ts +51 -284
- package/src/sdk/constants.ts +13 -13
- package/src/sdk/errorTracking.ts +1 -17
- package/src/sdk/index.ts +0 -2
- package/src/sdk/metricsTracking.ts +5 -33
- package/src/sdk/navigation.ts +8 -29
- package/src/sdk/networkInterceptor.ts +9 -42
- package/src/sdk/utils.ts +76 -19
- package/src/types/index.ts +0 -29
|
@@ -57,7 +57,6 @@ import java.io.File
|
|
|
57
57
|
import java.util.*
|
|
58
58
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
59
59
|
|
|
60
|
-
// Enum for session end reasons
|
|
61
60
|
enum class EndReason {
|
|
62
61
|
SESSION_TIMEOUT,
|
|
63
62
|
MANUAL_STOP,
|
|
@@ -80,25 +79,21 @@ class RejourneyModuleImpl(
|
|
|
80
79
|
|
|
81
80
|
companion object {
|
|
82
81
|
const val NAME = "Rejourney"
|
|
83
|
-
const val BACKGROUND_RESUME_TIMEOUT_MS = 30_000L
|
|
82
|
+
const val BACKGROUND_RESUME_TIMEOUT_MS = 30_000L
|
|
84
83
|
|
|
85
|
-
// Auth retry constants
|
|
86
84
|
private const val MAX_AUTH_RETRIES = 5
|
|
87
|
-
private const val AUTH_RETRY_BASE_DELAY_MS = 2000L
|
|
88
|
-
private const val AUTH_RETRY_MAX_DELAY_MS = 60000L
|
|
89
|
-
private const val AUTH_BACKGROUND_RETRY_DELAY_MS = 300000L
|
|
85
|
+
private const val AUTH_RETRY_BASE_DELAY_MS = 2000L
|
|
86
|
+
private const val AUTH_RETRY_MAX_DELAY_MS = 60000L
|
|
87
|
+
private const val AUTH_BACKGROUND_RETRY_DELAY_MS = 300000L
|
|
90
88
|
|
|
91
|
-
// Store process start time at class load for accurate app startup measurement
|
|
92
89
|
@JvmStatic
|
|
93
90
|
private val processStartTimeMs: Long = run {
|
|
94
91
|
try {
|
|
95
92
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
|
|
96
|
-
// API 24+: Use Process.getStartElapsedRealtime() for accurate measurement
|
|
97
93
|
val startElapsed = android.os.Process.getStartElapsedRealtime()
|
|
98
94
|
val nowElapsed = android.os.SystemClock.elapsedRealtime()
|
|
99
95
|
System.currentTimeMillis() - (nowElapsed - startElapsed)
|
|
100
96
|
} else {
|
|
101
|
-
// Fallback for older devices: use current time (less accurate)
|
|
102
97
|
System.currentTimeMillis()
|
|
103
98
|
}
|
|
104
99
|
} catch (e: Exception) {
|
|
@@ -107,7 +102,6 @@ class RejourneyModuleImpl(
|
|
|
107
102
|
}
|
|
108
103
|
}
|
|
109
104
|
|
|
110
|
-
// Core components
|
|
111
105
|
private var captureEngine: CaptureEngine? = null
|
|
112
106
|
private var uploadManager: UploadManager? = null
|
|
113
107
|
private var touchInterceptor: TouchInterceptor? = null
|
|
@@ -116,7 +110,6 @@ class RejourneyModuleImpl(
|
|
|
116
110
|
private var keyboardTracker: KeyboardTracker? = null
|
|
117
111
|
private var textInputTracker: TextInputTracker? = null
|
|
118
112
|
|
|
119
|
-
// Session state
|
|
120
113
|
private var currentSessionId: String? = null
|
|
121
114
|
private var userId: String? = null
|
|
122
115
|
@Volatile private var isRecording: Boolean = false
|
|
@@ -134,59 +127,42 @@ class RejourneyModuleImpl(
|
|
|
134
127
|
private var maxRecordingMinutes: Int = 10
|
|
135
128
|
@Volatile private var sessionEndSent: Boolean = false
|
|
136
129
|
|
|
137
|
-
// Keyboard state
|
|
138
130
|
private var keyPressCount: Int = 0
|
|
139
131
|
private var isKeyboardVisible: Boolean = false
|
|
140
132
|
private var lastKeyboardHeight: Int = 0
|
|
141
133
|
|
|
142
|
-
// Session state saved on background - used to restore on foreground if within timeout
|
|
143
134
|
private var savedApiUrl: String = ""
|
|
144
135
|
private var savedPublicKey: String = ""
|
|
145
136
|
private var savedDeviceHash: String = ""
|
|
146
137
|
|
|
147
|
-
// Events buffer
|
|
148
138
|
private val sessionEvents = CopyOnWriteArrayList<Map<String, Any?>>()
|
|
149
139
|
|
|
150
|
-
// Throttle immediate upload kicks (ms)
|
|
151
140
|
@Volatile private var lastImmediateUploadKickMs: Long = 0
|
|
152
141
|
|
|
153
|
-
// Write-first event buffer for crash-safe persistence
|
|
154
142
|
private var eventBuffer: EventBuffer? = null
|
|
155
143
|
|
|
156
|
-
// Coroutine scope for async operations
|
|
157
144
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
158
145
|
|
|
159
|
-
// Dedicated scope for background flush - survives independently of main scope
|
|
160
|
-
// This prevents cancellation when app goes to background
|
|
161
146
|
private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
162
147
|
|
|
163
|
-
// Timer jobs
|
|
164
148
|
private var batchUploadJob: Job? = null
|
|
165
149
|
private var durationLimitJob: Job? = null
|
|
166
150
|
|
|
167
|
-
// Main thread handler for posting delayed tasks
|
|
168
151
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
169
152
|
|
|
170
|
-
// Debounced background detection (prevents transient pauses from ending sessions)
|
|
171
153
|
private var scheduledBackgroundRunnable: Runnable? = null
|
|
172
154
|
private var backgroundScheduled: Boolean = false
|
|
173
155
|
|
|
174
|
-
// Safety flag
|
|
175
156
|
@Volatile private var isShuttingDown = false
|
|
176
157
|
|
|
177
|
-
// Auth resilience - retry mechanism
|
|
178
158
|
private var authRetryCount = 0
|
|
179
159
|
private var authPermanentlyFailed = false
|
|
180
160
|
private var authRetryJob: Job? = null
|
|
181
161
|
|
|
182
162
|
init {
|
|
183
|
-
// DO NOT initialize anything here that could throw exceptions
|
|
184
|
-
// React Native needs the module constructor to complete cleanly
|
|
185
|
-
// All initialization will happen lazily on first method call
|
|
186
163
|
Logger.debug("RejourneyModuleImpl constructor completed")
|
|
187
164
|
}
|
|
188
165
|
|
|
189
|
-
// Lazy initialization flag
|
|
190
166
|
@Volatile
|
|
191
167
|
private var isInitialized = false
|
|
192
168
|
private val initLock = Any()
|
|
@@ -207,7 +183,6 @@ class RejourneyModuleImpl(
|
|
|
207
183
|
registerActivityLifecycleCallbacks()
|
|
208
184
|
registerProcessLifecycleObserver()
|
|
209
185
|
|
|
210
|
-
// Start crash handler, ANR handler, and network monitor with error handling
|
|
211
186
|
try {
|
|
212
187
|
CrashHandler.getInstance(reactContext).startMonitoring()
|
|
213
188
|
} catch (e: Exception) {
|
|
@@ -229,49 +204,38 @@ class RejourneyModuleImpl(
|
|
|
229
204
|
Logger.error("Failed to start network monitor (non-critical)", e)
|
|
230
205
|
}
|
|
231
206
|
|
|
232
|
-
// Schedule recovery of any pending uploads from previous sessions
|
|
233
|
-
// This handles cases where the app was killed before uploads completed
|
|
234
207
|
try {
|
|
235
208
|
UploadWorker.scheduleRecoveryUpload(reactContext)
|
|
236
209
|
} catch (e: Exception) {
|
|
237
210
|
Logger.error("Failed to schedule recovery upload (non-critical)", e)
|
|
238
211
|
}
|
|
239
212
|
|
|
240
|
-
// Check if app was killed in previous session (Android 11+)
|
|
241
213
|
try {
|
|
242
214
|
checkPreviousAppKill()
|
|
243
215
|
} catch (e: Exception) {
|
|
244
216
|
Logger.error("Failed to check previous app kill (non-critical)", e)
|
|
245
217
|
}
|
|
246
218
|
|
|
247
|
-
// Check for unclosed sessions from previous launch
|
|
248
219
|
try {
|
|
249
220
|
checkForUnclosedSessions()
|
|
250
221
|
} catch (e: Exception) {
|
|
251
222
|
Logger.error("Failed to check for unclosed sessions (non-critical)", e)
|
|
252
223
|
}
|
|
253
224
|
|
|
254
|
-
// Log OEM information for debugging
|
|
255
225
|
val oem = OEMDetector.getOEM()
|
|
256
226
|
Logger.debug("Device OEM: $oem")
|
|
257
227
|
Logger.debug("OEM Recommendations: ${OEMDetector.getRecommendations()}")
|
|
258
228
|
Logger.debug("onTaskRemoved() reliable: ${OEMDetector.isTaskRemovedReliable()}")
|
|
259
229
|
|
|
260
|
-
// Set up SessionLifecycleService listener to detect app termination
|
|
261
230
|
try {
|
|
262
231
|
SessionLifecycleService.taskRemovedListener = object : TaskRemovedListener {
|
|
263
232
|
override fun onTaskRemoved() {
|
|
264
233
|
Logger.debug("[Rejourney] App terminated via swipe-away - SYNCHRONOUS session end (OEM: $oem)")
|
|
265
234
|
|
|
266
|
-
// CRITICAL: Use runBlocking to ensure session end completes BEFORE process death
|
|
267
|
-
// The previous async implementation using scope.launch would not complete in time
|
|
268
|
-
// because the process would be killed before the coroutine executed.
|
|
269
235
|
if (isRecording && !sessionEndSent) {
|
|
270
236
|
try {
|
|
271
|
-
// Use runBlocking with a timeout to ensure we don't block indefinitely
|
|
272
|
-
// but still give enough time for critical operations (HTTP calls)
|
|
273
237
|
runBlocking {
|
|
274
|
-
withTimeout(5000L) {
|
|
238
|
+
withTimeout(5000L) {
|
|
275
239
|
Logger.debug("[Rejourney] Starting synchronous session end...")
|
|
276
240
|
endSessionSynchronous()
|
|
277
241
|
Logger.debug("[Rejourney] Synchronous session end completed")
|
|
@@ -291,13 +255,11 @@ class RejourneyModuleImpl(
|
|
|
291
255
|
Logger.error("Failed to set up task removed listener (non-critical)", e)
|
|
292
256
|
}
|
|
293
257
|
|
|
294
|
-
// Use lifecycle log - only shown in debug builds
|
|
295
258
|
Logger.logInitSuccess(Constants.SDK_VERSION)
|
|
296
259
|
|
|
297
260
|
isInitialized = true
|
|
298
261
|
} catch (e: Exception) {
|
|
299
262
|
Logger.logInitFailure("${e.javaClass.simpleName}: ${e.message}")
|
|
300
|
-
// Mark as initialized anyway to prevent retry loops
|
|
301
263
|
isInitialized = true
|
|
302
264
|
}
|
|
303
265
|
}
|
|
@@ -313,8 +275,6 @@ class RejourneyModuleImpl(
|
|
|
313
275
|
|
|
314
276
|
Logger.debug("[Rejourney] addEventWithPersistence: type=$eventType, sessionId=$sessionId, inMemoryCount=${sessionEvents.size + 1}")
|
|
315
277
|
|
|
316
|
-
// CRITICAL: Write to disk immediately for crash safety
|
|
317
|
-
// This ensures events are never lost even if app is force-killed
|
|
318
278
|
val bufferSuccess = eventBuffer?.appendEvent(event) ?: false
|
|
319
279
|
if (!bufferSuccess) {
|
|
320
280
|
Logger.warning("[Rejourney] addEventWithPersistence: Failed to append event to buffer: type=$eventType")
|
|
@@ -322,7 +282,6 @@ class RejourneyModuleImpl(
|
|
|
322
282
|
Logger.debug("[Rejourney] addEventWithPersistence: Event appended to buffer: type=$eventType")
|
|
323
283
|
}
|
|
324
284
|
|
|
325
|
-
// Also add to in-memory buffer for batched upload
|
|
326
285
|
sessionEvents.add(event)
|
|
327
286
|
Logger.debug("[Rejourney] addEventWithPersistence: Event added to in-memory list: type=$eventType, totalInMemory=${sessionEvents.size}")
|
|
328
287
|
}
|
|
@@ -332,14 +291,12 @@ class RejourneyModuleImpl(
|
|
|
332
291
|
* This is more reliable than Activity lifecycle callbacks.
|
|
333
292
|
*/
|
|
334
293
|
private fun registerProcessLifecycleObserver() {
|
|
335
|
-
// Must run on main thread
|
|
336
294
|
Handler(Looper.getMainLooper()).post {
|
|
337
295
|
try {
|
|
338
296
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
|
339
297
|
Logger.debug("ProcessLifecycleOwner observer registered")
|
|
340
298
|
} catch (e: Exception) {
|
|
341
299
|
Logger.error("Failed to register ProcessLifecycleOwner observer (non-critical)", e)
|
|
342
|
-
// This is non-critical - we can still use Activity lifecycle callbacks as fallback
|
|
343
300
|
}
|
|
344
301
|
}
|
|
345
302
|
}
|
|
@@ -354,7 +311,6 @@ class RejourneyModuleImpl(
|
|
|
354
311
|
|
|
355
312
|
private fun setupComponents() {
|
|
356
313
|
try {
|
|
357
|
-
// Initialize capture engine with video segment mode
|
|
358
314
|
captureEngine = CaptureEngine(reactContext).apply {
|
|
359
315
|
captureScale = Constants.DEFAULT_CAPTURE_SCALE
|
|
360
316
|
minFrameInterval = Constants.DEFAULT_MIN_FRAME_INTERVAL
|
|
@@ -371,7 +327,6 @@ class RejourneyModuleImpl(
|
|
|
371
327
|
}
|
|
372
328
|
|
|
373
329
|
try {
|
|
374
|
-
// Initialize upload manager
|
|
375
330
|
uploadManager = UploadManager(reactContext, "https://api.rejourney.co")
|
|
376
331
|
Logger.debug("UploadManager initialized")
|
|
377
332
|
} catch (e: Exception) {
|
|
@@ -380,7 +335,6 @@ class RejourneyModuleImpl(
|
|
|
380
335
|
}
|
|
381
336
|
|
|
382
337
|
try {
|
|
383
|
-
// Initialize touch interceptor
|
|
384
338
|
touchInterceptor = TouchInterceptor.getInstance(reactContext).apply {
|
|
385
339
|
delegate = this@RejourneyModuleImpl
|
|
386
340
|
}
|
|
@@ -391,7 +345,6 @@ class RejourneyModuleImpl(
|
|
|
391
345
|
}
|
|
392
346
|
|
|
393
347
|
try {
|
|
394
|
-
// Initialize device auth manager
|
|
395
348
|
deviceAuthManager = DeviceAuthManager.getInstance(reactContext).apply {
|
|
396
349
|
authFailureListener = this@RejourneyModuleImpl
|
|
397
350
|
}
|
|
@@ -402,7 +355,6 @@ class RejourneyModuleImpl(
|
|
|
402
355
|
}
|
|
403
356
|
|
|
404
357
|
try {
|
|
405
|
-
// Initialize network monitor
|
|
406
358
|
networkMonitor = NetworkMonitor.getInstance(reactContext).apply {
|
|
407
359
|
listener = this@RejourneyModuleImpl
|
|
408
360
|
}
|
|
@@ -413,7 +365,6 @@ class RejourneyModuleImpl(
|
|
|
413
365
|
}
|
|
414
366
|
|
|
415
367
|
try {
|
|
416
|
-
// Initialize keyboard tracker (for keyboard show/hide events)
|
|
417
368
|
keyboardTracker = KeyboardTracker.getInstance(reactContext).apply {
|
|
418
369
|
listener = this@RejourneyModuleImpl
|
|
419
370
|
}
|
|
@@ -424,7 +375,6 @@ class RejourneyModuleImpl(
|
|
|
424
375
|
}
|
|
425
376
|
|
|
426
377
|
try {
|
|
427
|
-
// Initialize text input tracker (for key press counting)
|
|
428
378
|
textInputTracker = TextInputTracker.getInstance(reactContext).apply {
|
|
429
379
|
listener = this@RejourneyModuleImpl
|
|
430
380
|
}
|
|
@@ -462,27 +412,57 @@ class RejourneyModuleImpl(
|
|
|
462
412
|
val application = reactContext.applicationContext as? Application
|
|
463
413
|
application?.unregisterActivityLifecycleCallbacks(this)
|
|
464
414
|
|
|
465
|
-
// Unregister from ProcessLifecycleOwner
|
|
466
415
|
Handler(Looper.getMainLooper()).post {
|
|
467
416
|
try {
|
|
468
417
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
|
469
418
|
} catch (e: Exception) {
|
|
470
|
-
// Ignore
|
|
471
419
|
}
|
|
472
420
|
}
|
|
473
421
|
}
|
|
474
422
|
|
|
475
|
-
|
|
423
|
+
fun getDeviceInfo(promise: Promise) {
|
|
424
|
+
try {
|
|
425
|
+
val map = Arguments.createMap()
|
|
426
|
+
map.putString("model", android.os.Build.MODEL)
|
|
427
|
+
map.putString("brand", android.os.Build.MANUFACTURER)
|
|
428
|
+
map.putString("systemName", "Android")
|
|
429
|
+
map.putString("systemVersion", android.os.Build.VERSION.RELEASE)
|
|
430
|
+
map.putString("bundleId", reactContext.packageName)
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
val pInfo = reactContext.packageManager.getPackageInfo(reactContext.packageName, 0)
|
|
434
|
+
map.putString("appVersion", pInfo.versionName)
|
|
435
|
+
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
|
436
|
+
map.putString("buildNumber", pInfo.longVersionCode.toString())
|
|
437
|
+
} else {
|
|
438
|
+
@Suppress("DEPRECATION")
|
|
439
|
+
map.putString("buildNumber", pInfo.versionCode.toString())
|
|
440
|
+
}
|
|
441
|
+
} catch (e: Exception) {
|
|
442
|
+
map.putString("appVersion", "unknown")
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
map.putBoolean("isTablet", isTablet())
|
|
446
|
+
promise.resolve(map)
|
|
447
|
+
} catch (e: Exception) {
|
|
448
|
+
promise.reject("DEVICE_INFO_ERROR", e)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private fun isTablet(): Boolean {
|
|
453
|
+
val configuration = reactContext.resources.configuration
|
|
454
|
+
return (configuration.screenLayout and android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK) >=
|
|
455
|
+
android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
|
|
456
|
+
}
|
|
476
457
|
|
|
477
458
|
fun startSession(userId: String, apiUrl: String, publicKey: String, promise: Promise) {
|
|
478
|
-
ensureInitialized()
|
|
459
|
+
ensureInitialized()
|
|
479
460
|
|
|
480
461
|
if (isShuttingDown) {
|
|
481
462
|
promise.resolve(createResultMap(false, "", "Module is shutting down"))
|
|
482
463
|
return
|
|
483
464
|
}
|
|
484
465
|
|
|
485
|
-
// Optimistically allow start; remote config will shut down if disabled.
|
|
486
466
|
remoteRejourneyEnabled = true
|
|
487
467
|
|
|
488
468
|
scope.launch {
|
|
@@ -496,14 +476,12 @@ class RejourneyModuleImpl(
|
|
|
496
476
|
val safeApiUrl = apiUrl.ifEmpty { "https://api.rejourney.co" }
|
|
497
477
|
val safePublicKey = publicKey.ifEmpty { "" }
|
|
498
478
|
|
|
499
|
-
// Generate device hash
|
|
500
479
|
val androidId = Settings.Secure.getString(
|
|
501
480
|
reactContext.contentResolver,
|
|
502
481
|
Settings.Secure.ANDROID_ID
|
|
503
482
|
) ?: "unknown"
|
|
504
483
|
val deviceHash = generateSHA256Hash(androidId)
|
|
505
484
|
|
|
506
|
-
// Setup session
|
|
507
485
|
this@RejourneyModuleImpl.userId = safeUserId
|
|
508
486
|
currentSessionId = WindowUtils.generateSessionId()
|
|
509
487
|
sessionStartTime = System.currentTimeMillis()
|
|
@@ -511,36 +489,30 @@ class RejourneyModuleImpl(
|
|
|
511
489
|
sessionEndSent = false
|
|
512
490
|
sessionEvents.clear()
|
|
513
491
|
|
|
514
|
-
// Reset remote recording flag for this session until config says otherwise
|
|
515
492
|
remoteRecordingEnabled = true
|
|
516
493
|
recordingEnabledByConfig = true
|
|
517
494
|
projectSampleRate = 100
|
|
518
495
|
hasProjectConfig = false
|
|
519
496
|
resetSamplingDecision()
|
|
520
497
|
|
|
521
|
-
// Save session ID for crash handler
|
|
522
498
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
523
499
|
.edit()
|
|
524
500
|
.putString("rj_current_session_id", currentSessionId)
|
|
525
501
|
.apply()
|
|
526
502
|
|
|
527
|
-
// Configure upload manager
|
|
528
503
|
uploadManager?.apply {
|
|
529
504
|
this.apiUrl = safeApiUrl
|
|
530
505
|
this.publicKey = safePublicKey
|
|
531
506
|
this.deviceHash = deviceHash
|
|
532
|
-
// NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
|
|
533
507
|
setActiveSessionId(currentSessionId!!)
|
|
534
508
|
this.userId = safeUserId
|
|
535
509
|
this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
|
|
536
510
|
resetForNewSession()
|
|
537
511
|
}
|
|
538
512
|
|
|
539
|
-
// Mark session active for crash recovery (disk-backed)
|
|
540
513
|
currentSessionId?.let { sid ->
|
|
541
514
|
uploadManager?.markSessionActive(sid, sessionStartTime)
|
|
542
515
|
|
|
543
|
-
// Also save to SharedPreferences for unclosed session detection
|
|
544
516
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
545
517
|
.edit()
|
|
546
518
|
.putString("rj_current_session_id", sid)
|
|
@@ -548,33 +520,26 @@ class RejourneyModuleImpl(
|
|
|
548
520
|
.apply()
|
|
549
521
|
}
|
|
550
522
|
|
|
551
|
-
// Initialize write-first event buffer for crash-safe persistence
|
|
552
523
|
val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
|
|
553
524
|
currentSessionId?.let { sid ->
|
|
554
525
|
eventBuffer = EventBuffer(reactContext, sid, pendingDir)
|
|
555
526
|
}
|
|
556
527
|
|
|
557
|
-
// Save config for auto-resume on quick background return
|
|
558
528
|
savedApiUrl = safeApiUrl
|
|
559
529
|
savedPublicKey = safePublicKey
|
|
560
530
|
savedDeviceHash = deviceHash
|
|
561
531
|
|
|
562
|
-
// Start capture engine only if recording is enabled remotely
|
|
563
532
|
if (remoteRecordingEnabled) {
|
|
564
533
|
captureEngine?.startSession(currentSessionId!!)
|
|
565
534
|
}
|
|
566
535
|
|
|
567
|
-
// Enable touch tracking
|
|
568
536
|
touchInterceptor?.enableGlobalTracking()
|
|
569
537
|
|
|
570
|
-
// Start keyboard and text input tracking
|
|
571
538
|
keyboardTracker?.startTracking()
|
|
572
539
|
textInputTracker?.startTracking()
|
|
573
540
|
|
|
574
|
-
// Mark as recording
|
|
575
541
|
isRecording = true
|
|
576
542
|
|
|
577
|
-
// Start SessionLifecycleService to detect app termination
|
|
578
543
|
try {
|
|
579
544
|
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
580
545
|
reactContext.startService(serviceIntent)
|
|
@@ -583,15 +548,12 @@ class RejourneyModuleImpl(
|
|
|
583
548
|
Logger.warning("Failed to start SessionLifecycleService: ${e.message}")
|
|
584
549
|
}
|
|
585
550
|
|
|
586
|
-
// Start batch uploads
|
|
587
551
|
startBatchUploadTimer()
|
|
588
552
|
startDurationLimitTimer()
|
|
589
553
|
|
|
590
|
-
// Emit app_startup event with startup duration
|
|
591
|
-
// This is the time from process start to session start
|
|
592
554
|
val nowMs = System.currentTimeMillis()
|
|
593
555
|
val startupDurationMs = nowMs - processStartTimeMs
|
|
594
|
-
if (startupDurationMs > 0 && startupDurationMs < 60000) {
|
|
556
|
+
if (startupDurationMs > 0 && startupDurationMs < 60000) {
|
|
595
557
|
val startupEvent = mapOf(
|
|
596
558
|
"type" to "app_startup",
|
|
597
559
|
"timestamp" to nowMs,
|
|
@@ -602,13 +564,10 @@ class RejourneyModuleImpl(
|
|
|
602
564
|
Logger.debug("Recorded app startup time: ${startupDurationMs}ms")
|
|
603
565
|
}
|
|
604
566
|
|
|
605
|
-
// Fetch project config
|
|
606
567
|
fetchProjectConfig(safePublicKey, safeApiUrl)
|
|
607
568
|
|
|
608
|
-
// Register device
|
|
609
569
|
registerDevice(safePublicKey, safeApiUrl)
|
|
610
570
|
|
|
611
|
-
// Use lifecycle log for session start - only shown in debug builds
|
|
612
571
|
Logger.logSessionStart(currentSessionId ?: "")
|
|
613
572
|
|
|
614
573
|
promise.resolve(createResultMap(true, currentSessionId ?: ""))
|
|
@@ -635,22 +594,17 @@ class RejourneyModuleImpl(
|
|
|
635
594
|
|
|
636
595
|
val sessionId = currentSessionId ?: ""
|
|
637
596
|
|
|
638
|
-
// Stop timers
|
|
639
597
|
stopBatchUploadTimer()
|
|
640
598
|
stopDurationLimitTimer()
|
|
641
599
|
|
|
642
|
-
// Force final capture
|
|
643
600
|
if (remoteRecordingEnabled) {
|
|
644
601
|
captureEngine?.forceCaptureWithReason("session_end")
|
|
645
602
|
}
|
|
646
603
|
|
|
647
|
-
// Stop capture engine (triggers final segment upload via delegate)
|
|
648
604
|
captureEngine?.stopSession()
|
|
649
605
|
|
|
650
|
-
// Disable touch tracking
|
|
651
606
|
touchInterceptor?.disableGlobalTracking()
|
|
652
607
|
|
|
653
|
-
// Build metrics for promotion evaluation
|
|
654
608
|
var crashCount = 0
|
|
655
609
|
var anrCount = 0
|
|
656
610
|
var errorCount = 0
|
|
@@ -670,7 +624,6 @@ class RejourneyModuleImpl(
|
|
|
670
624
|
"durationSeconds" to durationSeconds
|
|
671
625
|
)
|
|
672
626
|
|
|
673
|
-
// Evaluate replay promotion
|
|
674
627
|
val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
|
|
675
628
|
val isPromoted = promotionResult?.first ?: false
|
|
676
629
|
val reason = promotionResult?.second ?: "unknown"
|
|
@@ -681,11 +634,9 @@ class RejourneyModuleImpl(
|
|
|
681
634
|
Logger.debug("Session not promoted (reason: $reason)")
|
|
682
635
|
}
|
|
683
636
|
|
|
684
|
-
// Upload remaining events (video segments uploaded via delegate callbacks)
|
|
685
637
|
val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
|
|
686
638
|
|
|
687
|
-
|
|
688
|
-
var endSessionSuccess = sessionEndSent // Already sent counts as success
|
|
639
|
+
var endSessionSuccess = sessionEndSent
|
|
689
640
|
if (!sessionEndSent) {
|
|
690
641
|
sessionEndSent = true
|
|
691
642
|
endSessionSuccess = uploadManager?.endSession() ?: false
|
|
@@ -694,12 +645,10 @@ class RejourneyModuleImpl(
|
|
|
694
645
|
}
|
|
695
646
|
}
|
|
696
647
|
|
|
697
|
-
// Clear crash recovery markers only if the session is actually closed
|
|
698
648
|
if (endSessionSuccess) {
|
|
699
649
|
currentSessionId?.let { sid ->
|
|
700
650
|
uploadManager?.clearSessionRecovery(sid)
|
|
701
651
|
|
|
702
|
-
// Mark session as closed in SharedPreferences
|
|
703
652
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
704
653
|
.edit()
|
|
705
654
|
.putLong("rj_session_end_time_$sid", System.currentTimeMillis())
|
|
@@ -709,13 +658,11 @@ class RejourneyModuleImpl(
|
|
|
709
658
|
}
|
|
710
659
|
}
|
|
711
660
|
|
|
712
|
-
// Clear state
|
|
713
661
|
isRecording = false
|
|
714
662
|
currentSessionId = null
|
|
715
663
|
this@RejourneyModuleImpl.userId = null
|
|
716
664
|
sessionEvents.clear()
|
|
717
665
|
|
|
718
|
-
// Stop SessionLifecycleService
|
|
719
666
|
try {
|
|
720
667
|
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
721
668
|
reactContext.stopService(serviceIntent)
|
|
@@ -724,7 +671,6 @@ class RejourneyModuleImpl(
|
|
|
724
671
|
Logger.warning("Failed to stop SessionLifecycleService: ${e.message}")
|
|
725
672
|
}
|
|
726
673
|
|
|
727
|
-
// Use lifecycle log for session end - only shown in debug builds
|
|
728
674
|
Logger.logSessionEnd(sessionId)
|
|
729
675
|
|
|
730
676
|
promise.resolve(createStopResultMap(true, sessionId, uploadSuccess && endSessionSuccess, null, null))
|
|
@@ -772,7 +718,6 @@ class RejourneyModuleImpl(
|
|
|
772
718
|
)
|
|
773
719
|
addEventWithPersistence(event)
|
|
774
720
|
|
|
775
|
-
// Notify capture engine with delay for render
|
|
776
721
|
scope.launch {
|
|
777
722
|
delay(100)
|
|
778
723
|
captureEngine?.notifyNavigationToScreen(screenName)
|
|
@@ -925,7 +870,6 @@ class RejourneyModuleImpl(
|
|
|
925
870
|
|
|
926
871
|
fun debugTriggerANR(durationMs: Double) {
|
|
927
872
|
Logger.debug("Triggering debug ANR for ${durationMs.toLong()}ms...")
|
|
928
|
-
// Post to main looper to block the main thread
|
|
929
873
|
Handler(Looper.getMainLooper()).post {
|
|
930
874
|
try {
|
|
931
875
|
Thread.sleep(durationMs.toLong())
|
|
@@ -939,8 +883,6 @@ class RejourneyModuleImpl(
|
|
|
939
883
|
promise.resolve(currentSessionId)
|
|
940
884
|
}
|
|
941
885
|
|
|
942
|
-
// ==================== Privacy / View Masking ====================
|
|
943
|
-
|
|
944
886
|
fun maskViewByNativeID(nativeID: String, promise: Promise) {
|
|
945
887
|
if (nativeID.isEmpty()) {
|
|
946
888
|
promise.resolve(createSuccessMap(false))
|
|
@@ -948,8 +890,6 @@ class RejourneyModuleImpl(
|
|
|
948
890
|
}
|
|
949
891
|
|
|
950
892
|
try {
|
|
951
|
-
// Add nativeID to the privacy mask set - will be checked during capture
|
|
952
|
-
// This is robust because we don't need to find the view immediately
|
|
953
893
|
com.rejourney.privacy.PrivacyMask.addMaskedNativeID(nativeID)
|
|
954
894
|
Logger.debug("Masked nativeID: $nativeID")
|
|
955
895
|
promise.resolve(createSuccessMap(true))
|
|
@@ -966,7 +906,6 @@ class RejourneyModuleImpl(
|
|
|
966
906
|
}
|
|
967
907
|
|
|
968
908
|
try {
|
|
969
|
-
// Remove nativeID from the privacy mask set
|
|
970
909
|
com.rejourney.privacy.PrivacyMask.removeMaskedNativeID(nativeID)
|
|
971
910
|
Logger.debug("Unmasked nativeID: $nativeID")
|
|
972
911
|
promise.resolve(createSuccessMap(true))
|
|
@@ -981,13 +920,11 @@ class RejourneyModuleImpl(
|
|
|
981
920
|
* In React Native, nativeID is typically stored in the view's tag or as a resource ID.
|
|
982
921
|
*/
|
|
983
922
|
private fun findViewByNativeID(view: android.view.View, nativeID: String): android.view.View? {
|
|
984
|
-
// Check if view has matching tag (common RN pattern)
|
|
985
923
|
val viewTag = view.getTag(com.facebook.react.R.id.view_tag_native_id)
|
|
986
924
|
if (viewTag is String && viewTag == nativeID) {
|
|
987
925
|
return view
|
|
988
926
|
}
|
|
989
927
|
|
|
990
|
-
// Recurse into ViewGroup children
|
|
991
928
|
if (view is android.view.ViewGroup) {
|
|
992
929
|
for (i in 0 until view.childCount) {
|
|
993
930
|
val child = view.getChildAt(i)
|
|
@@ -999,28 +936,22 @@ class RejourneyModuleImpl(
|
|
|
999
936
|
return null
|
|
1000
937
|
}
|
|
1001
938
|
|
|
1002
|
-
// ==================== User Identity ====================
|
|
1003
939
|
|
|
1004
940
|
fun setUserIdentity(userId: String, promise: Promise) {
|
|
1005
941
|
try {
|
|
1006
942
|
val safeUserId = userId.ifEmpty { "anonymous" }
|
|
1007
943
|
|
|
1008
|
-
// KEY CHANGE: Persist directly to SharedPreferences (Native Storage)
|
|
1009
|
-
// This replaces the need for async-storage on the JS side
|
|
1010
944
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
1011
945
|
.edit()
|
|
1012
946
|
.putString("rj_user_identity", safeUserId)
|
|
1013
947
|
.apply()
|
|
1014
948
|
|
|
1015
|
-
// Update in-memory state
|
|
1016
949
|
this.userId = safeUserId
|
|
1017
950
|
|
|
1018
|
-
// Update upload manager
|
|
1019
951
|
uploadManager?.userId = safeUserId
|
|
1020
952
|
|
|
1021
953
|
Logger.debug("User identity updated: $safeUserId")
|
|
1022
954
|
|
|
1023
|
-
// Log event for tracking
|
|
1024
955
|
if (isRecording) {
|
|
1025
956
|
val event = mapOf(
|
|
1026
957
|
"type" to "user_identity_changed",
|
|
@@ -1041,7 +972,6 @@ class RejourneyModuleImpl(
|
|
|
1041
972
|
promise.resolve(userId)
|
|
1042
973
|
}
|
|
1043
974
|
|
|
1044
|
-
// ==================== Helper Methods ====================
|
|
1045
975
|
|
|
1046
976
|
private fun createResultMap(success: Boolean, sessionId: String, error: String? = null): WritableMap {
|
|
1047
977
|
return Arguments.createMap().apply {
|
|
@@ -1103,18 +1033,18 @@ class RejourneyModuleImpl(
|
|
|
1103
1033
|
false
|
|
1104
1034
|
}
|
|
1105
1035
|
|
|
1106
|
-
val
|
|
1107
|
-
remoteRecordingEnabled =
|
|
1036
|
+
val shouldRecordVideo = recordingEnabledByConfig && sessionSampled
|
|
1037
|
+
remoteRecordingEnabled = shouldRecordVideo
|
|
1108
1038
|
|
|
1109
|
-
if (!
|
|
1039
|
+
if (!shouldRecordVideo && captureEngine?.isRecording == true) {
|
|
1110
1040
|
captureEngine?.stopSession()
|
|
1111
1041
|
}
|
|
1112
1042
|
|
|
1113
1043
|
if (decidedSample && recordingEnabledByConfig && !sessionSampled) {
|
|
1114
|
-
Logger.
|
|
1044
|
+
Logger.info("Session sampled out for video (${clampedRate}%) - entering Data-Only Mode (Events enabled, Video disabled)")
|
|
1115
1045
|
}
|
|
1116
1046
|
|
|
1117
|
-
return
|
|
1047
|
+
return shouldRecordVideo
|
|
1118
1048
|
}
|
|
1119
1049
|
|
|
1120
1050
|
private fun startBatchUploadTimer() {
|
|
@@ -1143,7 +1073,6 @@ class RejourneyModuleImpl(
|
|
|
1143
1073
|
try {
|
|
1144
1074
|
performBatchUpload()
|
|
1145
1075
|
} catch (_: Exception) {
|
|
1146
|
-
// Best-effort only
|
|
1147
1076
|
}
|
|
1148
1077
|
}
|
|
1149
1078
|
}
|
|
@@ -1179,25 +1108,18 @@ class RejourneyModuleImpl(
|
|
|
1179
1108
|
if (!isRecording || isShuttingDown) return
|
|
1180
1109
|
|
|
1181
1110
|
try {
|
|
1182
|
-
// Video segments are uploaded via CaptureEngineDelegate callbacks.
|
|
1183
|
-
// This timer now only handles event uploads.
|
|
1184
|
-
|
|
1185
1111
|
val eventsToUpload = sessionEvents.toList()
|
|
1186
1112
|
|
|
1187
1113
|
if (eventsToUpload.isEmpty()) return
|
|
1188
1114
|
|
|
1189
|
-
// Upload events only (video segments uploaded via delegate)
|
|
1190
1115
|
val ok = uploadManager?.uploadBatch(eventsToUpload) ?: false
|
|
1191
1116
|
|
|
1192
1117
|
if (ok) {
|
|
1193
|
-
// Only clear events after data is safely uploaded
|
|
1194
1118
|
sessionEvents.clear()
|
|
1195
1119
|
}
|
|
1196
1120
|
} catch (e: CancellationException) {
|
|
1197
|
-
// Normal cancellation (e.g., app going to background) - not an error
|
|
1198
|
-
// WorkManager will handle the upload instead
|
|
1199
1121
|
Logger.debug("Batch upload cancelled (coroutine cancelled)")
|
|
1200
|
-
throw e
|
|
1122
|
+
throw e
|
|
1201
1123
|
} catch (e: Exception) {
|
|
1202
1124
|
Logger.error("Batch upload failed", e)
|
|
1203
1125
|
}
|
|
@@ -1233,24 +1155,19 @@ class RejourneyModuleImpl(
|
|
|
1233
1155
|
val sessionId = currentSessionId ?: ""
|
|
1234
1156
|
Logger.debug("Ending session due to: $reason")
|
|
1235
1157
|
|
|
1236
|
-
// Stop timers
|
|
1237
1158
|
stopBatchUploadTimer()
|
|
1238
1159
|
stopDurationLimitTimer()
|
|
1239
1160
|
|
|
1240
|
-
// Force final capture
|
|
1241
1161
|
if (remoteRecordingEnabled) {
|
|
1242
1162
|
captureEngine?.forceCaptureWithReason("session_end_${reason.name.lowercase()}")
|
|
1243
1163
|
}
|
|
1244
1164
|
|
|
1245
|
-
// Stop capture engine
|
|
1246
1165
|
captureEngine?.stopSession()
|
|
1247
1166
|
|
|
1248
|
-
// Disable touch tracking
|
|
1249
1167
|
touchInterceptor?.disableGlobalTracking()
|
|
1250
1168
|
keyboardTracker?.stopTracking()
|
|
1251
1169
|
textInputTracker?.stopTracking()
|
|
1252
1170
|
|
|
1253
|
-
// Build metrics
|
|
1254
1171
|
var crashCount = 0
|
|
1255
1172
|
var anrCount = 0
|
|
1256
1173
|
var errorCount = 0
|
|
@@ -1270,7 +1187,6 @@ class RejourneyModuleImpl(
|
|
|
1270
1187
|
"durationSeconds" to durationSeconds
|
|
1271
1188
|
)
|
|
1272
1189
|
|
|
1273
|
-
// Evaluate promotion
|
|
1274
1190
|
val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
|
|
1275
1191
|
val isPromoted = promotionResult?.first ?: false
|
|
1276
1192
|
val promotionReason = promotionResult?.second ?: "unknown"
|
|
@@ -1279,22 +1195,18 @@ class RejourneyModuleImpl(
|
|
|
1279
1195
|
Logger.debug("Session promoted (reason: $promotionReason)")
|
|
1280
1196
|
}
|
|
1281
1197
|
|
|
1282
|
-
// Upload remaining events
|
|
1283
1198
|
val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
|
|
1284
1199
|
|
|
1285
|
-
// Send session end signal
|
|
1286
1200
|
var endSessionSuccess = sessionEndSent
|
|
1287
1201
|
if (!sessionEndSent) {
|
|
1288
1202
|
sessionEndSent = true
|
|
1289
1203
|
endSessionSuccess = uploadManager?.endSession() ?: false
|
|
1290
1204
|
}
|
|
1291
1205
|
|
|
1292
|
-
// Clear recovery markers
|
|
1293
1206
|
if (endSessionSuccess) {
|
|
1294
1207
|
currentSessionId?.let { sid ->
|
|
1295
1208
|
uploadManager?.clearSessionRecovery(sid)
|
|
1296
1209
|
|
|
1297
|
-
// Mark session as closed in SharedPreferences
|
|
1298
1210
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
1299
1211
|
.edit()
|
|
1300
1212
|
.putLong("rj_session_end_time_$sid", System.currentTimeMillis())
|
|
@@ -1304,13 +1216,11 @@ class RejourneyModuleImpl(
|
|
|
1304
1216
|
}
|
|
1305
1217
|
}
|
|
1306
1218
|
|
|
1307
|
-
// Clear state
|
|
1308
1219
|
isRecording = false
|
|
1309
1220
|
currentSessionId = null
|
|
1310
1221
|
userId = null
|
|
1311
1222
|
sessionEvents.clear()
|
|
1312
1223
|
|
|
1313
|
-
// Stop SessionLifecycleService
|
|
1314
1224
|
try {
|
|
1315
1225
|
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
1316
1226
|
reactContext.stopService(serviceIntent)
|
|
@@ -1346,11 +1256,10 @@ class RejourneyModuleImpl(
|
|
|
1346
1256
|
Logger.debug("[Rejourney] endSessionSynchronous: Starting for session $sessionId")
|
|
1347
1257
|
|
|
1348
1258
|
try {
|
|
1349
|
-
// Stop timers (synchronous)
|
|
1350
1259
|
stopBatchUploadTimer()
|
|
1351
1260
|
stopDurationLimitTimer()
|
|
1352
1261
|
|
|
1353
|
-
|
|
1262
|
+
/*
|
|
1354
1263
|
if (remoteRecordingEnabled) {
|
|
1355
1264
|
try {
|
|
1356
1265
|
captureEngine?.forceCaptureWithReason("session_end_kill")
|
|
@@ -1358,15 +1267,16 @@ class RejourneyModuleImpl(
|
|
|
1358
1267
|
Logger.warning("[Rejourney] Final capture failed: ${e.message}")
|
|
1359
1268
|
}
|
|
1360
1269
|
}
|
|
1270
|
+
*/
|
|
1361
1271
|
|
|
1362
|
-
|
|
1272
|
+
/*
|
|
1363
1273
|
try {
|
|
1364
1274
|
captureEngine?.stopSession()
|
|
1365
1275
|
} catch (e: Exception) {
|
|
1366
1276
|
Logger.warning("[Rejourney] Stop capture failed: ${e.message}")
|
|
1367
1277
|
}
|
|
1278
|
+
*/
|
|
1368
1279
|
|
|
1369
|
-
// Disable tracking
|
|
1370
1280
|
try {
|
|
1371
1281
|
touchInterceptor?.disableGlobalTracking()
|
|
1372
1282
|
keyboardTracker?.stopTracking()
|
|
@@ -1375,7 +1285,6 @@ class RejourneyModuleImpl(
|
|
|
1375
1285
|
Logger.warning("[Rejourney] Stop tracking failed: ${e.message}")
|
|
1376
1286
|
}
|
|
1377
1287
|
|
|
1378
|
-
// Build metrics
|
|
1379
1288
|
var crashCount = 0
|
|
1380
1289
|
var anrCount = 0
|
|
1381
1290
|
var errorCount = 0
|
|
@@ -1388,52 +1297,23 @@ class RejourneyModuleImpl(
|
|
|
1388
1297
|
}
|
|
1389
1298
|
val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
|
|
1390
1299
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
val uploadSuccess =
|
|
1394
|
-
|
|
1395
|
-
} catch (e: Exception) {
|
|
1396
|
-
Logger.warning("[Rejourney] Final upload failed: ${e.message}")
|
|
1397
|
-
false
|
|
1398
|
-
}
|
|
1399
|
-
Logger.debug("[Rejourney] endSessionSynchronous: Upload result=$uploadSuccess")
|
|
1300
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Skipping synchronous upload - relying on EventBuffer and UploadWorker recovery")
|
|
1301
|
+
|
|
1302
|
+
val uploadSuccess = true
|
|
1303
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Upload result=SKIPPED (persisted)")
|
|
1400
1304
|
|
|
1401
|
-
// Send session end signal - THIS IS THE CRITICAL /session/end CALL
|
|
1402
1305
|
if (!sessionEndSent) {
|
|
1403
1306
|
sessionEndSent = true
|
|
1404
|
-
Logger.debug("[Rejourney] endSessionSynchronous:
|
|
1307
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Skipping /session/end - UploadWorker will handle recovery (sessionId=$sessionId)")
|
|
1405
1308
|
|
|
1406
|
-
// CRITICAL: Ensure uploadManager has the correct sessionId
|
|
1407
|
-
// Prior handleAppBackground may have cleared it, so we restore it here
|
|
1408
|
-
if (sessionId.isNotEmpty()) {
|
|
1409
|
-
uploadManager?.sessionId = sessionId
|
|
1410
|
-
}
|
|
1411
1309
|
|
|
1412
|
-
val endSuccess =
|
|
1413
|
-
|
|
1414
|
-
} catch (e: Exception) {
|
|
1415
|
-
Logger.warning("[Rejourney] Session end API call failed: ${e.message}")
|
|
1416
|
-
false
|
|
1417
|
-
}
|
|
1418
|
-
Logger.debug("[Rejourney] endSessionSynchronous: /session/end result=$endSuccess")
|
|
1310
|
+
val endSuccess = true
|
|
1311
|
+
Logger.debug("[Rejourney] endSessionSynchronous: /session/end result=SKIPPED (recovery)")
|
|
1419
1312
|
|
|
1420
|
-
// Clear recovery markers if successful
|
|
1421
|
-
if (endSuccess) {
|
|
1422
|
-
try {
|
|
1423
|
-
uploadManager?.clearSessionRecovery(sessionId)
|
|
1424
|
-
reactContext.getSharedPreferences("rejourney", 0)
|
|
1425
|
-
.edit()
|
|
1426
|
-
.putLong("rj_session_end_time_$sessionId", System.currentTimeMillis())
|
|
1427
|
-
.remove("rj_current_session_id")
|
|
1428
|
-
.remove("rj_session_start_time")
|
|
1429
|
-
.apply()
|
|
1430
|
-
} catch (e: Exception) {
|
|
1431
|
-
Logger.warning("[Rejourney] Clear recovery failed: ${e.message}")
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
1313
|
}
|
|
1435
1314
|
|
|
1436
|
-
|
|
1315
|
+
|
|
1316
|
+
|
|
1437
1317
|
isRecording = false
|
|
1438
1318
|
currentSessionId = null
|
|
1439
1319
|
userId = null
|
|
@@ -1459,13 +1339,11 @@ class RejourneyModuleImpl(
|
|
|
1459
1339
|
return
|
|
1460
1340
|
}
|
|
1461
1341
|
|
|
1462
|
-
// Use saved config from previous session
|
|
1463
1342
|
val safeUserId = userId ?: "anonymous"
|
|
1464
1343
|
val safeApiUrl = savedApiUrl.ifEmpty { "https://api.rejourney.co" }
|
|
1465
1344
|
val safePublicKey = savedPublicKey.ifEmpty { "" }
|
|
1466
1345
|
val deviceHash = savedDeviceHash
|
|
1467
1346
|
|
|
1468
|
-
// Setup session
|
|
1469
1347
|
this.userId = safeUserId
|
|
1470
1348
|
currentSessionId = sessionId
|
|
1471
1349
|
sessionStartTime = System.currentTimeMillis()
|
|
@@ -1478,41 +1356,34 @@ class RejourneyModuleImpl(
|
|
|
1478
1356
|
updateRecordingEligibility(projectSampleRate)
|
|
1479
1357
|
}
|
|
1480
1358
|
|
|
1481
|
-
// Save session ID for crash handler
|
|
1482
1359
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
1483
1360
|
.edit()
|
|
1484
1361
|
.putString("rj_current_session_id", currentSessionId)
|
|
1485
1362
|
.apply()
|
|
1486
1363
|
|
|
1487
|
-
// Configure upload manager
|
|
1488
1364
|
uploadManager?.apply {
|
|
1489
1365
|
this.apiUrl = safeApiUrl
|
|
1490
1366
|
this.publicKey = safePublicKey
|
|
1491
1367
|
this.deviceHash = deviceHash
|
|
1492
|
-
// NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
|
|
1493
1368
|
setActiveSessionId(currentSessionId!!)
|
|
1494
1369
|
this.userId = safeUserId
|
|
1495
1370
|
this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
|
|
1496
1371
|
resetForNewSession()
|
|
1497
1372
|
}
|
|
1498
1373
|
|
|
1499
|
-
// Mark session active
|
|
1500
1374
|
currentSessionId?.let { sid ->
|
|
1501
1375
|
uploadManager?.markSessionActive(sid, sessionStartTime)
|
|
1502
1376
|
}
|
|
1503
1377
|
|
|
1504
|
-
// Initialize event buffer
|
|
1505
1378
|
val pendingDir = File(reactContext.cacheDir, "rj_pending")
|
|
1506
1379
|
currentSessionId?.let { sid ->
|
|
1507
1380
|
eventBuffer = EventBuffer(reactContext, sid, pendingDir)
|
|
1508
1381
|
}
|
|
1509
1382
|
|
|
1510
|
-
// Start capture
|
|
1511
1383
|
if (remoteRecordingEnabled) {
|
|
1512
1384
|
captureEngine?.startSession(currentSessionId!!)
|
|
1513
1385
|
}
|
|
1514
1386
|
|
|
1515
|
-
// Enable tracking
|
|
1516
1387
|
touchInterceptor?.enableGlobalTracking()
|
|
1517
1388
|
keyboardTracker?.startTracking()
|
|
1518
1389
|
textInputTracker?.startTracking()
|
|
@@ -1585,19 +1456,11 @@ class RejourneyModuleImpl(
|
|
|
1585
1456
|
if (success) {
|
|
1586
1457
|
Logger.debug("Device registered: $credentialId")
|
|
1587
1458
|
|
|
1588
|
-
// Auth succeeded - reset retry state
|
|
1589
1459
|
resetAuthRetryState()
|
|
1590
1460
|
|
|
1591
|
-
// Get upload token
|
|
1592
1461
|
deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
|
|
1593
1462
|
if (tokenSuccess) {
|
|
1594
|
-
// NOTE: Session recovery is now handled EXCLUSIVELY by WorkManager
|
|
1595
|
-
// The old recoverPendingSessions() approach held a mutex that blocked
|
|
1596
|
-
// all current session uploads. WorkManager.scheduleRecoveryUpload()
|
|
1597
|
-
// runs independently without blocking the current session.
|
|
1598
|
-
// Recovery is already scheduled in onLifecycleStart via UploadWorker.scheduleRecoveryUpload()
|
|
1599
1463
|
|
|
1600
|
-
// Check for pending crash reports
|
|
1601
1464
|
val crashHandler = CrashHandler.getInstance(reactContext)
|
|
1602
1465
|
if (crashHandler.hasPendingCrashReport()) {
|
|
1603
1466
|
crashHandler.loadAndPurgePendingCrashReport()?.let { crashReport ->
|
|
@@ -1607,7 +1470,6 @@ class RejourneyModuleImpl(
|
|
|
1607
1470
|
}
|
|
1608
1471
|
}
|
|
1609
1472
|
|
|
1610
|
-
// Check for pending ANR reports
|
|
1611
1473
|
val anrHandler = ANRHandler.getInstance(reactContext)
|
|
1612
1474
|
if (anrHandler.hasPendingANRReport()) {
|
|
1613
1475
|
anrHandler.loadAndPurgePendingANRReport()?.let { anrReport ->
|
|
@@ -1630,18 +1492,13 @@ class RejourneyModuleImpl(
|
|
|
1630
1492
|
}
|
|
1631
1493
|
}
|
|
1632
1494
|
|
|
1633
|
-
// ==================== Activity Lifecycle Callbacks ====================
|
|
1634
|
-
// Note: We prioritize ProcessLifecycleOwner for foreground/background,
|
|
1635
|
-
// but onActivityStopped is critical for immediate background detection.
|
|
1636
1495
|
|
|
1637
1496
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
|
1638
1497
|
|
|
1639
1498
|
override fun onActivityResumed(activity: Activity) {
|
|
1640
|
-
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1641
1499
|
try {
|
|
1642
1500
|
Logger.debug("Activity resumed")
|
|
1643
1501
|
cancelScheduledBackground()
|
|
1644
|
-
// Backup foreground detection - ProcessLifecycleOwner may not always fire
|
|
1645
1502
|
if (wasInBackground) {
|
|
1646
1503
|
handleAppForeground("Activity.onResume")
|
|
1647
1504
|
}
|
|
@@ -1651,11 +1508,9 @@ class RejourneyModuleImpl(
|
|
|
1651
1508
|
}
|
|
1652
1509
|
|
|
1653
1510
|
override fun onActivityPaused(activity: Activity) {
|
|
1654
|
-
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1655
1511
|
try {
|
|
1656
1512
|
Logger.debug("Activity paused (isFinishing=${activity.isFinishing})")
|
|
1657
1513
|
|
|
1658
|
-
// Force capture immediately in case app is killed from recents
|
|
1659
1514
|
if (remoteRecordingEnabled) {
|
|
1660
1515
|
try {
|
|
1661
1516
|
captureEngine?.forceCaptureWithReason("app_pausing")
|
|
@@ -1664,20 +1519,10 @@ class RejourneyModuleImpl(
|
|
|
1664
1519
|
}
|
|
1665
1520
|
}
|
|
1666
1521
|
|
|
1667
|
-
// LIGHTWEIGHT BACKGROUND PREP for recents detection
|
|
1668
|
-
// DO NOT call full handleAppBackground here - that stops the capture engine
|
|
1669
|
-
// which causes VideoEncoder race conditions (IllegalStateException: dequeue pending)
|
|
1670
|
-
//
|
|
1671
|
-
// Instead, we:
|
|
1672
|
-
// 1. Set backgroundEntryTime (for 60s timeout calculation)
|
|
1673
|
-
// 2. Flush events to disk (so they're persisted if user swipes to kill)
|
|
1674
|
-
//
|
|
1675
|
-
// Full background handling (stopping capture engine) happens in onActivityStopped/onActivityDestroyed
|
|
1676
1522
|
if (!wasInBackground && isRecording) {
|
|
1677
1523
|
Logger.debug("[BG] Activity.onPause: Setting background entry time (capture engine still running)")
|
|
1678
1524
|
backgroundEntryTime = System.currentTimeMillis()
|
|
1679
1525
|
|
|
1680
|
-
// Flush events to disk asynchronously
|
|
1681
1526
|
eventBuffer?.flush()
|
|
1682
1527
|
Logger.debug("[BG] Activity.onPause: Events flushed to disk, backgroundEntryTime=$backgroundEntryTime")
|
|
1683
1528
|
}
|
|
@@ -1688,10 +1533,8 @@ class RejourneyModuleImpl(
|
|
|
1688
1533
|
|
|
1689
1534
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
|
1690
1535
|
|
|
1691
|
-
// ==================== DefaultLifecycleObserver (ProcessLifecycleOwner) ====================
|
|
1692
1536
|
|
|
1693
1537
|
override fun onStart(owner: LifecycleOwner) {
|
|
1694
|
-
// Backup: if Activity callbacks failed/missed, this catches the app start
|
|
1695
1538
|
try {
|
|
1696
1539
|
Logger.debug("ProcessLifecycleOwner: onStart")
|
|
1697
1540
|
cancelScheduledBackground()
|
|
@@ -1704,12 +1547,9 @@ class RejourneyModuleImpl(
|
|
|
1704
1547
|
}
|
|
1705
1548
|
|
|
1706
1549
|
override fun onStop(owner: LifecycleOwner) {
|
|
1707
|
-
// Backup: catch app background if Activity callbacks missed it
|
|
1708
1550
|
try {
|
|
1709
1551
|
Logger.debug("ProcessLifecycleOwner: onStop")
|
|
1710
1552
|
if (isRecording && !wasInBackground) {
|
|
1711
|
-
// If we're recording and haven't detected background yet, do it now
|
|
1712
|
-
// ProcessLifecycleOwner is already debounced by AndroidX (700ms), so no extra delay needed
|
|
1713
1553
|
handleAppBackground("ProcessLifecycle.onStop")
|
|
1714
1554
|
}
|
|
1715
1555
|
} catch (e: Exception) {
|
|
@@ -1717,10 +1557,8 @@ class RejourneyModuleImpl(
|
|
|
1717
1557
|
}
|
|
1718
1558
|
}
|
|
1719
1559
|
|
|
1720
|
-
// ==================== ActivityLifecycleCallbacks (Backup/Early Detection) ====================
|
|
1721
1560
|
|
|
1722
1561
|
override fun onActivityStarted(activity: Activity) {
|
|
1723
|
-
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1724
1562
|
try {
|
|
1725
1563
|
Logger.debug("Activity started")
|
|
1726
1564
|
cancelScheduledBackground()
|
|
@@ -1733,7 +1571,6 @@ class RejourneyModuleImpl(
|
|
|
1733
1571
|
}
|
|
1734
1572
|
|
|
1735
1573
|
override fun onActivityStopped(activity: Activity) {
|
|
1736
|
-
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1737
1574
|
try {
|
|
1738
1575
|
if (activity.isChangingConfigurations) {
|
|
1739
1576
|
Logger.debug("Activity stopped but changing configurations - skipping background")
|
|
@@ -1741,14 +1578,10 @@ class RejourneyModuleImpl(
|
|
|
1741
1578
|
}
|
|
1742
1579
|
|
|
1743
1580
|
if (activity.isFinishing) {
|
|
1744
|
-
// App is closing/killed - IMMEDIATE background handling + FORCE SESSION END
|
|
1745
|
-
// Do not use debounce (scheduleBackground) because app kills (swipes) can terminate process instantly
|
|
1746
1581
|
Logger.debug("Activity stopped and finishing - triggering IMMEDIATE background and ENDING SESSION")
|
|
1747
1582
|
cancelScheduledBackground()
|
|
1748
1583
|
handleAppBackground("Activity.onStop:finishing", shouldEndSession = true)
|
|
1749
1584
|
} else {
|
|
1750
|
-
// Normal background - immediate handling (no debounce needed for single activity)
|
|
1751
|
-
// BUT do NOT end session (just flush)
|
|
1752
1585
|
Logger.debug("Activity stopped - triggering IMMEDIATE background")
|
|
1753
1586
|
cancelScheduledBackground()
|
|
1754
1587
|
handleAppBackground("Activity.onStop", shouldEndSession = false)
|
|
@@ -1760,12 +1593,9 @@ class RejourneyModuleImpl(
|
|
|
1760
1593
|
}
|
|
1761
1594
|
|
|
1762
1595
|
override fun onActivityDestroyed(activity: Activity) {
|
|
1763
|
-
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1764
1596
|
try {
|
|
1765
1597
|
if (activity.isChangingConfigurations) return
|
|
1766
1598
|
|
|
1767
|
-
// Redundant backup: ensure background triggered if somehow missed in onStop
|
|
1768
|
-
// FORCE SESSION END
|
|
1769
1599
|
Logger.debug("Activity destroyed (isFinishing=${activity.isFinishing}) - triggering IMMEDIATE background")
|
|
1770
1600
|
handleAppBackground("Activity.onDestroy", shouldEndSession = true)
|
|
1771
1601
|
|
|
@@ -1777,8 +1607,6 @@ class RejourneyModuleImpl(
|
|
|
1777
1607
|
private fun scheduleBackground(source: String) {
|
|
1778
1608
|
if (wasInBackground || backgroundScheduled) return
|
|
1779
1609
|
|
|
1780
|
-
// NOTE: This method is now kept mainly for onPause if we decide to use it,
|
|
1781
|
-
// or for legacy debounce logic. Currently onActivityStopped uses immediate handling.
|
|
1782
1610
|
backgroundScheduled = true
|
|
1783
1611
|
backgroundEntryTime = System.currentTimeMillis()
|
|
1784
1612
|
|
|
@@ -1790,7 +1618,7 @@ class RejourneyModuleImpl(
|
|
|
1790
1618
|
}
|
|
1791
1619
|
|
|
1792
1620
|
scheduledBackgroundRunnable = runnable
|
|
1793
|
-
mainHandler.postDelayed(runnable, 50L)
|
|
1621
|
+
mainHandler.postDelayed(runnable, 50L)
|
|
1794
1622
|
}
|
|
1795
1623
|
|
|
1796
1624
|
private fun cancelScheduledBackground() {
|
|
@@ -1827,16 +1655,13 @@ class RejourneyModuleImpl(
|
|
|
1827
1655
|
Logger.debug("[FG] Session timeout threshold: ${thresholdSec}s")
|
|
1828
1656
|
Logger.debug("[FG] Current totalBackgroundTimeMs: $totalBackgroundTimeMs")
|
|
1829
1657
|
|
|
1830
|
-
// Reset background tracking state immediately (like iOS)
|
|
1831
1658
|
wasInBackground = false
|
|
1832
1659
|
backgroundEntryTime = 0
|
|
1833
1660
|
|
|
1834
1661
|
if (bgDurationMs >= sessionTimeoutMs) {
|
|
1835
|
-
// === TIMEOUT CASE: End old session, start new one ===
|
|
1836
1662
|
Logger.debug("[FG] TIMEOUT: ${bgDurationSec}s >= ${thresholdSec}s → Creating NEW session")
|
|
1837
1663
|
handleSessionTimeoutOnForeground(bgDurationMs, source)
|
|
1838
1664
|
} else {
|
|
1839
|
-
// === SHORT BACKGROUND: Resume same session ===
|
|
1840
1665
|
Logger.debug("[FG] SHORT BACKGROUND: ${bgDurationSec}s < ${thresholdSec}s → Resuming SAME session")
|
|
1841
1666
|
handleShortBackgroundResume(bgDurationMs, source)
|
|
1842
1667
|
}
|
|
@@ -1860,11 +1685,9 @@ class RejourneyModuleImpl(
|
|
|
1860
1685
|
|
|
1861
1686
|
Logger.debug("SESSION TIMEOUT: Ending session $oldSessionId after ${bgDurationMs/1000}s in background")
|
|
1862
1687
|
|
|
1863
|
-
// Add final background time to accumulated total before ending
|
|
1864
1688
|
totalBackgroundTimeMs += bgDurationMs
|
|
1865
1689
|
uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
|
|
1866
1690
|
|
|
1867
|
-
// Stop all capture/tracking immediately (synchronous, like iOS)
|
|
1868
1691
|
try {
|
|
1869
1692
|
stopBatchUploadTimer()
|
|
1870
1693
|
stopDurationLimitTimer()
|
|
@@ -1876,25 +1699,17 @@ class RejourneyModuleImpl(
|
|
|
1876
1699
|
Logger.warning("Error stopping capture during session timeout: ${e.message}")
|
|
1877
1700
|
}
|
|
1878
1701
|
|
|
1879
|
-
// Mark as not recording to prevent race conditions
|
|
1880
1702
|
isRecording = false
|
|
1881
1703
|
|
|
1882
|
-
// Handle old session end and new session start asynchronously using backgroundScope
|
|
1883
|
-
// which survives independently of the main scope and won't be cancelled on background
|
|
1884
1704
|
backgroundScope.launch {
|
|
1885
|
-
// Use NonCancellable context to ensure critical recovery operations complete
|
|
1886
|
-
// even if the coroutine is cancelled (app goes to background again)
|
|
1887
1705
|
withContext(NonCancellable) {
|
|
1888
1706
|
try {
|
|
1889
|
-
// CRITICAL: Ensure auth token is valid before uploading
|
|
1890
|
-
// Token may have expired during the 60+ seconds in background
|
|
1891
1707
|
try {
|
|
1892
1708
|
DeviceAuthManager.getInstance(reactContext).ensureValidToken()
|
|
1893
1709
|
} catch (e: Exception) {
|
|
1894
1710
|
Logger.warning("Failed to refresh auth token during session timeout: ${e.message}")
|
|
1895
1711
|
}
|
|
1896
1712
|
|
|
1897
|
-
// Add session_timeout event to old session's events
|
|
1898
1713
|
val timeoutEvent = mapOf(
|
|
1899
1714
|
"type" to EventType.SESSION_TIMEOUT,
|
|
1900
1715
|
"timestamp" to System.currentTimeMillis(),
|
|
@@ -1904,7 +1719,6 @@ class RejourneyModuleImpl(
|
|
|
1904
1719
|
)
|
|
1905
1720
|
sessionEvents.add(timeoutEvent)
|
|
1906
1721
|
|
|
1907
|
-
// Upload old session's events as final and call session/end
|
|
1908
1722
|
val finalEvents = sessionEvents.toList()
|
|
1909
1723
|
sessionEvents.clear()
|
|
1910
1724
|
|
|
@@ -1916,8 +1730,6 @@ class RejourneyModuleImpl(
|
|
|
1916
1730
|
}
|
|
1917
1731
|
}
|
|
1918
1732
|
|
|
1919
|
-
// End the old session (calls /session/end which triggers promotion)
|
|
1920
|
-
// CRITICAL: Pass oldSessionId explicitly since uploadManager.sessionId may be reset
|
|
1921
1733
|
var endSessionSuccess = false
|
|
1922
1734
|
if (!sessionEndSent) {
|
|
1923
1735
|
sessionEndSent = true
|
|
@@ -1928,7 +1740,6 @@ class RejourneyModuleImpl(
|
|
|
1928
1740
|
}
|
|
1929
1741
|
}
|
|
1930
1742
|
|
|
1931
|
-
// Clear recovery markers for old session
|
|
1932
1743
|
try {
|
|
1933
1744
|
uploadManager?.clearSessionRecovery(oldSessionId)
|
|
1934
1745
|
} catch (e: Exception) {
|
|
@@ -1941,53 +1752,39 @@ class RejourneyModuleImpl(
|
|
|
1941
1752
|
Logger.warning("Old session $oldSessionId end signal failed - will be recovered on next launch")
|
|
1942
1753
|
}
|
|
1943
1754
|
|
|
1944
|
-
// === START NEW SESSION ===
|
|
1945
1755
|
val timestamp = System.currentTimeMillis()
|
|
1946
1756
|
val shortUuid = UUID.randomUUID().toString().take(8).uppercase()
|
|
1947
1757
|
val newSessionId = "session_${timestamp}_$shortUuid"
|
|
1948
1758
|
|
|
1949
|
-
// Reset state for new session
|
|
1950
1759
|
currentSessionId = newSessionId
|
|
1951
1760
|
sessionStartTime = timestamp
|
|
1952
1761
|
totalBackgroundTimeMs = 0
|
|
1953
1762
|
sessionEndSent = false
|
|
1954
1763
|
|
|
1955
|
-
// Reset upload manager for new session
|
|
1956
1764
|
uploadManager?.let { um ->
|
|
1957
|
-
// NUCLEAR FIX: Use setActiveSessionId() to update both sessionId AND activeSessionId
|
|
1958
|
-
// This ensures the new session doesn't get merged into the old one's recovery path
|
|
1959
1765
|
um.setActiveSessionId(newSessionId)
|
|
1960
1766
|
|
|
1961
1767
|
um.sessionStartTime = timestamp
|
|
1962
1768
|
um.totalBackgroundTimeMs = 0
|
|
1963
1769
|
|
|
1964
|
-
// FIX: Synchronize user identity and config to UploadManager
|
|
1965
|
-
// This matches iOS behavior and ensures robustness if memory was cleared
|
|
1966
1770
|
um.userId = userId ?: "anonymous"
|
|
1967
1771
|
|
|
1968
|
-
// Restore saved config if available
|
|
1969
1772
|
if (savedDeviceHash.isNotEmpty()) um.deviceHash = savedDeviceHash
|
|
1970
1773
|
if (savedPublicKey.isNotEmpty()) um.publicKey = savedPublicKey
|
|
1971
1774
|
if (savedApiUrl.isNotEmpty()) um.apiUrl = savedApiUrl
|
|
1972
1775
|
|
|
1973
|
-
// CRITICAL: Create session metadata file for crash recovery
|
|
1974
1776
|
um.markSessionActive(newSessionId, timestamp)
|
|
1975
1777
|
}
|
|
1976
1778
|
|
|
1977
|
-
// CRITICAL: Save new session ID to SharedPreferences for unclosed session detection
|
|
1978
|
-
// Use commit() instead of apply() to ensure synchronous write before app kill
|
|
1979
1779
|
reactContext.getSharedPreferences("rejourney", 0)
|
|
1980
1780
|
.edit()
|
|
1981
1781
|
.putString("rj_current_session_id", newSessionId)
|
|
1982
1782
|
.putLong("rj_session_start_time", timestamp)
|
|
1983
1783
|
.commit()
|
|
1984
1784
|
|
|
1985
|
-
// CRITICAL: Re-initialize EventBuffer for new session
|
|
1986
|
-
// Without this, events are written to wrong session's file
|
|
1987
1785
|
val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
|
|
1988
1786
|
eventBuffer = EventBuffer(reactContext, newSessionId, pendingDir)
|
|
1989
1787
|
|
|
1990
|
-
// Start capture for new session (run on main thread for UI safety)
|
|
1991
1788
|
withContext(Dispatchers.Main) {
|
|
1992
1789
|
try {
|
|
1993
1790
|
resetSamplingDecision()
|
|
@@ -2000,14 +1797,10 @@ class RejourneyModuleImpl(
|
|
|
2000
1797
|
captureEngine?.startSession(newSessionId)
|
|
2001
1798
|
}
|
|
2002
1799
|
|
|
2003
|
-
// Re-enable tracking
|
|
2004
1800
|
touchInterceptor?.enableGlobalTracking()
|
|
2005
1801
|
keyboardTracker?.startTracking()
|
|
2006
1802
|
textInputTracker?.startTracking()
|
|
2007
1803
|
|
|
2008
|
-
// CRITICAL: Restart SessionLifecycleService for the new session
|
|
2009
|
-
// The system destroys it after ~60 seconds in background
|
|
2010
|
-
// Without this, onTaskRemoved won't be called and session won't end properly
|
|
2011
1804
|
try {
|
|
2012
1805
|
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
2013
1806
|
reactContext.startService(serviceIntent)
|
|
@@ -2022,7 +1815,6 @@ class RejourneyModuleImpl(
|
|
|
2022
1815
|
|
|
2023
1816
|
isRecording = true
|
|
2024
1817
|
|
|
2025
|
-
// Add session_start event for new session
|
|
2026
1818
|
val sessionStartEvent = mapOf(
|
|
2027
1819
|
"type" to EventType.SESSION_START,
|
|
2028
1820
|
"timestamp" to System.currentTimeMillis(),
|
|
@@ -2033,11 +1825,9 @@ class RejourneyModuleImpl(
|
|
|
2033
1825
|
)
|
|
2034
1826
|
addEventWithPersistence(sessionStartEvent)
|
|
2035
1827
|
|
|
2036
|
-
// Start timers for new session
|
|
2037
1828
|
startBatchUploadTimer()
|
|
2038
1829
|
startDurationLimitTimer()
|
|
2039
1830
|
|
|
2040
|
-
// Trigger immediate upload to register new session
|
|
2041
1831
|
delay(100)
|
|
2042
1832
|
try {
|
|
2043
1833
|
performBatchUpload()
|
|
@@ -2048,15 +1838,11 @@ class RejourneyModuleImpl(
|
|
|
2048
1838
|
Logger.debug("New session $newSessionId started (previous: $oldSessionId)")
|
|
2049
1839
|
|
|
2050
1840
|
} catch (e: CancellationException) {
|
|
2051
|
-
// Coroutine was cancelled but NonCancellable should prevent this
|
|
2052
|
-
// Log as warning, not error - this is expected if app is killed
|
|
2053
1841
|
Logger.warning("Session timeout recovery interrupted: ${e.message}")
|
|
2054
|
-
// Ensure recording state is consistent
|
|
2055
1842
|
isRecording = true
|
|
2056
1843
|
startBatchUploadTimer()
|
|
2057
1844
|
} catch (e: Exception) {
|
|
2058
1845
|
Logger.error("Failed to handle session timeout", e)
|
|
2059
|
-
// Attempt recovery - restart recording
|
|
2060
1846
|
isRecording = true
|
|
2061
1847
|
startBatchUploadTimer()
|
|
2062
1848
|
}
|
|
@@ -2068,7 +1854,6 @@ class RejourneyModuleImpl(
|
|
|
2068
1854
|
* Handle short background return (< 60s) - resume same session.
|
|
2069
1855
|
*/
|
|
2070
1856
|
private fun handleShortBackgroundResume(bgDurationMs: Long, source: String) {
|
|
2071
|
-
// Accumulate background time for billing exclusion
|
|
2072
1857
|
val previousBgTime = totalBackgroundTimeMs
|
|
2073
1858
|
totalBackgroundTimeMs += bgDurationMs
|
|
2074
1859
|
uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
|
|
@@ -2084,7 +1869,6 @@ class RejourneyModuleImpl(
|
|
|
2084
1869
|
return
|
|
2085
1870
|
}
|
|
2086
1871
|
|
|
2087
|
-
// Log foreground event for replay player
|
|
2088
1872
|
addEventWithPersistence(
|
|
2089
1873
|
mapOf(
|
|
2090
1874
|
"type" to EventType.APP_FOREGROUND,
|
|
@@ -2095,7 +1879,6 @@ class RejourneyModuleImpl(
|
|
|
2095
1879
|
)
|
|
2096
1880
|
)
|
|
2097
1881
|
|
|
2098
|
-
// Resume capture and tracking
|
|
2099
1882
|
if (remoteRecordingEnabled) {
|
|
2100
1883
|
try {
|
|
2101
1884
|
captureEngine?.startSession(currentSessionId!!)
|
|
@@ -2125,7 +1908,6 @@ class RejourneyModuleImpl(
|
|
|
2125
1908
|
* upload remaining pending data and close the session via session/end.
|
|
2126
1909
|
*/
|
|
2127
1910
|
private fun handleAppBackground(source: String, shouldEndSession: Boolean = false) {
|
|
2128
|
-
// Prevent duplicate background handling (unless forcing end)
|
|
2129
1911
|
if (wasInBackground && !shouldEndSession) {
|
|
2130
1912
|
Logger.debug("[BG] Already in background, skipping duplicate handling")
|
|
2131
1913
|
return
|
|
@@ -2136,42 +1918,33 @@ class RejourneyModuleImpl(
|
|
|
2136
1918
|
|
|
2137
1919
|
if (isRecording && !isShuttingDown) {
|
|
2138
1920
|
wasInBackground = true
|
|
2139
|
-
// backgroundEntryTime is already set by debounce scheduling
|
|
2140
1921
|
if (backgroundEntryTime == 0L) {
|
|
2141
1922
|
backgroundEntryTime = System.currentTimeMillis()
|
|
2142
1923
|
}
|
|
2143
1924
|
Logger.debug("[BG] backgroundEntryTime set to $backgroundEntryTime")
|
|
2144
1925
|
Logger.debug("[BG] Current totalBackgroundTimeMs=$totalBackgroundTimeMs")
|
|
2145
1926
|
|
|
2146
|
-
// Stop timers (but don't cancel in-progress uploads)
|
|
2147
1927
|
stopBatchUploadTimer()
|
|
2148
1928
|
stopDurationLimitTimer()
|
|
2149
1929
|
|
|
2150
|
-
// Stop tracking
|
|
2151
1930
|
keyboardTracker?.stopTracking()
|
|
2152
1931
|
textInputTracker?.stopTracking()
|
|
2153
1932
|
touchInterceptor?.disableGlobalTracking()
|
|
2154
1933
|
|
|
2155
|
-
// Add background event
|
|
2156
1934
|
val event = mapOf(
|
|
2157
1935
|
"type" to EventType.APP_BACKGROUND,
|
|
2158
1936
|
"timestamp" to System.currentTimeMillis()
|
|
2159
1937
|
)
|
|
2160
1938
|
addEventWithPersistence(event)
|
|
2161
1939
|
|
|
2162
|
-
// CRITICAL: Ensure all in-memory events are written to disk before scheduling upload
|
|
2163
|
-
// EventBuffer uses async writes, so we need to flush to ensure all writes complete
|
|
2164
1940
|
Logger.debug("[BG] ===== ENSURING ALL EVENTS ARE PERSISTED TO DISK =====")
|
|
2165
1941
|
Logger.debug("[BG] In-memory events count: ${sessionEvents.size}")
|
|
2166
1942
|
Logger.debug("[BG] Event types in memory: ${sessionEvents.map { it["type"] }.joinToString(", ")}")
|
|
2167
1943
|
|
|
2168
|
-
// Log event buffer state before flush
|
|
2169
1944
|
eventBuffer?.let { buffer ->
|
|
2170
1945
|
Logger.debug("[BG] EventBuffer state: eventCount=${buffer.eventCount}, fileExists=${File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl").exists()}")
|
|
2171
1946
|
} ?: Logger.warning("[BG] EventBuffer is NULL - cannot flush events!")
|
|
2172
1947
|
|
|
2173
|
-
// Flush all pending writes to disk
|
|
2174
|
-
// This drains the async write queue and ensures all events are on disk
|
|
2175
1948
|
val flushStartTime = System.currentTimeMillis()
|
|
2176
1949
|
val flushSuccess = eventBuffer?.flush() ?: false
|
|
2177
1950
|
val flushDuration = System.currentTimeMillis() - flushStartTime
|
|
@@ -2180,7 +1953,6 @@ class RejourneyModuleImpl(
|
|
|
2180
1953
|
Logger.debug("[BG] ✅ Events flushed to disk successfully in ${flushDuration}ms")
|
|
2181
1954
|
Logger.debug("[BG] In-memory events: ${sessionEvents.size}, EventBuffer eventCount: ${eventBuffer?.eventCount ?: 0}")
|
|
2182
1955
|
|
|
2183
|
-
// Verify file exists and has content
|
|
2184
1956
|
val eventsFile = File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl")
|
|
2185
1957
|
if (eventsFile.exists()) {
|
|
2186
1958
|
val fileSize = eventsFile.length()
|
|
@@ -2194,15 +1966,12 @@ class RejourneyModuleImpl(
|
|
|
2194
1966
|
}
|
|
2195
1967
|
Logger.debug("[BG] ===== EVENT PERSISTENCE CHECK COMPLETE =====")
|
|
2196
1968
|
|
|
2197
|
-
// Stop capture engine while backgrounded (triggers final segment flush and hierarchy upload)
|
|
2198
1969
|
if (remoteRecordingEnabled) {
|
|
2199
1970
|
Logger.debug("[BG] ===== STOPPING CAPTURE ENGINE =====")
|
|
2200
1971
|
|
|
2201
1972
|
if (shouldEndSession) {
|
|
2202
|
-
// Kill scenario: use emergency flush to save crash metadata and stop ASAP
|
|
2203
1973
|
Logger.debug("[BG] Force kill detected - using emergency flush")
|
|
2204
1974
|
captureEngine?.emergencyFlush()
|
|
2205
|
-
// Continue to stopSession for hierarchy upload and other cleanup
|
|
2206
1975
|
}
|
|
2207
1976
|
|
|
2208
1977
|
Logger.debug("[BG] Stopping capture engine for background (sessionId=$currentSessionId)")
|
|
@@ -2210,62 +1979,45 @@ class RejourneyModuleImpl(
|
|
|
2210
1979
|
Logger.debug("[BG] Capture engine stopSession() called")
|
|
2211
1980
|
}
|
|
2212
1981
|
|
|
2213
|
-
// Update session recovery meta so WorkManager can find the session
|
|
2214
1982
|
currentSessionId?.let { sid ->
|
|
2215
1983
|
uploadManager?.updateSessionRecoveryMeta(sid)
|
|
2216
1984
|
Logger.debug("[BG] Session recovery metadata updated for: $sid")
|
|
2217
1985
|
}
|
|
2218
1986
|
|
|
2219
|
-
// ===== SIMPLIFIED UPLOAD: WorkManager Only =====
|
|
2220
|
-
// Industry-standard approach: persist first (done above), then schedule background worker.
|
|
2221
|
-
// NO synchronous uploads - they cause ANRs and mutex contention with recovery.
|
|
2222
|
-
// WorkManager is reliable for background uploads when properly configured.
|
|
2223
1987
|
currentSessionId?.let { sid ->
|
|
2224
1988
|
Logger.debug("[BG] ===== SCHEDULING WORKMANAGER UPLOAD =====")
|
|
2225
1989
|
Logger.debug("[BG] Session: $sid, Events persisted: ${eventBuffer?.eventCount ?: 0}, isFinal: $shouldEndSession")
|
|
2226
1990
|
|
|
2227
|
-
// Clear in-memory events since they're persisted to disk
|
|
2228
|
-
// WorkManager will read from disk
|
|
2229
1991
|
sessionEvents.clear()
|
|
2230
1992
|
|
|
2231
|
-
// Schedule expedited upload via WorkManager
|
|
2232
1993
|
UploadWorker.scheduleUpload(
|
|
2233
1994
|
context = reactContext,
|
|
2234
1995
|
sessionId = sid,
|
|
2235
|
-
isFinal = shouldEndSession,
|
|
2236
|
-
expedited = true
|
|
1996
|
+
isFinal = shouldEndSession,
|
|
1997
|
+
expedited = true
|
|
2237
1998
|
)
|
|
2238
1999
|
Logger.debug("[BG] ✅ WorkManager upload scheduled for session: $sid")
|
|
2239
2000
|
|
|
2240
|
-
// NEW: Best-effort immediate upload (Fire-and-Forget)
|
|
2241
|
-
// Try to upload immediately while app is still alive in memory.
|
|
2242
|
-
// If this succeeds, WorkManager will find nothing to do (which is fine).
|
|
2243
|
-
// If this fails/gets killed, WorkManager will pick it up.
|
|
2244
|
-
// This mimics iOS "beginBackgroundTask" pattern.
|
|
2245
2001
|
scope.launch(Dispatchers.IO) {
|
|
2246
2002
|
try {
|
|
2247
2003
|
Logger.debug("[BG] 🚀 Attempting immediate best-effort upload for $sid")
|
|
2248
2004
|
|
|
2249
|
-
// Create a temporary UploadManager because the main one's state is complex
|
|
2250
|
-
// We use the same parameters as WorkManager creates
|
|
2251
2005
|
val authManager = DeviceAuthManager.getInstance(reactContext)
|
|
2252
2006
|
val apiUrl = authManager.getCurrentApiUrl() ?: "https://api.rejourney.co"
|
|
2253
2007
|
|
|
2254
2008
|
val bgUploader = com.rejourney.network.UploadManager(reactContext, apiUrl).apply {
|
|
2255
2009
|
this.sessionId = sid
|
|
2256
|
-
this.setActiveSessionId(sid)
|
|
2010
|
+
this.setActiveSessionId(sid)
|
|
2257
2011
|
this.publicKey = authManager.getCurrentPublicKey() ?: ""
|
|
2258
2012
|
this.deviceHash = authManager.getCurrentDeviceHash() ?: ""
|
|
2259
2013
|
this.sessionStartTime = uploadManager?.sessionStartTime ?: 0L
|
|
2260
2014
|
this.totalBackgroundTimeMs = uploadManager?.totalBackgroundTimeMs ?: 0L
|
|
2261
2015
|
}
|
|
2262
2016
|
|
|
2263
|
-
// Read events from disk since we flushed them
|
|
2264
2017
|
val eventBufferDir = File(reactContext.cacheDir, "rj_pending/$sid")
|
|
2265
2018
|
val eventsFile = File(eventBufferDir, "events.jsonl")
|
|
2266
2019
|
|
|
2267
2020
|
if (eventsFile.exists()) {
|
|
2268
|
-
// Read events - duplicated logic from UploadWorker but necessary for successful off-main-thread upload
|
|
2269
2021
|
val events = mutableListOf<Map<String, Any?>>()
|
|
2270
2022
|
eventsFile.bufferedReader().useLines { lines ->
|
|
2271
2023
|
lines.forEach { line ->
|
|
@@ -2287,11 +2039,9 @@ class RejourneyModuleImpl(
|
|
|
2287
2039
|
val success = bgUploader.uploadBatch(events, isFinal = shouldEndSession)
|
|
2288
2040
|
if (success) {
|
|
2289
2041
|
Logger.debug("[BG] ✅ Immediate upload SUCCESS! Cleaning up disk...")
|
|
2290
|
-
// Clean up so WorkManager doesn't re-upload
|
|
2291
2042
|
eventsFile.delete()
|
|
2292
2043
|
File(eventBufferDir, "buffer_meta.json").delete()
|
|
2293
2044
|
|
|
2294
|
-
// IF FINAL, END SESSION IMMEDIATELY
|
|
2295
2045
|
if (shouldEndSession) {
|
|
2296
2046
|
Logger.debug("[BG] Immediate upload was final, ending session...")
|
|
2297
2047
|
bgUploader.endSession()
|
|
@@ -2300,12 +2050,10 @@ class RejourneyModuleImpl(
|
|
|
2300
2050
|
Logger.warning("[BG] Immediate upload failed - leaving for WorkManager")
|
|
2301
2051
|
}
|
|
2302
2052
|
} else if (shouldEndSession) {
|
|
2303
|
-
// Even if no events, if it's final, we should try to end session
|
|
2304
2053
|
Logger.debug("[BG] No events but shouldEndSession=true, ending session...")
|
|
2305
2054
|
bgUploader.endSession()
|
|
2306
2055
|
}
|
|
2307
2056
|
} else if (shouldEndSession) {
|
|
2308
|
-
// Even if no event file, if it's final, we should try to end session
|
|
2309
2057
|
Logger.debug("[BG] No event file but shouldEndSession=true, ending session...")
|
|
2310
2058
|
bgUploader.endSession()
|
|
2311
2059
|
}
|
|
@@ -2313,18 +2061,14 @@ class RejourneyModuleImpl(
|
|
|
2313
2061
|
Logger.error("[BG] Immediate upload error: ${e.message} - WorkManager will handle it")
|
|
2314
2062
|
}
|
|
2315
2063
|
}
|
|
2316
|
-
}
|
|
2064
|
+
}
|
|
2317
2065
|
} else {
|
|
2318
2066
|
Logger.debug("[BG] Skipping background handling (isRecording=$isRecording, isShuttingDown=$isShuttingDown)")
|
|
2319
2067
|
}
|
|
2320
2068
|
}
|
|
2321
2069
|
|
|
2322
|
-
// ==================== TouchInterceptorDelegate ====================
|
|
2323
2070
|
|
|
2324
2071
|
override fun onTouchEvent(event: MotionEvent, gestureType: String?) {
|
|
2325
|
-
// We rely primarily on onGestureRecognized, but can add raw touches if needed.
|
|
2326
|
-
// For now, to match iOS "touches visited", we can treat simple taps here if needed,
|
|
2327
|
-
// but gestureClassifier usually handles it.
|
|
2328
2072
|
}
|
|
2329
2073
|
|
|
2330
2074
|
override fun onGestureRecognized(gestureType: String, x: Float, y: Float, details: Map<String, Any?>) {
|
|
@@ -2333,8 +2077,6 @@ class RejourneyModuleImpl(
|
|
|
2333
2077
|
try {
|
|
2334
2078
|
val timestamp = System.currentTimeMillis()
|
|
2335
2079
|
|
|
2336
|
-
// Build touches array matching iOS format for web player compatibility
|
|
2337
|
-
// Web player filters events without touches array (e.touches.length > 0)
|
|
2338
2080
|
val touchPoint = mapOf(
|
|
2339
2081
|
"x" to x,
|
|
2340
2082
|
"y" to y,
|
|
@@ -2346,15 +2088,14 @@ class RejourneyModuleImpl(
|
|
|
2346
2088
|
"type" to EventType.GESTURE,
|
|
2347
2089
|
"timestamp" to timestamp,
|
|
2348
2090
|
"gestureType" to gestureType,
|
|
2349
|
-
"touches" to listOf(touchPoint),
|
|
2091
|
+
"touches" to listOf(touchPoint),
|
|
2350
2092
|
"duration" to (details["duration"] ?: 0),
|
|
2351
2093
|
"targetLabel" to details["targetLabel"],
|
|
2352
|
-
"x" to x,
|
|
2094
|
+
"x" to x,
|
|
2353
2095
|
"y" to y,
|
|
2354
2096
|
"details" to details
|
|
2355
2097
|
)
|
|
2356
2098
|
|
|
2357
|
-
// Debug logging to verify touch events are captured correctly for web overlay
|
|
2358
2099
|
Logger.debug("[TOUCH] Gesture recorded: type=$gestureType, x=$x, y=$y, touches=${listOf(touchPoint)}")
|
|
2359
2100
|
|
|
2360
2101
|
addEventWithPersistence(eventMap)
|
|
@@ -2405,7 +2146,6 @@ class RejourneyModuleImpl(
|
|
|
2405
2146
|
try {
|
|
2406
2147
|
val timestamp = System.currentTimeMillis()
|
|
2407
2148
|
|
|
2408
|
-
// Build touches array matching iOS format for web player compatibility
|
|
2409
2149
|
val touchPoint = mapOf(
|
|
2410
2150
|
"x" to x,
|
|
2411
2151
|
"y" to y,
|
|
@@ -2414,10 +2154,10 @@ class RejourneyModuleImpl(
|
|
|
2414
2154
|
)
|
|
2415
2155
|
|
|
2416
2156
|
val eventMap = mapOf(
|
|
2417
|
-
"type" to EventType.GESTURE,
|
|
2157
|
+
"type" to EventType.GESTURE,
|
|
2418
2158
|
"timestamp" to timestamp,
|
|
2419
2159
|
"gestureType" to "rage_tap",
|
|
2420
|
-
"touches" to listOf(touchPoint),
|
|
2160
|
+
"touches" to listOf(touchPoint),
|
|
2421
2161
|
"tapCount" to tapCount,
|
|
2422
2162
|
"x" to x,
|
|
2423
2163
|
"y" to y
|
|
@@ -2434,7 +2174,6 @@ class RejourneyModuleImpl(
|
|
|
2434
2174
|
|
|
2435
2175
|
override fun currentKeyboardHeight(): Int = lastKeyboardHeight
|
|
2436
2176
|
|
|
2437
|
-
// ==================== NetworkMonitorListener ====================
|
|
2438
2177
|
|
|
2439
2178
|
override fun onNetworkChanged(quality: com.rejourney.network.NetworkQuality) {
|
|
2440
2179
|
if (!isRecording) return
|
|
@@ -2459,7 +2198,6 @@ class RejourneyModuleImpl(
|
|
|
2459
2198
|
addEventWithPersistence(eventMap)
|
|
2460
2199
|
}
|
|
2461
2200
|
|
|
2462
|
-
// ==================== KeyboardTrackerListener ====================
|
|
2463
2201
|
|
|
2464
2202
|
override fun onKeyboardShown(keyboardHeight: Int) {
|
|
2465
2203
|
if (!isRecording) return
|
|
@@ -2475,7 +2213,6 @@ class RejourneyModuleImpl(
|
|
|
2475
2213
|
)
|
|
2476
2214
|
addEventWithPersistence(eventMap)
|
|
2477
2215
|
|
|
2478
|
-
// Schedule capture after keyboard settles
|
|
2479
2216
|
captureEngine?.notifyKeyboardEvent("keyboard_shown")
|
|
2480
2217
|
}
|
|
2481
2218
|
|
|
@@ -2485,7 +2222,6 @@ class RejourneyModuleImpl(
|
|
|
2485
2222
|
Logger.debug("[KEYBOARD] Keyboard hidden (keyPresses=$keyPressCount)")
|
|
2486
2223
|
isKeyboardVisible = false
|
|
2487
2224
|
|
|
2488
|
-
// Match iOS/player behavior: emit a recent typing signal if we recorded keypresses
|
|
2489
2225
|
if (keyPressCount > 0) {
|
|
2490
2226
|
addEventWithPersistence(
|
|
2491
2227
|
mapOf(
|
|
@@ -2503,10 +2239,8 @@ class RejourneyModuleImpl(
|
|
|
2503
2239
|
)
|
|
2504
2240
|
addEventWithPersistence(eventMap)
|
|
2505
2241
|
|
|
2506
|
-
// Reset key press count when keyboard hides
|
|
2507
2242
|
keyPressCount = 0
|
|
2508
2243
|
|
|
2509
|
-
// Schedule capture after keyboard settles
|
|
2510
2244
|
captureEngine?.notifyKeyboardEvent("keyboard_hidden")
|
|
2511
2245
|
}
|
|
2512
2246
|
|
|
@@ -2514,17 +2248,13 @@ class RejourneyModuleImpl(
|
|
|
2514
2248
|
keyPressCount++
|
|
2515
2249
|
}
|
|
2516
2250
|
|
|
2517
|
-
// ==================== TextInputTrackerListener ====================
|
|
2518
2251
|
|
|
2519
2252
|
override fun onTextChanged(characterCount: Int) {
|
|
2520
2253
|
if (!isRecording) return
|
|
2521
2254
|
if (characterCount <= 0) return
|
|
2522
2255
|
|
|
2523
|
-
// Accumulate key presses
|
|
2524
2256
|
keyPressCount += characterCount
|
|
2525
2257
|
|
|
2526
|
-
// Emit typing events so the player can animate typing indicators.
|
|
2527
|
-
// (No actual text content is captured.)
|
|
2528
2258
|
if (isKeyboardVisible) {
|
|
2529
2259
|
addEventWithPersistence(
|
|
2530
2260
|
mapOf(
|
|
@@ -2536,16 +2266,13 @@ class RejourneyModuleImpl(
|
|
|
2536
2266
|
}
|
|
2537
2267
|
}
|
|
2538
2268
|
|
|
2539
|
-
// ==================== ANRHandler.ANRListener ====================
|
|
2540
2269
|
|
|
2541
2270
|
override fun onANRDetected(durationMs: Long, threadState: String?) {
|
|
2542
|
-
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
2543
2271
|
try {
|
|
2544
2272
|
if (!isRecording) return
|
|
2545
2273
|
|
|
2546
2274
|
Logger.debug("ANR callback: duration=${durationMs}ms")
|
|
2547
2275
|
|
|
2548
|
-
// Log ANR as an event for timeline display
|
|
2549
2276
|
val eventMap = mutableMapOf<String, Any?>(
|
|
2550
2277
|
"type" to "anr",
|
|
2551
2278
|
"timestamp" to System.currentTimeMillis(),
|
|
@@ -2554,19 +2281,15 @@ class RejourneyModuleImpl(
|
|
|
2554
2281
|
threadState?.let { eventMap["threadState"] = it }
|
|
2555
2282
|
addEventWithPersistence(eventMap)
|
|
2556
2283
|
|
|
2557
|
-
// Increment telemetry counter
|
|
2558
2284
|
Telemetry.getInstance().recordANR()
|
|
2559
2285
|
} catch (e: Exception) {
|
|
2560
2286
|
Logger.error("SDK error in onANRDetected (non-fatal)", e)
|
|
2561
2287
|
}
|
|
2562
2288
|
}
|
|
2563
2289
|
|
|
2564
|
-
// ==================== CaptureEngineDelegate ====================
|
|
2565
2290
|
|
|
2566
2291
|
override fun onSegmentReady(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int) {
|
|
2567
|
-
// CRITICAL FIX: Do NOT delete segment if shutting down - we want to persist it for recovery!
|
|
2568
2292
|
if (!isRecording && !isShuttingDown) {
|
|
2569
|
-
// Clean up the segment file if we're not recording (and not shutting down)
|
|
2570
2293
|
try {
|
|
2571
2294
|
segmentFile.delete()
|
|
2572
2295
|
} catch (_: Exception) {}
|
|
@@ -2575,7 +2298,6 @@ class RejourneyModuleImpl(
|
|
|
2575
2298
|
|
|
2576
2299
|
if (isShuttingDown) {
|
|
2577
2300
|
Logger.debug("Segment ready during shutdown - preserving file for recovery: ${segmentFile.name}")
|
|
2578
|
-
// Do not attempt upload now as scope is cancelled. WorkManager/Recovery will handle it.
|
|
2579
2301
|
return
|
|
2580
2302
|
}
|
|
2581
2303
|
|
|
@@ -2612,7 +2334,6 @@ class RejourneyModuleImpl(
|
|
|
2612
2334
|
override fun onCaptureError(error: Exception) {
|
|
2613
2335
|
Logger.error("Capture error: ${error.message}", error)
|
|
2614
2336
|
|
|
2615
|
-
// Log capture error as an event
|
|
2616
2337
|
val eventMap = mutableMapOf<String, Any?>(
|
|
2617
2338
|
"type" to "capture_error",
|
|
2618
2339
|
"timestamp" to System.currentTimeMillis(),
|
|
@@ -2635,9 +2356,6 @@ class RejourneyModuleImpl(
|
|
|
2635
2356
|
return
|
|
2636
2357
|
}
|
|
2637
2358
|
|
|
2638
|
-
// CRITICAL FIX: Capture current session ID at callback time
|
|
2639
|
-
// This prevents stale session ID issues where UploadManager.sessionId
|
|
2640
|
-
// may still reference a previous session
|
|
2641
2359
|
val sid = currentSessionId ?: run {
|
|
2642
2360
|
Logger.error("[HIERARCHY] onHierarchySnapshotsReady: No current session ID, cannot upload hierarchy")
|
|
2643
2361
|
return
|
|
@@ -2654,7 +2372,7 @@ class RejourneyModuleImpl(
|
|
|
2654
2372
|
val success = uploadManager?.uploadHierarchy(
|
|
2655
2373
|
hierarchyData = snapshotsJson,
|
|
2656
2374
|
timestamp = timestamp,
|
|
2657
|
-
sessionId = sid
|
|
2375
|
+
sessionId = sid
|
|
2658
2376
|
) ?: false
|
|
2659
2377
|
|
|
2660
2378
|
val uploadDuration = System.currentTimeMillis() - uploadStartTime
|
|
@@ -2670,7 +2388,6 @@ class RejourneyModuleImpl(
|
|
|
2670
2388
|
}
|
|
2671
2389
|
}
|
|
2672
2390
|
|
|
2673
|
-
// ==================== AuthFailureListener ====================
|
|
2674
2391
|
|
|
2675
2392
|
/**
|
|
2676
2393
|
* Called when authentication fails due to security errors (403/404).
|
|
@@ -2683,14 +2400,11 @@ class RejourneyModuleImpl(
|
|
|
2683
2400
|
|
|
2684
2401
|
when (errorCode) {
|
|
2685
2402
|
403 -> {
|
|
2686
|
-
// SECURITY: Package name mismatch or access forbidden - PERMANENT failure
|
|
2687
2403
|
Logger.error("SECURITY: Access forbidden - stopping recording permanently")
|
|
2688
2404
|
authPermanentlyFailed = true
|
|
2689
2405
|
handleAuthenticationFailurePermanent(errorCode, errorMessage, domain)
|
|
2690
2406
|
}
|
|
2691
2407
|
else -> {
|
|
2692
|
-
// 404 and other errors - retry with exponential backoff
|
|
2693
|
-
// Recording continues locally, events queued for later upload
|
|
2694
2408
|
scheduleAuthRetry(errorCode, errorMessage, domain)
|
|
2695
2409
|
}
|
|
2696
2410
|
}
|
|
@@ -2707,19 +2421,15 @@ class RejourneyModuleImpl(
|
|
|
2707
2421
|
|
|
2708
2422
|
authRetryCount++
|
|
2709
2423
|
|
|
2710
|
-
// Check max retries
|
|
2711
2424
|
if (authRetryCount > MAX_AUTH_RETRIES) {
|
|
2712
2425
|
Logger.error("Auth failed after $MAX_AUTH_RETRIES retries. Recording continues locally.")
|
|
2713
2426
|
|
|
2714
|
-
// Emit warning (not error) - recording continues
|
|
2715
2427
|
emitAuthWarningEvent(errorCode, "Auth failed after max retries. Recording locally.", authRetryCount)
|
|
2716
2428
|
|
|
2717
|
-
// Schedule long background retry (5 minutes)
|
|
2718
2429
|
scheduleBackgroundAuthRetry(AUTH_BACKGROUND_RETRY_DELAY_MS)
|
|
2719
2430
|
return
|
|
2720
2431
|
}
|
|
2721
2432
|
|
|
2722
|
-
// Calculate exponential backoff: 2s, 4s, 8s, 16s, 32s, capped at 60s
|
|
2723
2433
|
val delay = minOf(
|
|
2724
2434
|
AUTH_RETRY_BASE_DELAY_MS * (1L shl (authRetryCount - 1)),
|
|
2725
2435
|
AUTH_RETRY_MAX_DELAY_MS
|
|
@@ -2728,7 +2438,6 @@ class RejourneyModuleImpl(
|
|
|
2728
2438
|
Logger.info("Auth failed (attempt $authRetryCount/$MAX_AUTH_RETRIES), retrying in ${delay}ms. " +
|
|
2729
2439
|
"Recording continues locally. Error: $errorMessage")
|
|
2730
2440
|
|
|
2731
|
-
// After 2 failed attempts, clear cached auth data and re-register fresh
|
|
2732
2441
|
if (authRetryCount >= 2) {
|
|
2733
2442
|
Logger.info("Clearing cached auth data and re-registering fresh...")
|
|
2734
2443
|
deviceAuthManager?.clearCredentials()
|
|
@@ -2741,7 +2450,6 @@ class RejourneyModuleImpl(
|
|
|
2741
2450
|
* Schedule a background auth retry after specified delay.
|
|
2742
2451
|
*/
|
|
2743
2452
|
private fun scheduleBackgroundAuthRetry(delayMs: Long) {
|
|
2744
|
-
// Cancel any existing retry job
|
|
2745
2453
|
authRetryJob?.cancel()
|
|
2746
2454
|
|
|
2747
2455
|
authRetryJob = scope.launch {
|
|
@@ -2768,7 +2476,6 @@ class RejourneyModuleImpl(
|
|
|
2768
2476
|
if (success) {
|
|
2769
2477
|
Logger.debug("Auth retry successful: device registered: $credentialId")
|
|
2770
2478
|
resetAuthRetryState()
|
|
2771
|
-
// Get upload token after successful registration
|
|
2772
2479
|
deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
|
|
2773
2480
|
if (tokenSuccess) {
|
|
2774
2481
|
Logger.debug("Upload token obtained after auth retry")
|
|
@@ -2798,38 +2505,29 @@ class RejourneyModuleImpl(
|
|
|
2798
2505
|
* Stops recording, clears credentials, and emits error event to JS.
|
|
2799
2506
|
*/
|
|
2800
2507
|
private fun handleAuthenticationFailurePermanent(errorCode: Int, errorMessage: String, domain: String) {
|
|
2801
|
-
// Must run on main thread for React Native event emission
|
|
2802
2508
|
Handler(Looper.getMainLooper()).post {
|
|
2803
2509
|
try {
|
|
2804
|
-
// Stop recording immediately
|
|
2805
2510
|
if (isRecording) {
|
|
2806
2511
|
Logger.warning("Stopping recording due to security authentication failure")
|
|
2807
2512
|
|
|
2808
|
-
// Stop capture engine
|
|
2809
2513
|
captureEngine?.stopSession()
|
|
2810
2514
|
|
|
2811
|
-
// Disable touch tracking
|
|
2812
2515
|
touchInterceptor?.disableGlobalTracking()
|
|
2813
2516
|
|
|
2814
|
-
// Stop keyboard and text input tracking
|
|
2815
2517
|
keyboardTracker?.stopTracking()
|
|
2816
2518
|
textInputTracker?.stopTracking()
|
|
2817
2519
|
|
|
2818
|
-
// Stop timers
|
|
2819
2520
|
stopBatchUploadTimer()
|
|
2820
2521
|
stopDurationLimitTimer()
|
|
2821
2522
|
|
|
2822
|
-
// Clear session state
|
|
2823
2523
|
isRecording = false
|
|
2824
2524
|
currentSessionId = null
|
|
2825
2525
|
userId = null
|
|
2826
2526
|
sessionEvents.clear()
|
|
2827
2527
|
}
|
|
2828
2528
|
|
|
2829
|
-
// Clear stored credentials
|
|
2830
2529
|
deviceAuthManager?.clearCredentials()
|
|
2831
2530
|
|
|
2832
|
-
// Emit error event to JavaScript layer
|
|
2833
2531
|
emitAuthErrorEvent(errorCode, errorMessage, domain)
|
|
2834
2532
|
|
|
2835
2533
|
} catch (e: Exception) {
|
|
@@ -2886,7 +2584,6 @@ class RejourneyModuleImpl(
|
|
|
2886
2584
|
*/
|
|
2887
2585
|
private fun checkPreviousAppKill() {
|
|
2888
2586
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
2889
|
-
// ApplicationExitInfo is only available on Android 11+ (API 30)
|
|
2890
2587
|
return
|
|
2891
2588
|
}
|
|
2892
2589
|
|
|
@@ -2897,7 +2594,6 @@ class RejourneyModuleImpl(
|
|
|
2897
2594
|
return
|
|
2898
2595
|
}
|
|
2899
2596
|
|
|
2900
|
-
// Get historical exit reasons for this process
|
|
2901
2597
|
val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1)
|
|
2902
2598
|
|
|
2903
2599
|
if (exitReasons.isNotEmpty()) {
|
|
@@ -2907,11 +2603,8 @@ class RejourneyModuleImpl(
|
|
|
2907
2603
|
|
|
2908
2604
|
Logger.debug("Previous app exit: reason=$reason, timestamp=$timestamp")
|
|
2909
2605
|
|
|
2910
|
-
// Check if app was killed by user (swipe away, force stop, etc.)
|
|
2911
|
-
// REASON_USER_REQUESTED includes swipe-away from recent apps
|
|
2912
2606
|
if (reason == android.app.ApplicationExitInfo.REASON_USER_REQUESTED) {
|
|
2913
2607
|
Logger.debug("App was killed by user (likely swipe-away) - checking for unclosed session")
|
|
2914
|
-
// This will be handled by checkForUnclosedSessions()
|
|
2915
2608
|
}
|
|
2916
2609
|
}
|
|
2917
2610
|
} catch (e: Exception) {
|
|
@@ -2930,40 +2623,29 @@ class RejourneyModuleImpl(
|
|
|
2930
2623
|
val lastSessionStartTime = prefs.getLong("rj_session_start_time", 0)
|
|
2931
2624
|
|
|
2932
2625
|
if (lastSessionId != null && lastSessionStartTime > 0) {
|
|
2933
|
-
// Check if session was never closed (no end timestamp stored)
|
|
2934
2626
|
val sessionEndTime = prefs.getLong("rj_session_end_time_$lastSessionId", 0)
|
|
2935
2627
|
|
|
2936
2628
|
if (sessionEndTime == 0L) {
|
|
2937
2629
|
Logger.debug("Found unclosed session: $lastSessionId (started at $lastSessionStartTime)")
|
|
2938
2630
|
|
|
2939
|
-
// Session was never properly closed - likely app was killed
|
|
2940
|
-
// End the session asynchronously using the upload manager
|
|
2941
2631
|
backgroundScope.launch {
|
|
2942
2632
|
try {
|
|
2943
|
-
// Reconstruct upload manager state if needed
|
|
2944
2633
|
uploadManager?.let { um ->
|
|
2945
|
-
// Set the session ID temporarily to allow endSession to work
|
|
2946
2634
|
val originalSessionId = um.sessionId
|
|
2947
2635
|
um.sessionId = lastSessionId
|
|
2948
2636
|
|
|
2949
|
-
|
|
2950
|
-
// Use a timestamp slightly before now to account for the gap
|
|
2951
|
-
val estimatedEndTime = System.currentTimeMillis() - 1000 // 1 second before now
|
|
2637
|
+
val estimatedEndTime = System.currentTimeMillis() - 1000
|
|
2952
2638
|
|
|
2953
2639
|
Logger.debug("Ending unclosed session: $lastSessionId at $estimatedEndTime")
|
|
2954
2640
|
|
|
2955
|
-
// Use the upload manager's endSession with override timestamp
|
|
2956
2641
|
val success = um.endSession(endedAtOverride = estimatedEndTime)
|
|
2957
2642
|
|
|
2958
|
-
// Restore original session ID
|
|
2959
2643
|
um.sessionId = originalSessionId
|
|
2960
2644
|
|
|
2961
2645
|
if (success) {
|
|
2962
2646
|
Logger.debug("Successfully ended unclosed session: $lastSessionId")
|
|
2963
|
-
// Clear the session markers
|
|
2964
2647
|
um.clearSessionRecovery(lastSessionId)
|
|
2965
2648
|
|
|
2966
|
-
// Update prefs to mark session as closed
|
|
2967
2649
|
prefs.edit()
|
|
2968
2650
|
.putLong("rj_session_end_time_$lastSessionId", estimatedEndTime)
|
|
2969
2651
|
.remove("rj_current_session_id")
|
|
@@ -2978,7 +2660,6 @@ class RejourneyModuleImpl(
|
|
|
2978
2660
|
}
|
|
2979
2661
|
}
|
|
2980
2662
|
} else {
|
|
2981
|
-
// Session was properly closed, clear old markers
|
|
2982
2663
|
prefs.edit()
|
|
2983
2664
|
.remove("rj_current_session_id")
|
|
2984
2665
|
.remove("rj_session_start_time")
|