@rejourneyco/react-native 1.0.0
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/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
- package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
- package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
- package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
- package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
- package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
- package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
- package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
- package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
- package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
- package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
- package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
- package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
- package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
- package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
- package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
- package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
- package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Capture/RJANRHandler.h +42 -0
- package/ios/Capture/RJANRHandler.m +328 -0
- package/ios/Capture/RJCaptureEngine.h +275 -0
- package/ios/Capture/RJCaptureEngine.m +2062 -0
- package/ios/Capture/RJCaptureHeuristics.h +80 -0
- package/ios/Capture/RJCaptureHeuristics.m +903 -0
- package/ios/Capture/RJCrashHandler.h +46 -0
- package/ios/Capture/RJCrashHandler.m +313 -0
- package/ios/Capture/RJMotionEvent.h +183 -0
- package/ios/Capture/RJMotionEvent.m +183 -0
- package/ios/Capture/RJPerformanceManager.h +100 -0
- package/ios/Capture/RJPerformanceManager.m +373 -0
- package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
- package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
- package/ios/Capture/RJSegmentUploader.h +146 -0
- package/ios/Capture/RJSegmentUploader.m +778 -0
- package/ios/Capture/RJVideoEncoder.h +247 -0
- package/ios/Capture/RJVideoEncoder.m +1036 -0
- package/ios/Capture/RJViewControllerTracker.h +73 -0
- package/ios/Capture/RJViewControllerTracker.m +508 -0
- package/ios/Capture/RJViewHierarchyScanner.h +215 -0
- package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
- package/ios/Capture/RJViewSerializer.h +119 -0
- package/ios/Capture/RJViewSerializer.m +498 -0
- package/ios/Core/RJConstants.h +124 -0
- package/ios/Core/RJConstants.m +88 -0
- package/ios/Core/RJLifecycleManager.h +85 -0
- package/ios/Core/RJLifecycleManager.m +308 -0
- package/ios/Core/RJLogger.h +61 -0
- package/ios/Core/RJLogger.m +211 -0
- package/ios/Core/RJTypes.h +176 -0
- package/ios/Core/RJTypes.m +66 -0
- package/ios/Core/Rejourney.h +64 -0
- package/ios/Core/Rejourney.mm +2495 -0
- package/ios/Network/RJDeviceAuthManager.h +94 -0
- package/ios/Network/RJDeviceAuthManager.m +967 -0
- package/ios/Network/RJNetworkMonitor.h +68 -0
- package/ios/Network/RJNetworkMonitor.m +267 -0
- package/ios/Network/RJRetryManager.h +73 -0
- package/ios/Network/RJRetryManager.m +325 -0
- package/ios/Network/RJUploadManager.h +267 -0
- package/ios/Network/RJUploadManager.m +2296 -0
- package/ios/Privacy/RJPrivacyMask.h +163 -0
- package/ios/Privacy/RJPrivacyMask.m +922 -0
- package/ios/Rejourney.h +63 -0
- package/ios/Touch/RJGestureClassifier.h +130 -0
- package/ios/Touch/RJGestureClassifier.m +333 -0
- package/ios/Touch/RJTouchInterceptor.h +169 -0
- package/ios/Touch/RJTouchInterceptor.m +772 -0
- package/ios/Utils/RJEventBuffer.h +112 -0
- package/ios/Utils/RJEventBuffer.m +358 -0
- package/ios/Utils/RJGzipUtils.h +33 -0
- package/ios/Utils/RJGzipUtils.m +89 -0
- package/ios/Utils/RJKeychainManager.h +48 -0
- package/ios/Utils/RJKeychainManager.m +111 -0
- package/ios/Utils/RJPerfTiming.h +209 -0
- package/ios/Utils/RJPerfTiming.m +264 -0
- package/ios/Utils/RJTelemetry.h +92 -0
- package/ios/Utils/RJTelemetry.m +320 -0
- package/ios/Utils/RJWindowUtils.h +66 -0
- package/ios/Utils/RJWindowUtils.m +133 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +79 -0
- package/lib/commonjs/index.js +1381 -0
- package/lib/commonjs/sdk/autoTracking.js +1259 -0
- package/lib/commonjs/sdk/constants.js +151 -0
- package/lib/commonjs/sdk/errorTracking.js +199 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +204 -0
- package/lib/commonjs/sdk/navigation.js +151 -0
- package/lib/commonjs/sdk/networkInterceptor.js +412 -0
- package/lib/commonjs/sdk/utils.js +363 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +72 -0
- package/lib/module/index.js +1284 -0
- package/lib/module/sdk/autoTracking.js +1233 -0
- package/lib/module/sdk/constants.js +145 -0
- package/lib/module/sdk/errorTracking.js +189 -0
- package/lib/module/sdk/index.js +12 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +143 -0
- package/lib/module/sdk/networkInterceptor.js +401 -0
- package/lib/module/sdk/utils.js +342 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +147 -0
- package/lib/typescript/components/Mask.d.ts +39 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +204 -0
- package/lib/typescript/sdk/constants.d.ts +120 -0
- package/lib/typescript/sdk/errorTracking.d.ts +32 -0
- package/lib/typescript/sdk/index.d.ts +9 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
- package/lib/typescript/sdk/navigation.d.ts +33 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
- package/lib/typescript/sdk/utils.d.ts +148 -0
- package/lib/typescript/types/index.d.ts +624 -0
- package/package.json +102 -0
- package/rejourney.podspec +21 -0
- package/src/NativeRejourney.ts +165 -0
- package/src/components/Mask.tsx +80 -0
- package/src/index.ts +1459 -0
- package/src/sdk/autoTracking.ts +1373 -0
- package/src/sdk/constants.ts +134 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +11 -0
- package/src/sdk/metricsTracking.ts +232 -0
- package/src/sdk/navigation.ts +157 -0
- package/src/sdk/networkInterceptor.ts +440 -0
- package/src/sdk/utils.ts +369 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +739 -0
|
@@ -0,0 +1,2981 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared implementation for Rejourney React Native module.
|
|
3
|
+
*
|
|
4
|
+
* This class contains all the business logic shared between:
|
|
5
|
+
* - Old Architecture (Bridge) module
|
|
6
|
+
* - New Architecture (TurboModules) module
|
|
7
|
+
*
|
|
8
|
+
* The actual RejourneyModule classes in oldarch/ and newarch/ are thin wrappers
|
|
9
|
+
* that delegate to this implementation.
|
|
10
|
+
*/
|
|
11
|
+
package com.rejourney
|
|
12
|
+
|
|
13
|
+
import android.app.Activity
|
|
14
|
+
import android.app.Application
|
|
15
|
+
import android.content.Context
|
|
16
|
+
import android.content.Intent
|
|
17
|
+
import android.os.Build
|
|
18
|
+
import android.os.Bundle
|
|
19
|
+
import android.os.Handler
|
|
20
|
+
import android.os.Looper
|
|
21
|
+
import android.view.MotionEvent
|
|
22
|
+
import android.provider.Settings
|
|
23
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
24
|
+
import androidx.lifecycle.LifecycleOwner
|
|
25
|
+
import androidx.lifecycle.ProcessLifecycleOwner
|
|
26
|
+
import com.rejourney.lifecycle.SessionLifecycleService
|
|
27
|
+
import com.rejourney.lifecycle.TaskRemovedListener
|
|
28
|
+
import com.facebook.react.bridge.*
|
|
29
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
30
|
+
import com.rejourney.capture.CaptureEngine
|
|
31
|
+
import com.rejourney.capture.CaptureEngineDelegate
|
|
32
|
+
import com.rejourney.capture.CrashHandler
|
|
33
|
+
import com.rejourney.capture.ANRHandler
|
|
34
|
+
import com.rejourney.core.Constants
|
|
35
|
+
import com.rejourney.core.EventType
|
|
36
|
+
import com.rejourney.core.Logger
|
|
37
|
+
import com.rejourney.core.SDKMetrics
|
|
38
|
+
import com.rejourney.network.AuthFailureListener
|
|
39
|
+
import com.rejourney.network.DeviceAuthManager
|
|
40
|
+
import com.rejourney.network.NetworkMonitor
|
|
41
|
+
import com.rejourney.network.NetworkMonitorListener
|
|
42
|
+
import com.rejourney.network.UploadManager
|
|
43
|
+
import com.rejourney.network.UploadWorker
|
|
44
|
+
import com.rejourney.touch.KeyboardTracker
|
|
45
|
+
import com.rejourney.touch.KeyboardTrackerListener
|
|
46
|
+
import com.rejourney.touch.TextInputTracker
|
|
47
|
+
import com.rejourney.touch.TextInputTrackerListener
|
|
48
|
+
import com.rejourney.touch.TouchInterceptor
|
|
49
|
+
import com.rejourney.touch.TouchInterceptorDelegate
|
|
50
|
+
import com.rejourney.utils.EventBuffer
|
|
51
|
+
import com.rejourney.utils.OEMDetector
|
|
52
|
+
import com.rejourney.utils.Telemetry
|
|
53
|
+
import com.rejourney.utils.WindowUtils
|
|
54
|
+
import kotlinx.coroutines.*
|
|
55
|
+
import java.security.MessageDigest
|
|
56
|
+
import java.io.File
|
|
57
|
+
import java.util.*
|
|
58
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
59
|
+
|
|
60
|
+
// Enum for session end reasons
|
|
61
|
+
enum class EndReason {
|
|
62
|
+
SESSION_TIMEOUT,
|
|
63
|
+
MANUAL_STOP,
|
|
64
|
+
DURATION_LIMIT,
|
|
65
|
+
REMOTE_DISABLE
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class RejourneyModuleImpl(
|
|
69
|
+
private val reactContext: ReactApplicationContext,
|
|
70
|
+
private val isNewArchitecture: Boolean
|
|
71
|
+
) : Application.ActivityLifecycleCallbacks,
|
|
72
|
+
TouchInterceptorDelegate,
|
|
73
|
+
NetworkMonitorListener,
|
|
74
|
+
DefaultLifecycleObserver,
|
|
75
|
+
KeyboardTrackerListener,
|
|
76
|
+
TextInputTrackerListener,
|
|
77
|
+
ANRHandler.ANRListener,
|
|
78
|
+
CaptureEngineDelegate,
|
|
79
|
+
AuthFailureListener {
|
|
80
|
+
|
|
81
|
+
companion object {
|
|
82
|
+
const val NAME = "Rejourney"
|
|
83
|
+
const val BACKGROUND_RESUME_TIMEOUT_MS = 30_000L // 30 seconds
|
|
84
|
+
|
|
85
|
+
// Auth retry constants
|
|
86
|
+
private const val MAX_AUTH_RETRIES = 5
|
|
87
|
+
private const val AUTH_RETRY_BASE_DELAY_MS = 2000L // 2 seconds base
|
|
88
|
+
private const val AUTH_RETRY_MAX_DELAY_MS = 60000L // 1 minute max
|
|
89
|
+
private const val AUTH_BACKGROUND_RETRY_DELAY_MS = 300000L // 5 minutes
|
|
90
|
+
|
|
91
|
+
// Store process start time at class load for accurate app startup measurement
|
|
92
|
+
@JvmStatic
|
|
93
|
+
private val processStartTimeMs: Long = run {
|
|
94
|
+
try {
|
|
95
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
|
|
96
|
+
// API 24+: Use Process.getStartElapsedRealtime() for accurate measurement
|
|
97
|
+
val startElapsed = android.os.Process.getStartElapsedRealtime()
|
|
98
|
+
val nowElapsed = android.os.SystemClock.elapsedRealtime()
|
|
99
|
+
System.currentTimeMillis() - (nowElapsed - startElapsed)
|
|
100
|
+
} else {
|
|
101
|
+
// Fallback for older devices: use current time (less accurate)
|
|
102
|
+
System.currentTimeMillis()
|
|
103
|
+
}
|
|
104
|
+
} catch (e: Exception) {
|
|
105
|
+
System.currentTimeMillis()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Core components
|
|
111
|
+
private var captureEngine: CaptureEngine? = null
|
|
112
|
+
private var uploadManager: UploadManager? = null
|
|
113
|
+
private var touchInterceptor: TouchInterceptor? = null
|
|
114
|
+
private var deviceAuthManager: DeviceAuthManager? = null
|
|
115
|
+
private var networkMonitor: NetworkMonitor? = null
|
|
116
|
+
private var keyboardTracker: KeyboardTracker? = null
|
|
117
|
+
private var textInputTracker: TextInputTracker? = null
|
|
118
|
+
|
|
119
|
+
// Session state
|
|
120
|
+
private var currentSessionId: String? = null
|
|
121
|
+
private var userId: String? = null
|
|
122
|
+
@Volatile private var isRecording: Boolean = false
|
|
123
|
+
@Volatile private var remoteRejourneyEnabled: Boolean = true
|
|
124
|
+
@Volatile private var remoteRecordingEnabled: Boolean = true
|
|
125
|
+
@Volatile private var recordingEnabledByConfig: Boolean = true
|
|
126
|
+
@Volatile private var sessionSampled: Boolean = true
|
|
127
|
+
@Volatile private var hasSampleDecision: Boolean = false
|
|
128
|
+
@Volatile private var hasProjectConfig: Boolean = false
|
|
129
|
+
private var projectSampleRate: Int = 100
|
|
130
|
+
private var sessionStartTime: Long = 0
|
|
131
|
+
private var totalBackgroundTimeMs: Long = 0
|
|
132
|
+
private var backgroundEntryTime: Long = 0
|
|
133
|
+
private var wasInBackground: Boolean = false
|
|
134
|
+
private var maxRecordingMinutes: Int = 10
|
|
135
|
+
@Volatile private var sessionEndSent: Boolean = false
|
|
136
|
+
|
|
137
|
+
// Keyboard state
|
|
138
|
+
private var keyPressCount: Int = 0
|
|
139
|
+
private var isKeyboardVisible: Boolean = false
|
|
140
|
+
private var lastKeyboardHeight: Int = 0
|
|
141
|
+
|
|
142
|
+
// Session state saved on background - used to restore on foreground if within timeout
|
|
143
|
+
private var savedApiUrl: String = ""
|
|
144
|
+
private var savedPublicKey: String = ""
|
|
145
|
+
private var savedDeviceHash: String = ""
|
|
146
|
+
|
|
147
|
+
// Events buffer
|
|
148
|
+
private val sessionEvents = CopyOnWriteArrayList<Map<String, Any?>>()
|
|
149
|
+
|
|
150
|
+
// Throttle immediate upload kicks (ms)
|
|
151
|
+
@Volatile private var lastImmediateUploadKickMs: Long = 0
|
|
152
|
+
|
|
153
|
+
// Write-first event buffer for crash-safe persistence
|
|
154
|
+
private var eventBuffer: EventBuffer? = null
|
|
155
|
+
|
|
156
|
+
// Coroutine scope for async operations
|
|
157
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
158
|
+
|
|
159
|
+
// Dedicated scope for background flush - survives independently of main scope
|
|
160
|
+
// This prevents cancellation when app goes to background
|
|
161
|
+
private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
162
|
+
|
|
163
|
+
// Timer jobs
|
|
164
|
+
private var batchUploadJob: Job? = null
|
|
165
|
+
private var durationLimitJob: Job? = null
|
|
166
|
+
|
|
167
|
+
// Main thread handler for posting delayed tasks
|
|
168
|
+
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
169
|
+
|
|
170
|
+
// Debounced background detection (prevents transient pauses from ending sessions)
|
|
171
|
+
private var scheduledBackgroundRunnable: Runnable? = null
|
|
172
|
+
private var backgroundScheduled: Boolean = false
|
|
173
|
+
|
|
174
|
+
// Safety flag
|
|
175
|
+
@Volatile private var isShuttingDown = false
|
|
176
|
+
|
|
177
|
+
// Auth resilience - retry mechanism
|
|
178
|
+
private var authRetryCount = 0
|
|
179
|
+
private var authPermanentlyFailed = false
|
|
180
|
+
private var authRetryJob: Job? = null
|
|
181
|
+
|
|
182
|
+
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
|
+
Logger.debug("RejourneyModuleImpl constructor completed")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Lazy initialization flag
|
|
190
|
+
@Volatile
|
|
191
|
+
private var isInitialized = false
|
|
192
|
+
private val initLock = Any()
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Lazy initialization - called on first method invocation.
|
|
196
|
+
* This ensures the module constructor completes successfully for React Native.
|
|
197
|
+
*/
|
|
198
|
+
private fun ensureInitialized() {
|
|
199
|
+
if (isInitialized) return
|
|
200
|
+
|
|
201
|
+
synchronized(initLock) {
|
|
202
|
+
if (isInitialized) return
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
logReactNativeArchitecture()
|
|
206
|
+
setupComponents()
|
|
207
|
+
registerActivityLifecycleCallbacks()
|
|
208
|
+
registerProcessLifecycleObserver()
|
|
209
|
+
|
|
210
|
+
// Start crash handler, ANR handler, and network monitor with error handling
|
|
211
|
+
try {
|
|
212
|
+
CrashHandler.getInstance(reactContext).startMonitoring()
|
|
213
|
+
} catch (e: Exception) {
|
|
214
|
+
Logger.error("Failed to start crash handler (non-critical)", e)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
ANRHandler.getInstance(reactContext).apply {
|
|
219
|
+
listener = this@RejourneyModuleImpl
|
|
220
|
+
startMonitoring()
|
|
221
|
+
}
|
|
222
|
+
} catch (e: Exception) {
|
|
223
|
+
Logger.error("Failed to start ANR handler (non-critical)", e)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
NetworkMonitor.getInstance(reactContext).startMonitoring()
|
|
228
|
+
} catch (e: Exception) {
|
|
229
|
+
Logger.error("Failed to start network monitor (non-critical)", e)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Schedule recovery of any pending uploads from previous sessions
|
|
233
|
+
// This handles cases where the app was killed before uploads completed
|
|
234
|
+
try {
|
|
235
|
+
UploadWorker.scheduleRecoveryUpload(reactContext)
|
|
236
|
+
} catch (e: Exception) {
|
|
237
|
+
Logger.error("Failed to schedule recovery upload (non-critical)", e)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check if app was killed in previous session (Android 11+)
|
|
241
|
+
try {
|
|
242
|
+
checkPreviousAppKill()
|
|
243
|
+
} catch (e: Exception) {
|
|
244
|
+
Logger.error("Failed to check previous app kill (non-critical)", e)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for unclosed sessions from previous launch
|
|
248
|
+
try {
|
|
249
|
+
checkForUnclosedSessions()
|
|
250
|
+
} catch (e: Exception) {
|
|
251
|
+
Logger.error("Failed to check for unclosed sessions (non-critical)", e)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Log OEM information for debugging
|
|
255
|
+
val oem = OEMDetector.getOEM()
|
|
256
|
+
Logger.debug("Device OEM: $oem")
|
|
257
|
+
Logger.debug("OEM Recommendations: ${OEMDetector.getRecommendations()}")
|
|
258
|
+
Logger.debug("onTaskRemoved() reliable: ${OEMDetector.isTaskRemovedReliable()}")
|
|
259
|
+
|
|
260
|
+
// Set up SessionLifecycleService listener to detect app termination
|
|
261
|
+
try {
|
|
262
|
+
SessionLifecycleService.taskRemovedListener = object : TaskRemovedListener {
|
|
263
|
+
override fun onTaskRemoved() {
|
|
264
|
+
Logger.debug("[Rejourney] App terminated via swipe-away - SYNCHRONOUS session end (OEM: $oem)")
|
|
265
|
+
|
|
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
|
+
if (isRecording && !sessionEndSent) {
|
|
270
|
+
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
|
+
runBlocking {
|
|
274
|
+
withTimeout(5000L) { // 5 second timeout
|
|
275
|
+
Logger.debug("[Rejourney] Starting synchronous session end...")
|
|
276
|
+
endSessionSynchronous()
|
|
277
|
+
Logger.debug("[Rejourney] Synchronous session end completed")
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch (e: TimeoutCancellationException) {
|
|
281
|
+
Logger.warning("[Rejourney] Session end timed out after 5s - WorkManager will recover")
|
|
282
|
+
} catch (e: Exception) {
|
|
283
|
+
Logger.error("[Rejourney] Failed to end session on task removed", e)
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
Logger.debug("[Rejourney] Session already ended or not recording - skipping")
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch (e: Exception) {
|
|
291
|
+
Logger.error("Failed to set up task removed listener (non-critical)", e)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Use lifecycle log - only shown in debug builds
|
|
295
|
+
Logger.logInitSuccess(Constants.SDK_VERSION)
|
|
296
|
+
|
|
297
|
+
isInitialized = true
|
|
298
|
+
} catch (e: Exception) {
|
|
299
|
+
Logger.logInitFailure("${e.javaClass.simpleName}: ${e.message}")
|
|
300
|
+
// Mark as initialized anyway to prevent retry loops
|
|
301
|
+
isInitialized = true
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Adds an event with immediate disk persistence for crash safety.
|
|
308
|
+
* This is the industry-standard approach for volume control.
|
|
309
|
+
*/
|
|
310
|
+
private fun addEventWithPersistence(event: Map<String, Any?>) {
|
|
311
|
+
val eventType = event["type"]?.toString() ?: "unknown"
|
|
312
|
+
val sessionId = currentSessionId ?: "no-session"
|
|
313
|
+
|
|
314
|
+
Logger.debug("[Rejourney] addEventWithPersistence: type=$eventType, sessionId=$sessionId, inMemoryCount=${sessionEvents.size + 1}")
|
|
315
|
+
|
|
316
|
+
// CRITICAL: Write to disk immediately for crash safety
|
|
317
|
+
// This ensures events are never lost even if app is force-killed
|
|
318
|
+
val bufferSuccess = eventBuffer?.appendEvent(event) ?: false
|
|
319
|
+
if (!bufferSuccess) {
|
|
320
|
+
Logger.warning("[Rejourney] addEventWithPersistence: Failed to append event to buffer: type=$eventType")
|
|
321
|
+
} else {
|
|
322
|
+
Logger.debug("[Rejourney] addEventWithPersistence: Event appended to buffer: type=$eventType")
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Also add to in-memory buffer for batched upload
|
|
326
|
+
sessionEvents.add(event)
|
|
327
|
+
Logger.debug("[Rejourney] addEventWithPersistence: Event added to in-memory list: type=$eventType, totalInMemory=${sessionEvents.size}")
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Register with ProcessLifecycleOwner for reliable app foreground/background detection.
|
|
332
|
+
* This is more reliable than Activity lifecycle callbacks.
|
|
333
|
+
*/
|
|
334
|
+
private fun registerProcessLifecycleObserver() {
|
|
335
|
+
// Must run on main thread
|
|
336
|
+
Handler(Looper.getMainLooper()).post {
|
|
337
|
+
try {
|
|
338
|
+
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
|
339
|
+
Logger.debug("ProcessLifecycleOwner observer registered")
|
|
340
|
+
} catch (e: Exception) {
|
|
341
|
+
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
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Log which React Native architecture is being used.
|
|
349
|
+
*/
|
|
350
|
+
private fun logReactNativeArchitecture() {
|
|
351
|
+
val archType = if (isNewArchitecture) "New Architecture (TurboModules)" else "Old Architecture (Bridge)"
|
|
352
|
+
Logger.logArchitectureInfo(isNewArchitecture, archType)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private fun setupComponents() {
|
|
356
|
+
try {
|
|
357
|
+
// Initialize capture engine with video segment mode
|
|
358
|
+
captureEngine = CaptureEngine(reactContext).apply {
|
|
359
|
+
captureScale = Constants.DEFAULT_CAPTURE_SCALE
|
|
360
|
+
minFrameInterval = Constants.DEFAULT_MIN_FRAME_INTERVAL
|
|
361
|
+
maxFramesPerMinute = Constants.DEFAULT_MAX_FRAMES_PER_MINUTE
|
|
362
|
+
targetBitrate = Constants.DEFAULT_VIDEO_BITRATE
|
|
363
|
+
targetFps = Constants.DEFAULT_VIDEO_FPS
|
|
364
|
+
framesPerSegment = Constants.DEFAULT_FRAMES_PER_SEGMENT
|
|
365
|
+
delegate = this@RejourneyModuleImpl
|
|
366
|
+
}
|
|
367
|
+
Logger.debug("CaptureEngine initialized")
|
|
368
|
+
} catch (e: Exception) {
|
|
369
|
+
Logger.error("Failed to initialize CaptureEngine", e)
|
|
370
|
+
captureEngine = null
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// Initialize upload manager
|
|
375
|
+
uploadManager = UploadManager(reactContext, "https://api.rejourney.co")
|
|
376
|
+
Logger.debug("UploadManager initialized")
|
|
377
|
+
} catch (e: Exception) {
|
|
378
|
+
Logger.error("Failed to initialize UploadManager", e)
|
|
379
|
+
uploadManager = null
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
// Initialize touch interceptor
|
|
384
|
+
touchInterceptor = TouchInterceptor.getInstance(reactContext).apply {
|
|
385
|
+
delegate = this@RejourneyModuleImpl
|
|
386
|
+
}
|
|
387
|
+
Logger.debug("TouchInterceptor initialized")
|
|
388
|
+
} catch (e: Exception) {
|
|
389
|
+
Logger.error("Failed to initialize TouchInterceptor", e)
|
|
390
|
+
touchInterceptor = null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
// Initialize device auth manager
|
|
395
|
+
deviceAuthManager = DeviceAuthManager.getInstance(reactContext).apply {
|
|
396
|
+
authFailureListener = this@RejourneyModuleImpl
|
|
397
|
+
}
|
|
398
|
+
Logger.debug("DeviceAuthManager initialized")
|
|
399
|
+
} catch (e: Exception) {
|
|
400
|
+
Logger.error("Failed to initialize DeviceAuthManager", e)
|
|
401
|
+
deviceAuthManager = null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
// Initialize network monitor
|
|
406
|
+
networkMonitor = NetworkMonitor.getInstance(reactContext).apply {
|
|
407
|
+
listener = this@RejourneyModuleImpl
|
|
408
|
+
}
|
|
409
|
+
Logger.debug("NetworkMonitor initialized")
|
|
410
|
+
} catch (e: Exception) {
|
|
411
|
+
Logger.error("Failed to initialize NetworkMonitor", e)
|
|
412
|
+
networkMonitor = null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
// Initialize keyboard tracker (for keyboard show/hide events)
|
|
417
|
+
keyboardTracker = KeyboardTracker.getInstance(reactContext).apply {
|
|
418
|
+
listener = this@RejourneyModuleImpl
|
|
419
|
+
}
|
|
420
|
+
Logger.debug("KeyboardTracker initialized")
|
|
421
|
+
} catch (e: Exception) {
|
|
422
|
+
Logger.error("Failed to initialize KeyboardTracker", e)
|
|
423
|
+
keyboardTracker = null
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
// Initialize text input tracker (for key press counting)
|
|
428
|
+
textInputTracker = TextInputTracker.getInstance(reactContext).apply {
|
|
429
|
+
listener = this@RejourneyModuleImpl
|
|
430
|
+
}
|
|
431
|
+
Logger.debug("TextInputTracker initialized")
|
|
432
|
+
} catch (e: Exception) {
|
|
433
|
+
Logger.error("Failed to initialize TextInputTracker", e)
|
|
434
|
+
textInputTracker = null
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private fun registerActivityLifecycleCallbacks() {
|
|
439
|
+
try {
|
|
440
|
+
val application = reactContext.applicationContext as? Application
|
|
441
|
+
if (application != null) {
|
|
442
|
+
application.registerActivityLifecycleCallbacks(this)
|
|
443
|
+
Logger.debug("Activity lifecycle callbacks registered")
|
|
444
|
+
} else {
|
|
445
|
+
Logger.error("Failed to register activity lifecycle callbacks - application context is not Application type")
|
|
446
|
+
}
|
|
447
|
+
} catch (e: Exception) {
|
|
448
|
+
Logger.error("Failed to register activity lifecycle callbacks", e)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fun invalidate() {
|
|
453
|
+
isShuttingDown = true
|
|
454
|
+
scope.cancel()
|
|
455
|
+
stopBatchUploadTimer()
|
|
456
|
+
stopDurationLimitTimer()
|
|
457
|
+
touchInterceptor?.disableGlobalTracking()
|
|
458
|
+
keyboardTracker?.stopTracking()
|
|
459
|
+
textInputTracker?.stopTracking()
|
|
460
|
+
networkMonitor?.stopMonitoring()
|
|
461
|
+
|
|
462
|
+
val application = reactContext.applicationContext as? Application
|
|
463
|
+
application?.unregisterActivityLifecycleCallbacks(this)
|
|
464
|
+
|
|
465
|
+
// Unregister from ProcessLifecycleOwner
|
|
466
|
+
Handler(Looper.getMainLooper()).post {
|
|
467
|
+
try {
|
|
468
|
+
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
|
469
|
+
} catch (e: Exception) {
|
|
470
|
+
// Ignore
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ==================== React Native Methods ====================
|
|
476
|
+
|
|
477
|
+
fun startSession(userId: String, apiUrl: String, publicKey: String, promise: Promise) {
|
|
478
|
+
ensureInitialized() // Lazy init on first call
|
|
479
|
+
|
|
480
|
+
if (isShuttingDown) {
|
|
481
|
+
promise.resolve(createResultMap(false, "", "Module is shutting down"))
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Optimistically allow start; remote config will shut down if disabled.
|
|
486
|
+
remoteRejourneyEnabled = true
|
|
487
|
+
|
|
488
|
+
scope.launch {
|
|
489
|
+
try {
|
|
490
|
+
if (isRecording) {
|
|
491
|
+
promise.resolve(createResultMap(true, currentSessionId ?: ""))
|
|
492
|
+
return@launch
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
val safeUserId = userId.ifEmpty { "anonymous" }
|
|
496
|
+
val safeApiUrl = apiUrl.ifEmpty { "https://api.rejourney.co" }
|
|
497
|
+
val safePublicKey = publicKey.ifEmpty { "" }
|
|
498
|
+
|
|
499
|
+
// Generate device hash
|
|
500
|
+
val androidId = Settings.Secure.getString(
|
|
501
|
+
reactContext.contentResolver,
|
|
502
|
+
Settings.Secure.ANDROID_ID
|
|
503
|
+
) ?: "unknown"
|
|
504
|
+
val deviceHash = generateSHA256Hash(androidId)
|
|
505
|
+
|
|
506
|
+
// Setup session
|
|
507
|
+
this@RejourneyModuleImpl.userId = safeUserId
|
|
508
|
+
currentSessionId = WindowUtils.generateSessionId()
|
|
509
|
+
sessionStartTime = System.currentTimeMillis()
|
|
510
|
+
totalBackgroundTimeMs = 0
|
|
511
|
+
sessionEndSent = false
|
|
512
|
+
sessionEvents.clear()
|
|
513
|
+
|
|
514
|
+
// Reset remote recording flag for this session until config says otherwise
|
|
515
|
+
remoteRecordingEnabled = true
|
|
516
|
+
recordingEnabledByConfig = true
|
|
517
|
+
projectSampleRate = 100
|
|
518
|
+
hasProjectConfig = false
|
|
519
|
+
resetSamplingDecision()
|
|
520
|
+
|
|
521
|
+
// Save session ID for crash handler
|
|
522
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
523
|
+
.edit()
|
|
524
|
+
.putString("rj_current_session_id", currentSessionId)
|
|
525
|
+
.apply()
|
|
526
|
+
|
|
527
|
+
// Configure upload manager
|
|
528
|
+
uploadManager?.apply {
|
|
529
|
+
this.apiUrl = safeApiUrl
|
|
530
|
+
this.publicKey = safePublicKey
|
|
531
|
+
this.deviceHash = deviceHash
|
|
532
|
+
// NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
|
|
533
|
+
setActiveSessionId(currentSessionId!!)
|
|
534
|
+
this.userId = safeUserId
|
|
535
|
+
this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
|
|
536
|
+
resetForNewSession()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Mark session active for crash recovery (disk-backed)
|
|
540
|
+
currentSessionId?.let { sid ->
|
|
541
|
+
uploadManager?.markSessionActive(sid, sessionStartTime)
|
|
542
|
+
|
|
543
|
+
// Also save to SharedPreferences for unclosed session detection
|
|
544
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
545
|
+
.edit()
|
|
546
|
+
.putString("rj_current_session_id", sid)
|
|
547
|
+
.putLong("rj_session_start_time", sessionStartTime)
|
|
548
|
+
.apply()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Initialize write-first event buffer for crash-safe persistence
|
|
552
|
+
val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
|
|
553
|
+
currentSessionId?.let { sid ->
|
|
554
|
+
eventBuffer = EventBuffer(reactContext, sid, pendingDir)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Save config for auto-resume on quick background return
|
|
558
|
+
savedApiUrl = safeApiUrl
|
|
559
|
+
savedPublicKey = safePublicKey
|
|
560
|
+
savedDeviceHash = deviceHash
|
|
561
|
+
|
|
562
|
+
// Start capture engine only if recording is enabled remotely
|
|
563
|
+
if (remoteRecordingEnabled) {
|
|
564
|
+
captureEngine?.startSession(currentSessionId!!)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Enable touch tracking
|
|
568
|
+
touchInterceptor?.enableGlobalTracking()
|
|
569
|
+
|
|
570
|
+
// Start keyboard and text input tracking
|
|
571
|
+
keyboardTracker?.startTracking()
|
|
572
|
+
textInputTracker?.startTracking()
|
|
573
|
+
|
|
574
|
+
// Mark as recording
|
|
575
|
+
isRecording = true
|
|
576
|
+
|
|
577
|
+
// Start SessionLifecycleService to detect app termination
|
|
578
|
+
try {
|
|
579
|
+
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
580
|
+
reactContext.startService(serviceIntent)
|
|
581
|
+
Logger.debug("SessionLifecycleService started")
|
|
582
|
+
} catch (e: Exception) {
|
|
583
|
+
Logger.warning("Failed to start SessionLifecycleService: ${e.message}")
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Start batch uploads
|
|
587
|
+
startBatchUploadTimer()
|
|
588
|
+
startDurationLimitTimer()
|
|
589
|
+
|
|
590
|
+
// Emit app_startup event with startup duration
|
|
591
|
+
// This is the time from process start to session start
|
|
592
|
+
val nowMs = System.currentTimeMillis()
|
|
593
|
+
val startupDurationMs = nowMs - processStartTimeMs
|
|
594
|
+
if (startupDurationMs > 0 && startupDurationMs < 60000) { // Sanity check: < 60s
|
|
595
|
+
val startupEvent = mapOf(
|
|
596
|
+
"type" to "app_startup",
|
|
597
|
+
"timestamp" to nowMs,
|
|
598
|
+
"durationMs" to startupDurationMs,
|
|
599
|
+
"platform" to "android"
|
|
600
|
+
)
|
|
601
|
+
addEventWithPersistence(startupEvent)
|
|
602
|
+
Logger.debug("Recorded app startup time: ${startupDurationMs}ms")
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Fetch project config
|
|
606
|
+
fetchProjectConfig(safePublicKey, safeApiUrl)
|
|
607
|
+
|
|
608
|
+
// Register device
|
|
609
|
+
registerDevice(safePublicKey, safeApiUrl)
|
|
610
|
+
|
|
611
|
+
// Use lifecycle log for session start - only shown in debug builds
|
|
612
|
+
Logger.logSessionStart(currentSessionId ?: "")
|
|
613
|
+
|
|
614
|
+
promise.resolve(createResultMap(true, currentSessionId ?: ""))
|
|
615
|
+
} catch (e: Exception) {
|
|
616
|
+
Logger.error("Failed to start session", e)
|
|
617
|
+
isRecording = false
|
|
618
|
+
promise.resolve(createResultMap(false, "", e.message))
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
fun stopSession(promise: Promise) {
|
|
624
|
+
if (isShuttingDown) {
|
|
625
|
+
promise.resolve(createStopResultMap(false, "", false, null, "Module is shutting down"))
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
scope.launch {
|
|
630
|
+
try {
|
|
631
|
+
if (!isRecording) {
|
|
632
|
+
promise.resolve(createStopResultMap(false, "", false, "Not recording", null))
|
|
633
|
+
return@launch
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
val sessionId = currentSessionId ?: ""
|
|
637
|
+
|
|
638
|
+
// Stop timers
|
|
639
|
+
stopBatchUploadTimer()
|
|
640
|
+
stopDurationLimitTimer()
|
|
641
|
+
|
|
642
|
+
// Force final capture
|
|
643
|
+
if (remoteRecordingEnabled) {
|
|
644
|
+
captureEngine?.forceCaptureWithReason("session_end")
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Stop capture engine (triggers final segment upload via delegate)
|
|
648
|
+
captureEngine?.stopSession()
|
|
649
|
+
|
|
650
|
+
// Disable touch tracking
|
|
651
|
+
touchInterceptor?.disableGlobalTracking()
|
|
652
|
+
|
|
653
|
+
// Build metrics for promotion evaluation
|
|
654
|
+
var crashCount = 0
|
|
655
|
+
var anrCount = 0
|
|
656
|
+
var errorCount = 0
|
|
657
|
+
for (event in sessionEvents) {
|
|
658
|
+
when (event["type"]) {
|
|
659
|
+
"crash" -> crashCount++
|
|
660
|
+
"anr" -> anrCount++
|
|
661
|
+
"error" -> errorCount++
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
|
|
665
|
+
|
|
666
|
+
val metrics = mapOf(
|
|
667
|
+
"crashCount" to crashCount,
|
|
668
|
+
"anrCount" to anrCount,
|
|
669
|
+
"errorCount" to errorCount,
|
|
670
|
+
"durationSeconds" to durationSeconds
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
// Evaluate replay promotion
|
|
674
|
+
val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
|
|
675
|
+
val isPromoted = promotionResult?.first ?: false
|
|
676
|
+
val reason = promotionResult?.second ?: "unknown"
|
|
677
|
+
|
|
678
|
+
if (isPromoted) {
|
|
679
|
+
Logger.debug("Session promoted (reason: $reason)")
|
|
680
|
+
} else {
|
|
681
|
+
Logger.debug("Session not promoted (reason: $reason)")
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Upload remaining events (video segments uploaded via delegate callbacks)
|
|
685
|
+
val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
|
|
686
|
+
|
|
687
|
+
// Send session end signal if not already sent
|
|
688
|
+
var endSessionSuccess = sessionEndSent // Already sent counts as success
|
|
689
|
+
if (!sessionEndSent) {
|
|
690
|
+
sessionEndSent = true
|
|
691
|
+
endSessionSuccess = uploadManager?.endSession() ?: false
|
|
692
|
+
if (!endSessionSuccess) {
|
|
693
|
+
Logger.warning("Session end signal may have failed")
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Clear crash recovery markers only if the session is actually closed
|
|
698
|
+
if (endSessionSuccess) {
|
|
699
|
+
currentSessionId?.let { sid ->
|
|
700
|
+
uploadManager?.clearSessionRecovery(sid)
|
|
701
|
+
|
|
702
|
+
// Mark session as closed in SharedPreferences
|
|
703
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
704
|
+
.edit()
|
|
705
|
+
.putLong("rj_session_end_time_$sid", System.currentTimeMillis())
|
|
706
|
+
.remove("rj_current_session_id")
|
|
707
|
+
.remove("rj_session_start_time")
|
|
708
|
+
.apply()
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Clear state
|
|
713
|
+
isRecording = false
|
|
714
|
+
currentSessionId = null
|
|
715
|
+
this@RejourneyModuleImpl.userId = null
|
|
716
|
+
sessionEvents.clear()
|
|
717
|
+
|
|
718
|
+
// Stop SessionLifecycleService
|
|
719
|
+
try {
|
|
720
|
+
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
721
|
+
reactContext.stopService(serviceIntent)
|
|
722
|
+
Logger.debug("SessionLifecycleService stopped")
|
|
723
|
+
} catch (e: Exception) {
|
|
724
|
+
Logger.warning("Failed to stop SessionLifecycleService: ${e.message}")
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Use lifecycle log for session end - only shown in debug builds
|
|
728
|
+
Logger.logSessionEnd(sessionId)
|
|
729
|
+
|
|
730
|
+
promise.resolve(createStopResultMap(true, sessionId, uploadSuccess && endSessionSuccess, null, null))
|
|
731
|
+
} catch (e: Exception) {
|
|
732
|
+
Logger.error("Failed to stop session", e)
|
|
733
|
+
isRecording = false
|
|
734
|
+
promise.resolve(createStopResultMap(false, currentSessionId ?: "", false, null, e.message))
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
|
|
740
|
+
if (!isRecording || isShuttingDown) {
|
|
741
|
+
promise.resolve(createSuccessMap(false))
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
val event = mapOf(
|
|
747
|
+
"type" to eventType,
|
|
748
|
+
"timestamp" to System.currentTimeMillis(),
|
|
749
|
+
"details" to details.toHashMap()
|
|
750
|
+
)
|
|
751
|
+
addEventWithPersistence(event)
|
|
752
|
+
promise.resolve(createSuccessMap(true))
|
|
753
|
+
} catch (e: Exception) {
|
|
754
|
+
Logger.error("Failed to log event", e)
|
|
755
|
+
promise.resolve(createSuccessMap(false))
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
fun screenChanged(screenName: String, promise: Promise) {
|
|
760
|
+
if (!isRecording || isShuttingDown) {
|
|
761
|
+
promise.resolve(createSuccessMap(false))
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
Logger.debug("Screen changed to: $screenName")
|
|
767
|
+
|
|
768
|
+
val event = mapOf(
|
|
769
|
+
"type" to EventType.NAVIGATION,
|
|
770
|
+
"timestamp" to System.currentTimeMillis(),
|
|
771
|
+
"screenName" to screenName
|
|
772
|
+
)
|
|
773
|
+
addEventWithPersistence(event)
|
|
774
|
+
|
|
775
|
+
// Notify capture engine with delay for render
|
|
776
|
+
scope.launch {
|
|
777
|
+
delay(100)
|
|
778
|
+
captureEngine?.notifyNavigationToScreen(screenName)
|
|
779
|
+
captureEngine?.notifyReactNativeCommit()
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
promise.resolve(createSuccessMap(true))
|
|
783
|
+
} catch (e: Exception) {
|
|
784
|
+
Logger.error("Failed to handle screen change", e)
|
|
785
|
+
promise.resolve(createSuccessMap(false))
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
fun onScroll(offsetY: Double, promise: Promise) {
|
|
790
|
+
if (!isRecording || isShuttingDown) {
|
|
791
|
+
promise.resolve(createSuccessMap(false))
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
captureEngine?.notifyScrollOffset(offsetY.toFloat())
|
|
797
|
+
promise.resolve(createSuccessMap(true))
|
|
798
|
+
} catch (e: Exception) {
|
|
799
|
+
promise.resolve(createSuccessMap(false))
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
fun markVisualChange(reason: String, importance: String, promise: Promise) {
|
|
804
|
+
if (!isRecording || isShuttingDown) {
|
|
805
|
+
promise.resolve(false)
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
val importanceLevel = when (importance.lowercase()) {
|
|
811
|
+
"low" -> com.rejourney.core.CaptureImportance.LOW
|
|
812
|
+
"medium" -> com.rejourney.core.CaptureImportance.MEDIUM
|
|
813
|
+
"high" -> com.rejourney.core.CaptureImportance.HIGH
|
|
814
|
+
"critical" -> com.rejourney.core.CaptureImportance.CRITICAL
|
|
815
|
+
else -> com.rejourney.core.CaptureImportance.MEDIUM
|
|
816
|
+
}
|
|
817
|
+
captureEngine?.notifyVisualChange(reason, importanceLevel)
|
|
818
|
+
promise.resolve(true)
|
|
819
|
+
} catch (e: Exception) {
|
|
820
|
+
promise.resolve(false)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
fun onExternalURLOpened(urlScheme: String, promise: Promise) {
|
|
825
|
+
if (!isRecording || isShuttingDown) {
|
|
826
|
+
promise.resolve(createSuccessMap(false))
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
val event = mapOf(
|
|
832
|
+
"type" to EventType.EXTERNAL_URL_OPENED,
|
|
833
|
+
"timestamp" to System.currentTimeMillis(),
|
|
834
|
+
"urlScheme" to urlScheme
|
|
835
|
+
)
|
|
836
|
+
addEventWithPersistence(event)
|
|
837
|
+
promise.resolve(createSuccessMap(true))
|
|
838
|
+
} catch (e: Exception) {
|
|
839
|
+
promise.resolve(createSuccessMap(false))
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
fun onOAuthStarted(provider: String, promise: Promise) {
|
|
844
|
+
if (!isRecording || isShuttingDown) {
|
|
845
|
+
promise.resolve(createSuccessMap(false))
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
val event = mapOf(
|
|
851
|
+
"type" to EventType.OAUTH_STARTED,
|
|
852
|
+
"timestamp" to System.currentTimeMillis(),
|
|
853
|
+
"provider" to provider
|
|
854
|
+
)
|
|
855
|
+
addEventWithPersistence(event)
|
|
856
|
+
promise.resolve(createSuccessMap(true))
|
|
857
|
+
} catch (e: Exception) {
|
|
858
|
+
promise.resolve(createSuccessMap(false))
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
fun onOAuthCompleted(provider: String, success: Boolean, promise: Promise) {
|
|
863
|
+
if (!isRecording || isShuttingDown) {
|
|
864
|
+
promise.resolve(createSuccessMap(false))
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
val event = mapOf(
|
|
870
|
+
"type" to EventType.OAUTH_COMPLETED,
|
|
871
|
+
"timestamp" to System.currentTimeMillis(),
|
|
872
|
+
"provider" to provider,
|
|
873
|
+
"success" to success
|
|
874
|
+
)
|
|
875
|
+
addEventWithPersistence(event)
|
|
876
|
+
promise.resolve(createSuccessMap(true))
|
|
877
|
+
} catch (e: Exception) {
|
|
878
|
+
promise.resolve(createSuccessMap(false))
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
fun getSDKMetrics(promise: Promise) {
|
|
883
|
+
try {
|
|
884
|
+
val metrics = Telemetry.getInstance().currentMetrics()
|
|
885
|
+
val map = Arguments.createMap().apply {
|
|
886
|
+
putInt("uploadSuccessCount", metrics.uploadSuccessCount)
|
|
887
|
+
putInt("uploadFailureCount", metrics.uploadFailureCount)
|
|
888
|
+
putInt("retryAttemptCount", metrics.retryAttemptCount)
|
|
889
|
+
putInt("circuitBreakerOpenCount", metrics.circuitBreakerOpenCount)
|
|
890
|
+
putInt("memoryEvictionCount", metrics.memoryEvictionCount)
|
|
891
|
+
putInt("offlinePersistCount", metrics.offlinePersistCount)
|
|
892
|
+
putInt("sessionStartCount", metrics.sessionStartCount)
|
|
893
|
+
putInt("crashCount", metrics.crashCount)
|
|
894
|
+
putInt("anrCount", metrics.anrCount)
|
|
895
|
+
putDouble("uploadSuccessRate", metrics.uploadSuccessRate.toDouble())
|
|
896
|
+
putDouble("avgUploadDurationMs", metrics.avgUploadDurationMs.toDouble())
|
|
897
|
+
putInt("currentQueueDepth", metrics.currentQueueDepth)
|
|
898
|
+
metrics.lastUploadTime?.let { value -> putDouble("lastUploadTime", value.toDouble()) }
|
|
899
|
+
metrics.lastRetryTime?.let { value -> putDouble("lastRetryTime", value.toDouble()) }
|
|
900
|
+
putDouble("totalBytesUploaded", metrics.totalBytesUploaded.toDouble())
|
|
901
|
+
putDouble("totalBytesEvicted", metrics.totalBytesEvicted.toDouble())
|
|
902
|
+
}
|
|
903
|
+
promise.resolve(map)
|
|
904
|
+
} catch (e: Exception) {
|
|
905
|
+
Logger.error("Failed to get SDK metrics", e)
|
|
906
|
+
promise.resolve(Arguments.createMap())
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
fun setDebugMode(enabled: Boolean, promise: Promise) {
|
|
911
|
+
try {
|
|
912
|
+
Logger.setDebugMode(enabled)
|
|
913
|
+
promise.resolve(createSuccessMap(true))
|
|
914
|
+
} catch (e: Exception) {
|
|
915
|
+
promise.resolve(createSuccessMap(false))
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
fun debugCrash() {
|
|
920
|
+
Logger.debug("Triggering debug crash...")
|
|
921
|
+
scope.launch(Dispatchers.Main) {
|
|
922
|
+
throw RuntimeException("This is a test crash triggered from React Native")
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
fun debugTriggerANR(durationMs: Double) {
|
|
927
|
+
Logger.debug("Triggering debug ANR for ${durationMs.toLong()}ms...")
|
|
928
|
+
// Post to main looper to block the main thread
|
|
929
|
+
Handler(Looper.getMainLooper()).post {
|
|
930
|
+
try {
|
|
931
|
+
Thread.sleep(durationMs.toLong())
|
|
932
|
+
} catch (e: InterruptedException) {
|
|
933
|
+
e.printStackTrace()
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
fun getSessionId(promise: Promise) {
|
|
939
|
+
promise.resolve(currentSessionId)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ==================== Privacy / View Masking ====================
|
|
943
|
+
|
|
944
|
+
fun maskViewByNativeID(nativeID: String, promise: Promise) {
|
|
945
|
+
if (nativeID.isEmpty()) {
|
|
946
|
+
promise.resolve(createSuccessMap(false))
|
|
947
|
+
return
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
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
|
+
com.rejourney.privacy.PrivacyMask.addMaskedNativeID(nativeID)
|
|
954
|
+
Logger.debug("Masked nativeID: $nativeID")
|
|
955
|
+
promise.resolve(createSuccessMap(true))
|
|
956
|
+
} catch (e: Exception) {
|
|
957
|
+
Logger.warning("maskViewByNativeID failed: ${e.message}")
|
|
958
|
+
promise.resolve(createSuccessMap(false))
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
fun unmaskViewByNativeID(nativeID: String, promise: Promise) {
|
|
963
|
+
if (nativeID.isEmpty()) {
|
|
964
|
+
promise.resolve(createSuccessMap(false))
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
// Remove nativeID from the privacy mask set
|
|
970
|
+
com.rejourney.privacy.PrivacyMask.removeMaskedNativeID(nativeID)
|
|
971
|
+
Logger.debug("Unmasked nativeID: $nativeID")
|
|
972
|
+
promise.resolve(createSuccessMap(true))
|
|
973
|
+
} catch (e: Exception) {
|
|
974
|
+
Logger.warning("unmaskViewByNativeID failed: ${e.message}")
|
|
975
|
+
promise.resolve(createSuccessMap(false))
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Recursively find a view with a given nativeID.
|
|
981
|
+
* In React Native, nativeID is typically stored in the view's tag or as a resource ID.
|
|
982
|
+
*/
|
|
983
|
+
private fun findViewByNativeID(view: android.view.View, nativeID: String): android.view.View? {
|
|
984
|
+
// Check if view has matching tag (common RN pattern)
|
|
985
|
+
val viewTag = view.getTag(com.facebook.react.R.id.view_tag_native_id)
|
|
986
|
+
if (viewTag is String && viewTag == nativeID) {
|
|
987
|
+
return view
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Recurse into ViewGroup children
|
|
991
|
+
if (view is android.view.ViewGroup) {
|
|
992
|
+
for (i in 0 until view.childCount) {
|
|
993
|
+
val child = view.getChildAt(i)
|
|
994
|
+
val found = findViewByNativeID(child, nativeID)
|
|
995
|
+
if (found != null) return found
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return null
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ==================== User Identity ====================
|
|
1003
|
+
|
|
1004
|
+
fun setUserIdentity(userId: String, promise: Promise) {
|
|
1005
|
+
try {
|
|
1006
|
+
val safeUserId = userId.ifEmpty { "anonymous" }
|
|
1007
|
+
|
|
1008
|
+
// Update userId
|
|
1009
|
+
this.userId = safeUserId
|
|
1010
|
+
|
|
1011
|
+
// Update upload manager
|
|
1012
|
+
uploadManager?.userId = safeUserId
|
|
1013
|
+
|
|
1014
|
+
Logger.debug("User identity updated: $safeUserId")
|
|
1015
|
+
|
|
1016
|
+
// Log event for tracking
|
|
1017
|
+
if (isRecording) {
|
|
1018
|
+
val event = mapOf(
|
|
1019
|
+
"type" to "user_identity_changed",
|
|
1020
|
+
"timestamp" to System.currentTimeMillis(),
|
|
1021
|
+
"userId" to safeUserId
|
|
1022
|
+
)
|
|
1023
|
+
addEventWithPersistence(event)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
promise.resolve(createSuccessMap(true))
|
|
1027
|
+
} catch (e: Exception) {
|
|
1028
|
+
Logger.warning("setUserIdentity failed: ${e.message}")
|
|
1029
|
+
promise.resolve(createSuccessMap(false))
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ==================== Helper Methods ====================
|
|
1034
|
+
|
|
1035
|
+
private fun createResultMap(success: Boolean, sessionId: String, error: String? = null): WritableMap {
|
|
1036
|
+
return Arguments.createMap().apply {
|
|
1037
|
+
putBoolean("success", success)
|
|
1038
|
+
putString("sessionId", sessionId)
|
|
1039
|
+
error?.let { putString("error", it) }
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private fun createStopResultMap(
|
|
1044
|
+
success: Boolean,
|
|
1045
|
+
sessionId: String,
|
|
1046
|
+
uploadSuccess: Boolean,
|
|
1047
|
+
warning: String?,
|
|
1048
|
+
error: String?
|
|
1049
|
+
): WritableMap {
|
|
1050
|
+
return Arguments.createMap().apply {
|
|
1051
|
+
putBoolean("success", success)
|
|
1052
|
+
putString("sessionId", sessionId)
|
|
1053
|
+
putBoolean("uploadSuccess", uploadSuccess)
|
|
1054
|
+
warning?.let { putString("warning", it) }
|
|
1055
|
+
error?.let { putString("error", it) }
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private fun createSuccessMap(success: Boolean): WritableMap {
|
|
1060
|
+
return Arguments.createMap().apply {
|
|
1061
|
+
putBoolean("success", success)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private fun generateSHA256Hash(input: String): String {
|
|
1066
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
1067
|
+
val hash = digest.digest(input.toByteArray())
|
|
1068
|
+
return hash.joinToString("") { "%02x".format(it) }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private fun resetSamplingDecision() {
|
|
1072
|
+
sessionSampled = true
|
|
1073
|
+
hasSampleDecision = false
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private fun shouldSampleSession(sampleRate: Int): Boolean {
|
|
1077
|
+
val clampedRate = sampleRate.coerceIn(0, 100)
|
|
1078
|
+
if (clampedRate >= 100) return true
|
|
1079
|
+
if (clampedRate <= 0) return false
|
|
1080
|
+
return Random().nextInt(100) < clampedRate
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private fun updateRecordingEligibility(sampleRate: Int = projectSampleRate): Boolean {
|
|
1084
|
+
val clampedRate = sampleRate.coerceIn(0, 100)
|
|
1085
|
+
projectSampleRate = clampedRate
|
|
1086
|
+
|
|
1087
|
+
val decidedSample = if (!hasSampleDecision) {
|
|
1088
|
+
sessionSampled = shouldSampleSession(clampedRate)
|
|
1089
|
+
hasSampleDecision = true
|
|
1090
|
+
true
|
|
1091
|
+
} else {
|
|
1092
|
+
false
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
val shouldRecord = recordingEnabledByConfig && sessionSampled
|
|
1096
|
+
remoteRecordingEnabled = shouldRecord
|
|
1097
|
+
|
|
1098
|
+
if (!shouldRecord && captureEngine?.isRecording == true) {
|
|
1099
|
+
captureEngine?.stopSession()
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (decidedSample && recordingEnabledByConfig && !sessionSampled) {
|
|
1103
|
+
Logger.warning("Session skipped by sample rate (${clampedRate}%)")
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
return shouldRecord
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private fun startBatchUploadTimer() {
|
|
1110
|
+
stopBatchUploadTimer()
|
|
1111
|
+
batchUploadJob = scope.launch {
|
|
1112
|
+
delay((Constants.INITIAL_UPLOAD_DELAY * 1000).toLong())
|
|
1113
|
+
while (isActive && isRecording) {
|
|
1114
|
+
performBatchUpload()
|
|
1115
|
+
delay((Constants.BATCH_UPLOAD_INTERVAL * 1000).toLong())
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Best-effort: trigger an upload/persist attempt soon after a keyframe is captured.
|
|
1122
|
+
* This materially improves crash sessions (more frames make it to disk quickly).
|
|
1123
|
+
*/
|
|
1124
|
+
private fun scheduleImmediateUploadKick() {
|
|
1125
|
+
if (!isRecording || isShuttingDown) return
|
|
1126
|
+
|
|
1127
|
+
val now = System.currentTimeMillis()
|
|
1128
|
+
if (now - lastImmediateUploadKickMs < 1_000L) return
|
|
1129
|
+
lastImmediateUploadKickMs = now
|
|
1130
|
+
|
|
1131
|
+
scope.launch {
|
|
1132
|
+
try {
|
|
1133
|
+
performBatchUpload()
|
|
1134
|
+
} catch (_: Exception) {
|
|
1135
|
+
// Best-effort only
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private fun stopBatchUploadTimer() {
|
|
1141
|
+
batchUploadJob?.cancel()
|
|
1142
|
+
batchUploadJob = null
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private fun startDurationLimitTimer() {
|
|
1146
|
+
stopDurationLimitTimer()
|
|
1147
|
+
val maxMs = maxRecordingMinutes * 60 * 1000L
|
|
1148
|
+
val elapsed = System.currentTimeMillis() - sessionStartTime
|
|
1149
|
+
val remaining = maxMs - elapsed
|
|
1150
|
+
|
|
1151
|
+
if (remaining > 0) {
|
|
1152
|
+
durationLimitJob = scope.launch {
|
|
1153
|
+
delay(remaining)
|
|
1154
|
+
if (isRecording) {
|
|
1155
|
+
Logger.warning("Recording duration limit reached, stopping session")
|
|
1156
|
+
stopSessionInternal()
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private fun stopDurationLimitTimer() {
|
|
1163
|
+
durationLimitJob?.cancel()
|
|
1164
|
+
durationLimitJob = null
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
private suspend fun performBatchUpload() {
|
|
1168
|
+
if (!isRecording || isShuttingDown) return
|
|
1169
|
+
|
|
1170
|
+
try {
|
|
1171
|
+
// Video segments are uploaded via CaptureEngineDelegate callbacks.
|
|
1172
|
+
// This timer now only handles event uploads.
|
|
1173
|
+
|
|
1174
|
+
val eventsToUpload = sessionEvents.toList()
|
|
1175
|
+
|
|
1176
|
+
if (eventsToUpload.isEmpty()) return
|
|
1177
|
+
|
|
1178
|
+
// Upload events only (video segments uploaded via delegate)
|
|
1179
|
+
val ok = uploadManager?.uploadBatch(eventsToUpload) ?: false
|
|
1180
|
+
|
|
1181
|
+
if (ok) {
|
|
1182
|
+
// Only clear events after data is safely uploaded
|
|
1183
|
+
sessionEvents.clear()
|
|
1184
|
+
}
|
|
1185
|
+
} catch (e: CancellationException) {
|
|
1186
|
+
// Normal cancellation (e.g., app going to background) - not an error
|
|
1187
|
+
// WorkManager will handle the upload instead
|
|
1188
|
+
Logger.debug("Batch upload cancelled (coroutine cancelled)")
|
|
1189
|
+
throw e // Re-throw to propagate cancellation
|
|
1190
|
+
} catch (e: Exception) {
|
|
1191
|
+
Logger.error("Batch upload failed", e)
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private suspend fun stopSessionInternal() {
|
|
1196
|
+
if (!isRecording) return
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
stopBatchUploadTimer()
|
|
1200
|
+
stopDurationLimitTimer()
|
|
1201
|
+
captureEngine?.stopSession()
|
|
1202
|
+
isRecording = false
|
|
1203
|
+
currentSessionId = null
|
|
1204
|
+
userId = null
|
|
1205
|
+
sessionEvents.clear()
|
|
1206
|
+
} catch (e: Exception) {
|
|
1207
|
+
Logger.error("Failed to stop session internally", e)
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* End the current session with a specific reason.
|
|
1213
|
+
*/
|
|
1214
|
+
private fun endSession(reason: EndReason, promise: Promise?) {
|
|
1215
|
+
scope.launch {
|
|
1216
|
+
try {
|
|
1217
|
+
if (!isRecording) {
|
|
1218
|
+
promise?.resolve(createStopResultMap(false, "", false, "Not recording", null))
|
|
1219
|
+
return@launch
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
val sessionId = currentSessionId ?: ""
|
|
1223
|
+
Logger.debug("Ending session due to: $reason")
|
|
1224
|
+
|
|
1225
|
+
// Stop timers
|
|
1226
|
+
stopBatchUploadTimer()
|
|
1227
|
+
stopDurationLimitTimer()
|
|
1228
|
+
|
|
1229
|
+
// Force final capture
|
|
1230
|
+
if (remoteRecordingEnabled) {
|
|
1231
|
+
captureEngine?.forceCaptureWithReason("session_end_${reason.name.lowercase()}")
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Stop capture engine
|
|
1235
|
+
captureEngine?.stopSession()
|
|
1236
|
+
|
|
1237
|
+
// Disable touch tracking
|
|
1238
|
+
touchInterceptor?.disableGlobalTracking()
|
|
1239
|
+
keyboardTracker?.stopTracking()
|
|
1240
|
+
textInputTracker?.stopTracking()
|
|
1241
|
+
|
|
1242
|
+
// Build metrics
|
|
1243
|
+
var crashCount = 0
|
|
1244
|
+
var anrCount = 0
|
|
1245
|
+
var errorCount = 0
|
|
1246
|
+
for (event in sessionEvents) {
|
|
1247
|
+
when (event["type"]) {
|
|
1248
|
+
"crash" -> crashCount++
|
|
1249
|
+
"anr" -> anrCount++
|
|
1250
|
+
"error" -> errorCount++
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
|
|
1254
|
+
|
|
1255
|
+
val metrics = mapOf(
|
|
1256
|
+
"crashCount" to crashCount,
|
|
1257
|
+
"anrCount" to anrCount,
|
|
1258
|
+
"errorCount" to errorCount,
|
|
1259
|
+
"durationSeconds" to durationSeconds
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
// Evaluate promotion
|
|
1263
|
+
val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
|
|
1264
|
+
val isPromoted = promotionResult?.first ?: false
|
|
1265
|
+
val promotionReason = promotionResult?.second ?: "unknown"
|
|
1266
|
+
|
|
1267
|
+
if (isPromoted) {
|
|
1268
|
+
Logger.debug("Session promoted (reason: $promotionReason)")
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Upload remaining events
|
|
1272
|
+
val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
|
|
1273
|
+
|
|
1274
|
+
// Send session end signal
|
|
1275
|
+
var endSessionSuccess = sessionEndSent
|
|
1276
|
+
if (!sessionEndSent) {
|
|
1277
|
+
sessionEndSent = true
|
|
1278
|
+
endSessionSuccess = uploadManager?.endSession() ?: false
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Clear recovery markers
|
|
1282
|
+
if (endSessionSuccess) {
|
|
1283
|
+
currentSessionId?.let { sid ->
|
|
1284
|
+
uploadManager?.clearSessionRecovery(sid)
|
|
1285
|
+
|
|
1286
|
+
// Mark session as closed in SharedPreferences
|
|
1287
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
1288
|
+
.edit()
|
|
1289
|
+
.putLong("rj_session_end_time_$sid", System.currentTimeMillis())
|
|
1290
|
+
.remove("rj_current_session_id")
|
|
1291
|
+
.remove("rj_session_start_time")
|
|
1292
|
+
.apply()
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Clear state
|
|
1297
|
+
isRecording = false
|
|
1298
|
+
currentSessionId = null
|
|
1299
|
+
userId = null
|
|
1300
|
+
sessionEvents.clear()
|
|
1301
|
+
|
|
1302
|
+
// Stop SessionLifecycleService
|
|
1303
|
+
try {
|
|
1304
|
+
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
1305
|
+
reactContext.stopService(serviceIntent)
|
|
1306
|
+
Logger.debug("SessionLifecycleService stopped")
|
|
1307
|
+
} catch (e: Exception) {
|
|
1308
|
+
Logger.warning("Failed to stop SessionLifecycleService: ${e.message}")
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
Logger.logSessionEnd(sessionId)
|
|
1312
|
+
promise?.resolve(createStopResultMap(true, sessionId, uploadSuccess && endSessionSuccess, null, null))
|
|
1313
|
+
} catch (e: Exception) {
|
|
1314
|
+
Logger.error("Failed to end session", e)
|
|
1315
|
+
isRecording = false
|
|
1316
|
+
promise?.resolve(createStopResultMap(false, currentSessionId ?: "", false, null, e.message))
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Synchronous session end for use with runBlocking when app is being killed.
|
|
1323
|
+
*
|
|
1324
|
+
* This is called from onTaskRemoved where we need to complete session end
|
|
1325
|
+
* BEFORE the process is killed. Unlike endSession() which uses scope.launch,
|
|
1326
|
+
* this is a suspend function that runs in the calling coroutine context.
|
|
1327
|
+
*/
|
|
1328
|
+
private suspend fun endSessionSynchronous() {
|
|
1329
|
+
if (!isRecording) {
|
|
1330
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Not recording, skipping")
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
val sessionId = currentSessionId ?: ""
|
|
1335
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Starting for session $sessionId")
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
// Stop timers (synchronous)
|
|
1339
|
+
stopBatchUploadTimer()
|
|
1340
|
+
stopDurationLimitTimer()
|
|
1341
|
+
|
|
1342
|
+
// Force final capture
|
|
1343
|
+
if (remoteRecordingEnabled) {
|
|
1344
|
+
try {
|
|
1345
|
+
captureEngine?.forceCaptureWithReason("session_end_kill")
|
|
1346
|
+
} catch (e: Exception) {
|
|
1347
|
+
Logger.warning("[Rejourney] Final capture failed: ${e.message}")
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Stop capture engine
|
|
1352
|
+
try {
|
|
1353
|
+
captureEngine?.stopSession()
|
|
1354
|
+
} catch (e: Exception) {
|
|
1355
|
+
Logger.warning("[Rejourney] Stop capture failed: ${e.message}")
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Disable tracking
|
|
1359
|
+
try {
|
|
1360
|
+
touchInterceptor?.disableGlobalTracking()
|
|
1361
|
+
keyboardTracker?.stopTracking()
|
|
1362
|
+
textInputTracker?.stopTracking()
|
|
1363
|
+
} catch (e: Exception) {
|
|
1364
|
+
Logger.warning("[Rejourney] Stop tracking failed: ${e.message}")
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Build metrics
|
|
1368
|
+
var crashCount = 0
|
|
1369
|
+
var anrCount = 0
|
|
1370
|
+
var errorCount = 0
|
|
1371
|
+
for (event in sessionEvents) {
|
|
1372
|
+
when (event["type"]) {
|
|
1373
|
+
"crash" -> crashCount++
|
|
1374
|
+
"anr" -> anrCount++
|
|
1375
|
+
"error" -> errorCount++
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
|
|
1379
|
+
|
|
1380
|
+
// Upload remaining events - THIS IS THE CRITICAL HTTP CALL
|
|
1381
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Uploading final events (count=${sessionEvents.size})")
|
|
1382
|
+
val uploadSuccess = try {
|
|
1383
|
+
uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
|
|
1384
|
+
} catch (e: Exception) {
|
|
1385
|
+
Logger.warning("[Rejourney] Final upload failed: ${e.message}")
|
|
1386
|
+
false
|
|
1387
|
+
}
|
|
1388
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Upload result=$uploadSuccess")
|
|
1389
|
+
|
|
1390
|
+
// Send session end signal - THIS IS THE CRITICAL /session/end CALL
|
|
1391
|
+
if (!sessionEndSent) {
|
|
1392
|
+
sessionEndSent = true
|
|
1393
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Calling /session/end... (sessionId=$sessionId)")
|
|
1394
|
+
|
|
1395
|
+
// CRITICAL: Ensure uploadManager has the correct sessionId
|
|
1396
|
+
// Prior handleAppBackground may have cleared it, so we restore it here
|
|
1397
|
+
if (sessionId.isNotEmpty()) {
|
|
1398
|
+
uploadManager?.sessionId = sessionId
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
val endSuccess = try {
|
|
1402
|
+
uploadManager?.endSession() ?: false
|
|
1403
|
+
} catch (e: Exception) {
|
|
1404
|
+
Logger.warning("[Rejourney] Session end API call failed: ${e.message}")
|
|
1405
|
+
false
|
|
1406
|
+
}
|
|
1407
|
+
Logger.debug("[Rejourney] endSessionSynchronous: /session/end result=$endSuccess")
|
|
1408
|
+
|
|
1409
|
+
// Clear recovery markers if successful
|
|
1410
|
+
if (endSuccess) {
|
|
1411
|
+
try {
|
|
1412
|
+
uploadManager?.clearSessionRecovery(sessionId)
|
|
1413
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
1414
|
+
.edit()
|
|
1415
|
+
.putLong("rj_session_end_time_$sessionId", System.currentTimeMillis())
|
|
1416
|
+
.remove("rj_current_session_id")
|
|
1417
|
+
.remove("rj_session_start_time")
|
|
1418
|
+
.apply()
|
|
1419
|
+
} catch (e: Exception) {
|
|
1420
|
+
Logger.warning("[Rejourney] Clear recovery failed: ${e.message}")
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Clear state
|
|
1426
|
+
isRecording = false
|
|
1427
|
+
currentSessionId = null
|
|
1428
|
+
userId = null
|
|
1429
|
+
sessionEvents.clear()
|
|
1430
|
+
|
|
1431
|
+
Logger.debug("[Rejourney] endSessionSynchronous: Completed successfully")
|
|
1432
|
+
} catch (e: Exception) {
|
|
1433
|
+
Logger.error("[Rejourney] endSessionSynchronous: Error", e)
|
|
1434
|
+
isRecording = false
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Internal method to start recording with options.
|
|
1440
|
+
*/
|
|
1441
|
+
private suspend fun startRecordingInternal(
|
|
1442
|
+
options: Map<String, Any?>?,
|
|
1443
|
+
sessionId: String,
|
|
1444
|
+
source: String
|
|
1445
|
+
) {
|
|
1446
|
+
if (isRecording) {
|
|
1447
|
+
Logger.debug("Already recording, ignoring start request from $source")
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Use saved config from previous session
|
|
1452
|
+
val safeUserId = userId ?: "anonymous"
|
|
1453
|
+
val safeApiUrl = savedApiUrl.ifEmpty { "https://api.rejourney.co" }
|
|
1454
|
+
val safePublicKey = savedPublicKey.ifEmpty { "" }
|
|
1455
|
+
val deviceHash = savedDeviceHash
|
|
1456
|
+
|
|
1457
|
+
// Setup session
|
|
1458
|
+
this.userId = safeUserId
|
|
1459
|
+
currentSessionId = sessionId
|
|
1460
|
+
sessionStartTime = System.currentTimeMillis()
|
|
1461
|
+
totalBackgroundTimeMs = 0
|
|
1462
|
+
sessionEndSent = false
|
|
1463
|
+
sessionEvents.clear()
|
|
1464
|
+
resetSamplingDecision()
|
|
1465
|
+
remoteRecordingEnabled = recordingEnabledByConfig
|
|
1466
|
+
if (hasProjectConfig) {
|
|
1467
|
+
updateRecordingEligibility(projectSampleRate)
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Save session ID for crash handler
|
|
1471
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
1472
|
+
.edit()
|
|
1473
|
+
.putString("rj_current_session_id", currentSessionId)
|
|
1474
|
+
.apply()
|
|
1475
|
+
|
|
1476
|
+
// Configure upload manager
|
|
1477
|
+
uploadManager?.apply {
|
|
1478
|
+
this.apiUrl = safeApiUrl
|
|
1479
|
+
this.publicKey = safePublicKey
|
|
1480
|
+
this.deviceHash = deviceHash
|
|
1481
|
+
// NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
|
|
1482
|
+
setActiveSessionId(currentSessionId!!)
|
|
1483
|
+
this.userId = safeUserId
|
|
1484
|
+
this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
|
|
1485
|
+
resetForNewSession()
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Mark session active
|
|
1489
|
+
currentSessionId?.let { sid ->
|
|
1490
|
+
uploadManager?.markSessionActive(sid, sessionStartTime)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Initialize event buffer
|
|
1494
|
+
val pendingDir = File(reactContext.cacheDir, "rj_pending")
|
|
1495
|
+
currentSessionId?.let { sid ->
|
|
1496
|
+
eventBuffer = EventBuffer(reactContext, sid, pendingDir)
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Start capture
|
|
1500
|
+
if (remoteRecordingEnabled) {
|
|
1501
|
+
captureEngine?.startSession(currentSessionId!!)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Enable tracking
|
|
1505
|
+
touchInterceptor?.enableGlobalTracking()
|
|
1506
|
+
keyboardTracker?.startTracking()
|
|
1507
|
+
textInputTracker?.startTracking()
|
|
1508
|
+
|
|
1509
|
+
isRecording = true
|
|
1510
|
+
startBatchUploadTimer()
|
|
1511
|
+
startDurationLimitTimer()
|
|
1512
|
+
|
|
1513
|
+
Logger.logSessionStart(currentSessionId ?: "")
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
private fun fetchProjectConfig(publicKey: String, apiUrl: String) {
|
|
1517
|
+
scope.launch(Dispatchers.IO) {
|
|
1518
|
+
try {
|
|
1519
|
+
uploadManager?.fetchProjectConfig { success, config ->
|
|
1520
|
+
if (success && config != null) {
|
|
1521
|
+
hasProjectConfig = true
|
|
1522
|
+
|
|
1523
|
+
config["maxRecordingMinutes"]?.let { maxMinutes ->
|
|
1524
|
+
scope.launch(Dispatchers.Main) {
|
|
1525
|
+
maxRecordingMinutes = (maxMinutes as? Number)?.toInt() ?: 10
|
|
1526
|
+
startDurationLimitTimer()
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
val sampleRate = (config["sampleRate"] as? Number)?.toInt()
|
|
1531
|
+
if (sampleRate != null) {
|
|
1532
|
+
projectSampleRate = sampleRate.coerceIn(0, 100)
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
val recordingEnabled = (config["recordingEnabled"] as? Boolean) != false
|
|
1536
|
+
scope.launch(Dispatchers.Main) {
|
|
1537
|
+
recordingEnabledByConfig = recordingEnabled
|
|
1538
|
+
if (!recordingEnabled) {
|
|
1539
|
+
Logger.warning("Recording disabled by remote config, stopping capture only")
|
|
1540
|
+
}
|
|
1541
|
+
updateRecordingEligibility(projectSampleRate)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
config["rejourneyEnabled"]?.let { enabled ->
|
|
1545
|
+
if (enabled == false) {
|
|
1546
|
+
scope.launch(Dispatchers.Main) {
|
|
1547
|
+
Logger.warning("Rejourney disabled by remote config, stopping session")
|
|
1548
|
+
remoteRejourneyEnabled = false
|
|
1549
|
+
stopSessionInternal()
|
|
1550
|
+
}
|
|
1551
|
+
} else {
|
|
1552
|
+
remoteRejourneyEnabled = true
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
} catch (e: Exception) {
|
|
1558
|
+
Logger.error("Failed to fetch project config", e)
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
private fun registerDevice(publicKey: String, apiUrl: String) {
|
|
1564
|
+
scope.launch(Dispatchers.IO) {
|
|
1565
|
+
try {
|
|
1566
|
+
val bundleId = reactContext.packageName
|
|
1567
|
+
deviceAuthManager?.registerDevice(
|
|
1568
|
+
projectKey = publicKey,
|
|
1569
|
+
bundleId = bundleId,
|
|
1570
|
+
platform = "android",
|
|
1571
|
+
sdkVersion = Constants.SDK_VERSION,
|
|
1572
|
+
apiUrl = apiUrl
|
|
1573
|
+
) { success, credentialId, error ->
|
|
1574
|
+
if (success) {
|
|
1575
|
+
Logger.debug("Device registered: $credentialId")
|
|
1576
|
+
|
|
1577
|
+
// Auth succeeded - reset retry state
|
|
1578
|
+
resetAuthRetryState()
|
|
1579
|
+
|
|
1580
|
+
// Get upload token
|
|
1581
|
+
deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
|
|
1582
|
+
if (tokenSuccess) {
|
|
1583
|
+
// NOTE: Session recovery is now handled EXCLUSIVELY by WorkManager
|
|
1584
|
+
// The old recoverPendingSessions() approach held a mutex that blocked
|
|
1585
|
+
// all current session uploads. WorkManager.scheduleRecoveryUpload()
|
|
1586
|
+
// runs independently without blocking the current session.
|
|
1587
|
+
// Recovery is already scheduled in onLifecycleStart via UploadWorker.scheduleRecoveryUpload()
|
|
1588
|
+
|
|
1589
|
+
// Check for pending crash reports
|
|
1590
|
+
val crashHandler = CrashHandler.getInstance(reactContext)
|
|
1591
|
+
if (crashHandler.hasPendingCrashReport()) {
|
|
1592
|
+
crashHandler.loadAndPurgePendingCrashReport()?.let { crashReport ->
|
|
1593
|
+
scope.launch(Dispatchers.IO) {
|
|
1594
|
+
uploadManager?.uploadCrashReport(crashReport)
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Check for pending ANR reports
|
|
1600
|
+
val anrHandler = ANRHandler.getInstance(reactContext)
|
|
1601
|
+
if (anrHandler.hasPendingANRReport()) {
|
|
1602
|
+
anrHandler.loadAndPurgePendingANRReport()?.let { anrReport ->
|
|
1603
|
+
scope.launch(Dispatchers.IO) {
|
|
1604
|
+
uploadManager?.uploadANRReport(anrReport)
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
} else {
|
|
1609
|
+
Logger.warning("Failed to get upload token: $tokenError")
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
} else {
|
|
1613
|
+
Logger.warning("Device registration failed: $error")
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
} catch (e: Exception) {
|
|
1617
|
+
Logger.error("Device registration error", e)
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// ==================== Activity Lifecycle Callbacks ====================
|
|
1623
|
+
// Note: We prioritize ProcessLifecycleOwner for foreground/background,
|
|
1624
|
+
// but onActivityStopped is critical for immediate background detection.
|
|
1625
|
+
|
|
1626
|
+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
|
1627
|
+
|
|
1628
|
+
override fun onActivityResumed(activity: Activity) {
|
|
1629
|
+
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1630
|
+
try {
|
|
1631
|
+
Logger.debug("Activity resumed")
|
|
1632
|
+
cancelScheduledBackground()
|
|
1633
|
+
// Backup foreground detection - ProcessLifecycleOwner may not always fire
|
|
1634
|
+
if (wasInBackground) {
|
|
1635
|
+
handleAppForeground("Activity.onResume")
|
|
1636
|
+
}
|
|
1637
|
+
} catch (e: Exception) {
|
|
1638
|
+
Logger.error("SDK error in onActivityResumed (non-fatal)", e)
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
override fun onActivityPaused(activity: Activity) {
|
|
1643
|
+
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1644
|
+
try {
|
|
1645
|
+
Logger.debug("Activity paused (isFinishing=${activity.isFinishing})")
|
|
1646
|
+
|
|
1647
|
+
// Force capture immediately in case app is killed from recents
|
|
1648
|
+
if (remoteRecordingEnabled) {
|
|
1649
|
+
try {
|
|
1650
|
+
captureEngine?.forceCaptureWithReason("app_pausing")
|
|
1651
|
+
} catch (e: Exception) {
|
|
1652
|
+
Logger.warning("Pre-background capture failed: ${e.message}")
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// LIGHTWEIGHT BACKGROUND PREP for recents detection
|
|
1657
|
+
// DO NOT call full handleAppBackground here - that stops the capture engine
|
|
1658
|
+
// which causes VideoEncoder race conditions (IllegalStateException: dequeue pending)
|
|
1659
|
+
//
|
|
1660
|
+
// Instead, we:
|
|
1661
|
+
// 1. Set backgroundEntryTime (for 60s timeout calculation)
|
|
1662
|
+
// 2. Flush events to disk (so they're persisted if user swipes to kill)
|
|
1663
|
+
//
|
|
1664
|
+
// Full background handling (stopping capture engine) happens in onActivityStopped/onActivityDestroyed
|
|
1665
|
+
if (!wasInBackground && isRecording) {
|
|
1666
|
+
Logger.debug("[BG] Activity.onPause: Setting background entry time (capture engine still running)")
|
|
1667
|
+
backgroundEntryTime = System.currentTimeMillis()
|
|
1668
|
+
|
|
1669
|
+
// Flush events to disk asynchronously
|
|
1670
|
+
eventBuffer?.flush()
|
|
1671
|
+
Logger.debug("[BG] Activity.onPause: Events flushed to disk, backgroundEntryTime=$backgroundEntryTime")
|
|
1672
|
+
}
|
|
1673
|
+
} catch (e: Exception) {
|
|
1674
|
+
Logger.error("SDK error in onActivityPaused (non-fatal)", e)
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
|
1679
|
+
|
|
1680
|
+
// ==================== DefaultLifecycleObserver (ProcessLifecycleOwner) ====================
|
|
1681
|
+
|
|
1682
|
+
override fun onStart(owner: LifecycleOwner) {
|
|
1683
|
+
// Backup: if Activity callbacks failed/missed, this catches the app start
|
|
1684
|
+
try {
|
|
1685
|
+
Logger.debug("ProcessLifecycleOwner: onStart")
|
|
1686
|
+
cancelScheduledBackground()
|
|
1687
|
+
if (wasInBackground) {
|
|
1688
|
+
handleAppForeground("ProcessLifecycle.onStart")
|
|
1689
|
+
}
|
|
1690
|
+
} catch (e: Exception) {
|
|
1691
|
+
Logger.error("SDK error in ProcessLifecycleOwner.onStart", e)
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
override fun onStop(owner: LifecycleOwner) {
|
|
1696
|
+
// Backup: catch app background if Activity callbacks missed it
|
|
1697
|
+
try {
|
|
1698
|
+
Logger.debug("ProcessLifecycleOwner: onStop")
|
|
1699
|
+
if (isRecording && !wasInBackground) {
|
|
1700
|
+
// If we're recording and haven't detected background yet, do it now
|
|
1701
|
+
// ProcessLifecycleOwner is already debounced by AndroidX (700ms), so no extra delay needed
|
|
1702
|
+
handleAppBackground("ProcessLifecycle.onStop")
|
|
1703
|
+
}
|
|
1704
|
+
} catch (e: Exception) {
|
|
1705
|
+
Logger.error("SDK error in ProcessLifecycleOwner.onStop", e)
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ==================== ActivityLifecycleCallbacks (Backup/Early Detection) ====================
|
|
1710
|
+
|
|
1711
|
+
override fun onActivityStarted(activity: Activity) {
|
|
1712
|
+
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1713
|
+
try {
|
|
1714
|
+
Logger.debug("Activity started")
|
|
1715
|
+
cancelScheduledBackground()
|
|
1716
|
+
if (wasInBackground) {
|
|
1717
|
+
handleAppForeground("Activity.onStart")
|
|
1718
|
+
}
|
|
1719
|
+
} catch (e: Exception) {
|
|
1720
|
+
Logger.error("SDK error in onActivityStarted (non-fatal)", e)
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
override fun onActivityStopped(activity: Activity) {
|
|
1725
|
+
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1726
|
+
try {
|
|
1727
|
+
if (activity.isChangingConfigurations) {
|
|
1728
|
+
Logger.debug("Activity stopped but changing configurations - skipping background")
|
|
1729
|
+
return
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (activity.isFinishing) {
|
|
1733
|
+
// App is closing/killed - IMMEDIATE background handling + FORCE SESSION END
|
|
1734
|
+
// Do not use debounce (scheduleBackground) because app kills (swipes) can terminate process instantly
|
|
1735
|
+
Logger.debug("Activity stopped and finishing - triggering IMMEDIATE background and ENDING SESSION")
|
|
1736
|
+
cancelScheduledBackground()
|
|
1737
|
+
handleAppBackground("Activity.onStop:finishing", shouldEndSession = true)
|
|
1738
|
+
} else {
|
|
1739
|
+
// Normal background - immediate handling (no debounce needed for single activity)
|
|
1740
|
+
// BUT do NOT end session (just flush)
|
|
1741
|
+
Logger.debug("Activity stopped - triggering IMMEDIATE background")
|
|
1742
|
+
cancelScheduledBackground()
|
|
1743
|
+
handleAppBackground("Activity.onStop", shouldEndSession = false)
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
} catch (e: Exception) {
|
|
1747
|
+
Logger.error("SDK error in onActivityStopped (non-fatal)", e)
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
override fun onActivityDestroyed(activity: Activity) {
|
|
1752
|
+
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
1753
|
+
try {
|
|
1754
|
+
if (activity.isChangingConfigurations) return
|
|
1755
|
+
|
|
1756
|
+
// Redundant backup: ensure background triggered if somehow missed in onStop
|
|
1757
|
+
// FORCE SESSION END
|
|
1758
|
+
Logger.debug("Activity destroyed (isFinishing=${activity.isFinishing}) - triggering IMMEDIATE background")
|
|
1759
|
+
handleAppBackground("Activity.onDestroy", shouldEndSession = true)
|
|
1760
|
+
|
|
1761
|
+
} catch (e: Exception) {
|
|
1762
|
+
Logger.error("SDK error in onActivityDestroyed (non-fatal)", e)
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
private fun scheduleBackground(source: String) {
|
|
1767
|
+
if (wasInBackground || backgroundScheduled) return
|
|
1768
|
+
|
|
1769
|
+
// NOTE: This method is now kept mainly for onPause if we decide to use it,
|
|
1770
|
+
// or for legacy debounce logic. Currently onActivityStopped uses immediate handling.
|
|
1771
|
+
backgroundScheduled = true
|
|
1772
|
+
backgroundEntryTime = System.currentTimeMillis()
|
|
1773
|
+
|
|
1774
|
+
Logger.debug("Scheduling background in 50ms (source=$source)")
|
|
1775
|
+
|
|
1776
|
+
val runnable = Runnable {
|
|
1777
|
+
backgroundScheduled = false
|
|
1778
|
+
handleAppBackground("$source:debounced")
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
scheduledBackgroundRunnable = runnable
|
|
1782
|
+
mainHandler.postDelayed(runnable, 50L) // Reduced to 50ms
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
private fun cancelScheduledBackground() {
|
|
1786
|
+
if (!backgroundScheduled) return
|
|
1787
|
+
scheduledBackgroundRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
1788
|
+
scheduledBackgroundRunnable = null
|
|
1789
|
+
backgroundScheduled = false
|
|
1790
|
+
if (!wasInBackground) {
|
|
1791
|
+
backgroundEntryTime = 0L
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Foreground handler - handles return from background with session timeout logic.
|
|
1797
|
+
*
|
|
1798
|
+
* Session Behavior (matching iOS):
|
|
1799
|
+
* - Background < 60s: Resume same session, accumulate background time for billing exclusion
|
|
1800
|
+
* - Background >= 60s: End old session, start new session
|
|
1801
|
+
* - App killed in background: Auto-finalized by backend worker after 60s
|
|
1802
|
+
*/
|
|
1803
|
+
private fun handleAppForeground(source: String) {
|
|
1804
|
+
if (!wasInBackground || backgroundEntryTime == 0L) {
|
|
1805
|
+
Logger.debug("[FG] Not returning from background, skipping")
|
|
1806
|
+
return
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
val bgDurationMs = System.currentTimeMillis() - backgroundEntryTime
|
|
1810
|
+
val bgDurationSec = bgDurationMs / 1000.0
|
|
1811
|
+
val sessionTimeoutMs = (Constants.BACKGROUND_SESSION_TIMEOUT * 1000).toLong()
|
|
1812
|
+
val thresholdSec = Constants.BACKGROUND_SESSION_TIMEOUT
|
|
1813
|
+
|
|
1814
|
+
Logger.debug("[FG] === APP FOREGROUND ($source) ===")
|
|
1815
|
+
Logger.debug("[FG] Was in background for ${String.format("%.1f", bgDurationSec)}s")
|
|
1816
|
+
Logger.debug("[FG] Session timeout threshold: ${thresholdSec}s")
|
|
1817
|
+
Logger.debug("[FG] Current totalBackgroundTimeMs: $totalBackgroundTimeMs")
|
|
1818
|
+
|
|
1819
|
+
// Reset background tracking state immediately (like iOS)
|
|
1820
|
+
wasInBackground = false
|
|
1821
|
+
backgroundEntryTime = 0
|
|
1822
|
+
|
|
1823
|
+
if (bgDurationMs >= sessionTimeoutMs) {
|
|
1824
|
+
// === TIMEOUT CASE: End old session, start new one ===
|
|
1825
|
+
Logger.debug("[FG] TIMEOUT: ${bgDurationSec}s >= ${thresholdSec}s → Creating NEW session")
|
|
1826
|
+
handleSessionTimeoutOnForeground(bgDurationMs, source)
|
|
1827
|
+
} else {
|
|
1828
|
+
// === SHORT BACKGROUND: Resume same session ===
|
|
1829
|
+
Logger.debug("[FG] SHORT BACKGROUND: ${bgDurationSec}s < ${thresholdSec}s → Resuming SAME session")
|
|
1830
|
+
handleShortBackgroundResume(bgDurationMs, source)
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* Handle session timeout after extended background (>= 60s).
|
|
1836
|
+
* Ends the old session and starts a fresh one.
|
|
1837
|
+
*
|
|
1838
|
+
* CRITICAL: Uses backgroundScope + NonCancellable to ensure recovery completes
|
|
1839
|
+
* even if the app goes to background again during this process.
|
|
1840
|
+
*/
|
|
1841
|
+
private fun handleSessionTimeoutOnForeground(bgDurationMs: Long, source: String) {
|
|
1842
|
+
val oldSessionId = currentSessionId ?: return
|
|
1843
|
+
val wasRecording = isRecording
|
|
1844
|
+
|
|
1845
|
+
if (!wasRecording) {
|
|
1846
|
+
Logger.debug("Session timeout but wasn't recording - ignoring")
|
|
1847
|
+
return
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
Logger.debug("SESSION TIMEOUT: Ending session $oldSessionId after ${bgDurationMs/1000}s in background")
|
|
1851
|
+
|
|
1852
|
+
// Add final background time to accumulated total before ending
|
|
1853
|
+
totalBackgroundTimeMs += bgDurationMs
|
|
1854
|
+
uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
|
|
1855
|
+
|
|
1856
|
+
// Stop all capture/tracking immediately (synchronous, like iOS)
|
|
1857
|
+
try {
|
|
1858
|
+
stopBatchUploadTimer()
|
|
1859
|
+
stopDurationLimitTimer()
|
|
1860
|
+
captureEngine?.stopSession()
|
|
1861
|
+
touchInterceptor?.disableGlobalTracking()
|
|
1862
|
+
keyboardTracker?.stopTracking()
|
|
1863
|
+
textInputTracker?.stopTracking()
|
|
1864
|
+
} catch (e: Exception) {
|
|
1865
|
+
Logger.warning("Error stopping capture during session timeout: ${e.message}")
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Mark as not recording to prevent race conditions
|
|
1869
|
+
isRecording = false
|
|
1870
|
+
|
|
1871
|
+
// Handle old session end and new session start asynchronously using backgroundScope
|
|
1872
|
+
// which survives independently of the main scope and won't be cancelled on background
|
|
1873
|
+
backgroundScope.launch {
|
|
1874
|
+
// Use NonCancellable context to ensure critical recovery operations complete
|
|
1875
|
+
// even if the coroutine is cancelled (app goes to background again)
|
|
1876
|
+
withContext(NonCancellable) {
|
|
1877
|
+
try {
|
|
1878
|
+
// CRITICAL: Ensure auth token is valid before uploading
|
|
1879
|
+
// Token may have expired during the 60+ seconds in background
|
|
1880
|
+
try {
|
|
1881
|
+
DeviceAuthManager.getInstance(reactContext).ensureValidToken()
|
|
1882
|
+
} catch (e: Exception) {
|
|
1883
|
+
Logger.warning("Failed to refresh auth token during session timeout: ${e.message}")
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Add session_timeout event to old session's events
|
|
1887
|
+
val timeoutEvent = mapOf(
|
|
1888
|
+
"type" to EventType.SESSION_TIMEOUT,
|
|
1889
|
+
"timestamp" to System.currentTimeMillis(),
|
|
1890
|
+
"backgroundDuration" to bgDurationMs,
|
|
1891
|
+
"timeoutThreshold" to (Constants.BACKGROUND_SESSION_TIMEOUT * 1000).toLong(),
|
|
1892
|
+
"reason" to "background_timeout"
|
|
1893
|
+
)
|
|
1894
|
+
sessionEvents.add(timeoutEvent)
|
|
1895
|
+
|
|
1896
|
+
// Upload old session's events as final and call session/end
|
|
1897
|
+
val finalEvents = sessionEvents.toList()
|
|
1898
|
+
sessionEvents.clear()
|
|
1899
|
+
|
|
1900
|
+
if (finalEvents.isNotEmpty()) {
|
|
1901
|
+
try {
|
|
1902
|
+
uploadManager?.uploadBatch(finalEvents, isFinal = true)
|
|
1903
|
+
} catch (e: Exception) {
|
|
1904
|
+
Logger.warning("Failed to upload final events during session timeout: ${e.message}")
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// End the old session (calls /session/end which triggers promotion)
|
|
1909
|
+
// CRITICAL: Pass oldSessionId explicitly since uploadManager.sessionId may be reset
|
|
1910
|
+
var endSessionSuccess = false
|
|
1911
|
+
if (!sessionEndSent) {
|
|
1912
|
+
sessionEndSent = true
|
|
1913
|
+
try {
|
|
1914
|
+
endSessionSuccess = uploadManager?.endSession(sessionIdOverride = oldSessionId) ?: false
|
|
1915
|
+
} catch (e: Exception) {
|
|
1916
|
+
Logger.warning("Failed to end old session: ${e.message}")
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// Clear recovery markers for old session
|
|
1921
|
+
try {
|
|
1922
|
+
uploadManager?.clearSessionRecovery(oldSessionId)
|
|
1923
|
+
} catch (e: Exception) {
|
|
1924
|
+
Logger.warning("Failed to clear session recovery: ${e.message}")
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
if (endSessionSuccess) {
|
|
1928
|
+
Logger.debug("Old session $oldSessionId ended successfully")
|
|
1929
|
+
} else {
|
|
1930
|
+
Logger.warning("Old session $oldSessionId end signal failed - will be recovered on next launch")
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// === START NEW SESSION ===
|
|
1934
|
+
val timestamp = System.currentTimeMillis()
|
|
1935
|
+
val shortUuid = UUID.randomUUID().toString().take(8).uppercase()
|
|
1936
|
+
val newSessionId = "session_${timestamp}_$shortUuid"
|
|
1937
|
+
|
|
1938
|
+
// Reset state for new session
|
|
1939
|
+
currentSessionId = newSessionId
|
|
1940
|
+
sessionStartTime = timestamp
|
|
1941
|
+
totalBackgroundTimeMs = 0
|
|
1942
|
+
sessionEndSent = false
|
|
1943
|
+
|
|
1944
|
+
// Reset upload manager for new session
|
|
1945
|
+
uploadManager?.let { um ->
|
|
1946
|
+
// NUCLEAR FIX: Use setActiveSessionId() to update both sessionId AND activeSessionId
|
|
1947
|
+
// This ensures the new session doesn't get merged into the old one's recovery path
|
|
1948
|
+
um.setActiveSessionId(newSessionId)
|
|
1949
|
+
|
|
1950
|
+
um.sessionStartTime = timestamp
|
|
1951
|
+
um.totalBackgroundTimeMs = 0
|
|
1952
|
+
|
|
1953
|
+
// FIX: Synchronize user identity and config to UploadManager
|
|
1954
|
+
// This matches iOS behavior and ensures robustness if memory was cleared
|
|
1955
|
+
um.userId = userId ?: "anonymous"
|
|
1956
|
+
|
|
1957
|
+
// Restore saved config if available
|
|
1958
|
+
if (savedDeviceHash.isNotEmpty()) um.deviceHash = savedDeviceHash
|
|
1959
|
+
if (savedPublicKey.isNotEmpty()) um.publicKey = savedPublicKey
|
|
1960
|
+
if (savedApiUrl.isNotEmpty()) um.apiUrl = savedApiUrl
|
|
1961
|
+
|
|
1962
|
+
// CRITICAL: Create session metadata file for crash recovery
|
|
1963
|
+
um.markSessionActive(newSessionId, timestamp)
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// CRITICAL: Save new session ID to SharedPreferences for unclosed session detection
|
|
1967
|
+
// Use commit() instead of apply() to ensure synchronous write before app kill
|
|
1968
|
+
reactContext.getSharedPreferences("rejourney", 0)
|
|
1969
|
+
.edit()
|
|
1970
|
+
.putString("rj_current_session_id", newSessionId)
|
|
1971
|
+
.putLong("rj_session_start_time", timestamp)
|
|
1972
|
+
.commit()
|
|
1973
|
+
|
|
1974
|
+
// CRITICAL: Re-initialize EventBuffer for new session
|
|
1975
|
+
// Without this, events are written to wrong session's file
|
|
1976
|
+
val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
|
|
1977
|
+
eventBuffer = EventBuffer(reactContext, newSessionId, pendingDir)
|
|
1978
|
+
|
|
1979
|
+
// Start capture for new session (run on main thread for UI safety)
|
|
1980
|
+
withContext(Dispatchers.Main) {
|
|
1981
|
+
try {
|
|
1982
|
+
resetSamplingDecision()
|
|
1983
|
+
remoteRecordingEnabled = recordingEnabledByConfig
|
|
1984
|
+
if (hasProjectConfig) {
|
|
1985
|
+
updateRecordingEligibility(projectSampleRate)
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (remoteRecordingEnabled) {
|
|
1989
|
+
captureEngine?.startSession(newSessionId)
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Re-enable tracking
|
|
1993
|
+
touchInterceptor?.enableGlobalTracking()
|
|
1994
|
+
keyboardTracker?.startTracking()
|
|
1995
|
+
textInputTracker?.startTracking()
|
|
1996
|
+
|
|
1997
|
+
// CRITICAL: Restart SessionLifecycleService for the new session
|
|
1998
|
+
// The system destroys it after ~60 seconds in background
|
|
1999
|
+
// Without this, onTaskRemoved won't be called and session won't end properly
|
|
2000
|
+
try {
|
|
2001
|
+
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
2002
|
+
reactContext.startService(serviceIntent)
|
|
2003
|
+
Logger.debug("SessionLifecycleService restarted for new session after timeout")
|
|
2004
|
+
} catch (e: Exception) {
|
|
2005
|
+
Logger.warning("Failed to restart SessionLifecycleService: ${e.message}")
|
|
2006
|
+
}
|
|
2007
|
+
} catch (e: Exception) {
|
|
2008
|
+
Logger.warning("Error starting capture for new session: ${e.message}")
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
isRecording = true
|
|
2013
|
+
|
|
2014
|
+
// Add session_start event for new session
|
|
2015
|
+
val sessionStartEvent = mapOf(
|
|
2016
|
+
"type" to EventType.SESSION_START,
|
|
2017
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2018
|
+
"previousSessionId" to oldSessionId,
|
|
2019
|
+
"backgroundDuration" to bgDurationMs,
|
|
2020
|
+
"reason" to "resumed_after_background_timeout",
|
|
2021
|
+
"userId" to (userId ?: "anonymous")
|
|
2022
|
+
)
|
|
2023
|
+
addEventWithPersistence(sessionStartEvent)
|
|
2024
|
+
|
|
2025
|
+
// Start timers for new session
|
|
2026
|
+
startBatchUploadTimer()
|
|
2027
|
+
startDurationLimitTimer()
|
|
2028
|
+
|
|
2029
|
+
// Trigger immediate upload to register new session
|
|
2030
|
+
delay(100)
|
|
2031
|
+
try {
|
|
2032
|
+
performBatchUpload()
|
|
2033
|
+
} catch (e: Exception) {
|
|
2034
|
+
Logger.warning("Failed to perform immediate batch upload: ${e.message}")
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
Logger.debug("New session $newSessionId started (previous: $oldSessionId)")
|
|
2038
|
+
|
|
2039
|
+
} catch (e: CancellationException) {
|
|
2040
|
+
// Coroutine was cancelled but NonCancellable should prevent this
|
|
2041
|
+
// Log as warning, not error - this is expected if app is killed
|
|
2042
|
+
Logger.warning("Session timeout recovery interrupted: ${e.message}")
|
|
2043
|
+
// Ensure recording state is consistent
|
|
2044
|
+
isRecording = true
|
|
2045
|
+
startBatchUploadTimer()
|
|
2046
|
+
} catch (e: Exception) {
|
|
2047
|
+
Logger.error("Failed to handle session timeout", e)
|
|
2048
|
+
// Attempt recovery - restart recording
|
|
2049
|
+
isRecording = true
|
|
2050
|
+
startBatchUploadTimer()
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* Handle short background return (< 60s) - resume same session.
|
|
2058
|
+
*/
|
|
2059
|
+
private fun handleShortBackgroundResume(bgDurationMs: Long, source: String) {
|
|
2060
|
+
// Accumulate background time for billing exclusion
|
|
2061
|
+
val previousBgTime = totalBackgroundTimeMs
|
|
2062
|
+
totalBackgroundTimeMs += bgDurationMs
|
|
2063
|
+
uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
|
|
2064
|
+
currentSessionId?.let { sid ->
|
|
2065
|
+
uploadManager?.updateSessionRecoveryMeta(sid)
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
Logger.debug("[FG] Background time: $previousBgTime + $bgDurationMs = $totalBackgroundTimeMs ms")
|
|
2069
|
+
Logger.debug("[FG] Resuming session: $currentSessionId")
|
|
2070
|
+
|
|
2071
|
+
if (!isRecording || currentSessionId.isNullOrEmpty()) {
|
|
2072
|
+
Logger.debug("[FG] Not recording or no session - skipping resume")
|
|
2073
|
+
return
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// Log foreground event for replay player
|
|
2077
|
+
addEventWithPersistence(
|
|
2078
|
+
mapOf(
|
|
2079
|
+
"type" to EventType.APP_FOREGROUND,
|
|
2080
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2081
|
+
"backgroundDurationMs" to bgDurationMs,
|
|
2082
|
+
"totalBackgroundTimeMs" to totalBackgroundTimeMs,
|
|
2083
|
+
"source" to source
|
|
2084
|
+
)
|
|
2085
|
+
)
|
|
2086
|
+
|
|
2087
|
+
// Resume capture and tracking
|
|
2088
|
+
if (remoteRecordingEnabled) {
|
|
2089
|
+
try {
|
|
2090
|
+
captureEngine?.startSession(currentSessionId!!)
|
|
2091
|
+
captureEngine?.forceCaptureWithReason("app_foreground")
|
|
2092
|
+
} catch (e: Exception) {
|
|
2093
|
+
Logger.warning("Foreground capture resume failed: ${e.message}")
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
touchInterceptor?.enableGlobalTracking()
|
|
2098
|
+
keyboardTracker?.startTracking()
|
|
2099
|
+
textInputTracker?.startTracking()
|
|
2100
|
+
|
|
2101
|
+
startBatchUploadTimer()
|
|
2102
|
+
startDurationLimitTimer()
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Background handler (aligned with iOS + replay player expectations).
|
|
2107
|
+
*
|
|
2108
|
+
* We treat background as a pause:
|
|
2109
|
+
* - log app_background
|
|
2110
|
+
* - flush pending data (NOT final)
|
|
2111
|
+
* - stop capture/tracking while backgrounded
|
|
2112
|
+
*
|
|
2113
|
+
* If the process is killed (shouldEndSession=true), crash-safe persistence + next-launch recovery will
|
|
2114
|
+
* upload remaining pending data and close the session via session/end.
|
|
2115
|
+
*/
|
|
2116
|
+
private fun handleAppBackground(source: String, shouldEndSession: Boolean = false) {
|
|
2117
|
+
// Prevent duplicate background handling (unless forcing end)
|
|
2118
|
+
if (wasInBackground && !shouldEndSession) {
|
|
2119
|
+
Logger.debug("[BG] Already in background, skipping duplicate handling")
|
|
2120
|
+
return
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
Logger.debug("[BG] === APP BACKGROUND ($source) ===")
|
|
2124
|
+
Logger.debug("[BG] isRecording=$isRecording, isShuttingDown=$isShuttingDown, sessionId=$currentSessionId, shouldEndSession=$shouldEndSession")
|
|
2125
|
+
|
|
2126
|
+
if (isRecording && !isShuttingDown) {
|
|
2127
|
+
wasInBackground = true
|
|
2128
|
+
// backgroundEntryTime is already set by debounce scheduling
|
|
2129
|
+
if (backgroundEntryTime == 0L) {
|
|
2130
|
+
backgroundEntryTime = System.currentTimeMillis()
|
|
2131
|
+
}
|
|
2132
|
+
Logger.debug("[BG] backgroundEntryTime set to $backgroundEntryTime")
|
|
2133
|
+
Logger.debug("[BG] Current totalBackgroundTimeMs=$totalBackgroundTimeMs")
|
|
2134
|
+
|
|
2135
|
+
// Stop timers (but don't cancel in-progress uploads)
|
|
2136
|
+
stopBatchUploadTimer()
|
|
2137
|
+
stopDurationLimitTimer()
|
|
2138
|
+
|
|
2139
|
+
// Stop tracking
|
|
2140
|
+
keyboardTracker?.stopTracking()
|
|
2141
|
+
textInputTracker?.stopTracking()
|
|
2142
|
+
touchInterceptor?.disableGlobalTracking()
|
|
2143
|
+
|
|
2144
|
+
// Add background event
|
|
2145
|
+
val event = mapOf(
|
|
2146
|
+
"type" to EventType.APP_BACKGROUND,
|
|
2147
|
+
"timestamp" to System.currentTimeMillis()
|
|
2148
|
+
)
|
|
2149
|
+
addEventWithPersistence(event)
|
|
2150
|
+
|
|
2151
|
+
// CRITICAL: Ensure all in-memory events are written to disk before scheduling upload
|
|
2152
|
+
// EventBuffer uses async writes, so we need to flush to ensure all writes complete
|
|
2153
|
+
Logger.debug("[BG] ===== ENSURING ALL EVENTS ARE PERSISTED TO DISK =====")
|
|
2154
|
+
Logger.debug("[BG] In-memory events count: ${sessionEvents.size}")
|
|
2155
|
+
Logger.debug("[BG] Event types in memory: ${sessionEvents.map { it["type"] }.joinToString(", ")}")
|
|
2156
|
+
|
|
2157
|
+
// Log event buffer state before flush
|
|
2158
|
+
eventBuffer?.let { buffer ->
|
|
2159
|
+
Logger.debug("[BG] EventBuffer state: eventCount=${buffer.eventCount}, fileExists=${File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl").exists()}")
|
|
2160
|
+
} ?: Logger.warning("[BG] EventBuffer is NULL - cannot flush events!")
|
|
2161
|
+
|
|
2162
|
+
// Flush all pending writes to disk
|
|
2163
|
+
// This drains the async write queue and ensures all events are on disk
|
|
2164
|
+
val flushStartTime = System.currentTimeMillis()
|
|
2165
|
+
val flushSuccess = eventBuffer?.flush() ?: false
|
|
2166
|
+
val flushDuration = System.currentTimeMillis() - flushStartTime
|
|
2167
|
+
|
|
2168
|
+
if (flushSuccess) {
|
|
2169
|
+
Logger.debug("[BG] ✅ Events flushed to disk successfully in ${flushDuration}ms")
|
|
2170
|
+
Logger.debug("[BG] In-memory events: ${sessionEvents.size}, EventBuffer eventCount: ${eventBuffer?.eventCount ?: 0}")
|
|
2171
|
+
|
|
2172
|
+
// Verify file exists and has content
|
|
2173
|
+
val eventsFile = File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl")
|
|
2174
|
+
if (eventsFile.exists()) {
|
|
2175
|
+
val fileSize = eventsFile.length()
|
|
2176
|
+
Logger.debug("[BG] Events file exists: size=$fileSize bytes, path=${eventsFile.absolutePath}")
|
|
2177
|
+
} else {
|
|
2178
|
+
Logger.error("[BG] ⚠️ Events file does NOT exist after flush! path=${eventsFile.absolutePath}")
|
|
2179
|
+
}
|
|
2180
|
+
} else {
|
|
2181
|
+
Logger.error("[BG] ❌ FAILED to flush events to disk - some events may be lost!")
|
|
2182
|
+
Logger.error("[BG] Flush duration: ${flushDuration}ms")
|
|
2183
|
+
}
|
|
2184
|
+
Logger.debug("[BG] ===== EVENT PERSISTENCE CHECK COMPLETE =====")
|
|
2185
|
+
|
|
2186
|
+
// Stop capture engine while backgrounded (triggers final segment flush and hierarchy upload)
|
|
2187
|
+
if (remoteRecordingEnabled) {
|
|
2188
|
+
Logger.debug("[BG] ===== STOPPING CAPTURE ENGINE =====")
|
|
2189
|
+
|
|
2190
|
+
if (shouldEndSession) {
|
|
2191
|
+
// Kill scenario: use emergency flush to save crash metadata and stop ASAP
|
|
2192
|
+
Logger.debug("[BG] Force kill detected - using emergency flush")
|
|
2193
|
+
captureEngine?.emergencyFlush()
|
|
2194
|
+
// Continue to stopSession for hierarchy upload and other cleanup
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
Logger.debug("[BG] Stopping capture engine for background (sessionId=$currentSessionId)")
|
|
2198
|
+
captureEngine?.stopSession()
|
|
2199
|
+
Logger.debug("[BG] Capture engine stopSession() called")
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// Update session recovery meta so WorkManager can find the session
|
|
2203
|
+
currentSessionId?.let { sid ->
|
|
2204
|
+
uploadManager?.updateSessionRecoveryMeta(sid)
|
|
2205
|
+
Logger.debug("[BG] Session recovery metadata updated for: $sid")
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// ===== SIMPLIFIED UPLOAD: WorkManager Only =====
|
|
2209
|
+
// Industry-standard approach: persist first (done above), then schedule background worker.
|
|
2210
|
+
// NO synchronous uploads - they cause ANRs and mutex contention with recovery.
|
|
2211
|
+
// WorkManager is reliable for background uploads when properly configured.
|
|
2212
|
+
currentSessionId?.let { sid ->
|
|
2213
|
+
Logger.debug("[BG] ===== SCHEDULING WORKMANAGER UPLOAD =====")
|
|
2214
|
+
Logger.debug("[BG] Session: $sid, Events persisted: ${eventBuffer?.eventCount ?: 0}, isFinal: $shouldEndSession")
|
|
2215
|
+
|
|
2216
|
+
// Clear in-memory events since they're persisted to disk
|
|
2217
|
+
// WorkManager will read from disk
|
|
2218
|
+
sessionEvents.clear()
|
|
2219
|
+
|
|
2220
|
+
// Schedule expedited upload via WorkManager
|
|
2221
|
+
UploadWorker.scheduleUpload(
|
|
2222
|
+
context = reactContext,
|
|
2223
|
+
sessionId = sid,
|
|
2224
|
+
isFinal = shouldEndSession, // Pass shouldEndSession as isFinal
|
|
2225
|
+
expedited = true // Request expedited execution
|
|
2226
|
+
)
|
|
2227
|
+
Logger.debug("[BG] ✅ WorkManager upload scheduled for session: $sid")
|
|
2228
|
+
|
|
2229
|
+
// NEW: Best-effort immediate upload (Fire-and-Forget)
|
|
2230
|
+
// Try to upload immediately while app is still alive in memory.
|
|
2231
|
+
// If this succeeds, WorkManager will find nothing to do (which is fine).
|
|
2232
|
+
// If this fails/gets killed, WorkManager will pick it up.
|
|
2233
|
+
// This mimics iOS "beginBackgroundTask" pattern.
|
|
2234
|
+
scope.launch(Dispatchers.IO) {
|
|
2235
|
+
try {
|
|
2236
|
+
Logger.debug("[BG] 🚀 Attempting immediate best-effort upload for $sid")
|
|
2237
|
+
|
|
2238
|
+
// Create a temporary UploadManager because the main one's state is complex
|
|
2239
|
+
// We use the same parameters as WorkManager creates
|
|
2240
|
+
val authManager = DeviceAuthManager.getInstance(reactContext)
|
|
2241
|
+
val apiUrl = authManager.getCurrentApiUrl() ?: "https://api.rejourney.co"
|
|
2242
|
+
|
|
2243
|
+
val bgUploader = com.rejourney.network.UploadManager(reactContext, apiUrl).apply {
|
|
2244
|
+
this.sessionId = sid
|
|
2245
|
+
this.setActiveSessionId(sid) // CRITICAL: Set active session ID
|
|
2246
|
+
this.publicKey = authManager.getCurrentPublicKey() ?: ""
|
|
2247
|
+
this.deviceHash = authManager.getCurrentDeviceHash() ?: ""
|
|
2248
|
+
this.sessionStartTime = uploadManager?.sessionStartTime ?: 0L
|
|
2249
|
+
this.totalBackgroundTimeMs = uploadManager?.totalBackgroundTimeMs ?: 0L
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// Read events from disk since we flushed them
|
|
2253
|
+
val eventBufferDir = File(reactContext.cacheDir, "rj_pending/$sid")
|
|
2254
|
+
val eventsFile = File(eventBufferDir, "events.jsonl")
|
|
2255
|
+
|
|
2256
|
+
if (eventsFile.exists()) {
|
|
2257
|
+
// Read events - duplicated logic from UploadWorker but necessary for successful off-main-thread upload
|
|
2258
|
+
val events = mutableListOf<Map<String, Any?>>()
|
|
2259
|
+
eventsFile.bufferedReader().useLines { lines ->
|
|
2260
|
+
lines.forEach { line ->
|
|
2261
|
+
if (line.isNotBlank()) {
|
|
2262
|
+
try {
|
|
2263
|
+
val json = org.json.JSONObject(line)
|
|
2264
|
+
val map = mutableMapOf<String, Any?>()
|
|
2265
|
+
json.keys().forEach { key ->
|
|
2266
|
+
map[key] = json.opt(key)
|
|
2267
|
+
}
|
|
2268
|
+
events.add(map)
|
|
2269
|
+
} catch (e: Exception) { }
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
if (events.isNotEmpty()) {
|
|
2275
|
+
Logger.debug("[BG] Immediate upload: found ${events.size} events")
|
|
2276
|
+
val success = bgUploader.uploadBatch(events, isFinal = shouldEndSession)
|
|
2277
|
+
if (success) {
|
|
2278
|
+
Logger.debug("[BG] ✅ Immediate upload SUCCESS! Cleaning up disk...")
|
|
2279
|
+
// Clean up so WorkManager doesn't re-upload
|
|
2280
|
+
eventsFile.delete()
|
|
2281
|
+
File(eventBufferDir, "buffer_meta.json").delete()
|
|
2282
|
+
|
|
2283
|
+
// IF FINAL, END SESSION IMMEDIATELY
|
|
2284
|
+
if (shouldEndSession) {
|
|
2285
|
+
Logger.debug("[BG] Immediate upload was final, ending session...")
|
|
2286
|
+
bgUploader.endSession()
|
|
2287
|
+
}
|
|
2288
|
+
} else {
|
|
2289
|
+
Logger.warning("[BG] Immediate upload failed - leaving for WorkManager")
|
|
2290
|
+
}
|
|
2291
|
+
} else if (shouldEndSession) {
|
|
2292
|
+
// Even if no events, if it's final, we should try to end session
|
|
2293
|
+
Logger.debug("[BG] No events but shouldEndSession=true, ending session...")
|
|
2294
|
+
bgUploader.endSession()
|
|
2295
|
+
}
|
|
2296
|
+
} else if (shouldEndSession) {
|
|
2297
|
+
// Even if no event file, if it's final, we should try to end session
|
|
2298
|
+
Logger.debug("[BG] No event file but shouldEndSession=true, ending session...")
|
|
2299
|
+
bgUploader.endSession()
|
|
2300
|
+
}
|
|
2301
|
+
} catch (e: Exception) {
|
|
2302
|
+
Logger.error("[BG] Immediate upload error: ${e.message} - WorkManager will handle it")
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
} // End of currentSessionId?.let
|
|
2306
|
+
} else {
|
|
2307
|
+
Logger.debug("[BG] Skipping background handling (isRecording=$isRecording, isShuttingDown=$isShuttingDown)")
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// ==================== TouchInterceptorDelegate ====================
|
|
2312
|
+
|
|
2313
|
+
override fun onTouchEvent(event: MotionEvent, gestureType: String?) {
|
|
2314
|
+
// We rely primarily on onGestureRecognized, but can add raw touches if needed.
|
|
2315
|
+
// For now, to match iOS "touches visited", we can treat simple taps here if needed,
|
|
2316
|
+
// but gestureClassifier usually handles it.
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
override fun onGestureRecognized(gestureType: String, x: Float, y: Float, details: Map<String, Any?>) {
|
|
2320
|
+
if (!isRecording) return
|
|
2321
|
+
|
|
2322
|
+
try {
|
|
2323
|
+
val timestamp = System.currentTimeMillis()
|
|
2324
|
+
|
|
2325
|
+
// Build touches array matching iOS format for web player compatibility
|
|
2326
|
+
// Web player filters events without touches array (e.touches.length > 0)
|
|
2327
|
+
val touchPoint = mapOf(
|
|
2328
|
+
"x" to x,
|
|
2329
|
+
"y" to y,
|
|
2330
|
+
"timestamp" to timestamp,
|
|
2331
|
+
"force" to (details["force"] ?: 0f)
|
|
2332
|
+
)
|
|
2333
|
+
|
|
2334
|
+
val eventMap = mapOf(
|
|
2335
|
+
"type" to EventType.GESTURE,
|
|
2336
|
+
"timestamp" to timestamp,
|
|
2337
|
+
"gestureType" to gestureType,
|
|
2338
|
+
"touches" to listOf(touchPoint), // Required by web player TouchOverlay
|
|
2339
|
+
"duration" to (details["duration"] ?: 0),
|
|
2340
|
+
"targetLabel" to details["targetLabel"],
|
|
2341
|
+
"x" to x, // Keep for backwards compatibility
|
|
2342
|
+
"y" to y,
|
|
2343
|
+
"details" to details
|
|
2344
|
+
)
|
|
2345
|
+
|
|
2346
|
+
// Debug logging to verify touch events are captured correctly for web overlay
|
|
2347
|
+
Logger.debug("[TOUCH] Gesture recorded: type=$gestureType, x=$x, y=$y, touches=${listOf(touchPoint)}")
|
|
2348
|
+
|
|
2349
|
+
addEventWithPersistence(eventMap)
|
|
2350
|
+
} catch (e: Exception) {
|
|
2351
|
+
Logger.error("Failed to record gesture", e)
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
override fun onGestureWithTouchPath(
|
|
2356
|
+
gestureType: String,
|
|
2357
|
+
touches: List<Map<String, Any>>,
|
|
2358
|
+
duration: Long,
|
|
2359
|
+
targetLabel: String?
|
|
2360
|
+
) {
|
|
2361
|
+
if (!isRecording) return
|
|
2362
|
+
|
|
2363
|
+
try {
|
|
2364
|
+
val timestamp = System.currentTimeMillis()
|
|
2365
|
+
val firstTouch = touches.firstOrNull()
|
|
2366
|
+
val x = (firstTouch?.get("x") as? Number)?.toFloat() ?: 0f
|
|
2367
|
+
val y = (firstTouch?.get("y") as? Number)?.toFloat() ?: 0f
|
|
2368
|
+
|
|
2369
|
+
val eventMap = mapOf(
|
|
2370
|
+
"type" to EventType.GESTURE,
|
|
2371
|
+
"timestamp" to timestamp,
|
|
2372
|
+
"gestureType" to gestureType,
|
|
2373
|
+
"touches" to touches,
|
|
2374
|
+
"duration" to duration,
|
|
2375
|
+
"targetLabel" to targetLabel,
|
|
2376
|
+
"x" to x,
|
|
2377
|
+
"y" to y,
|
|
2378
|
+
"details" to mapOf(
|
|
2379
|
+
"duration" to duration,
|
|
2380
|
+
"targetLabel" to targetLabel
|
|
2381
|
+
)
|
|
2382
|
+
)
|
|
2383
|
+
|
|
2384
|
+
Logger.debug("[TOUCH] Gesture recorded: type=$gestureType, touches=${touches.size}")
|
|
2385
|
+
addEventWithPersistence(eventMap)
|
|
2386
|
+
} catch (e: Exception) {
|
|
2387
|
+
Logger.error("Failed to record gesture", e)
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
override fun onRageTap(tapCount: Int, x: Float, y: Float) {
|
|
2392
|
+
if (!isRecording) return
|
|
2393
|
+
|
|
2394
|
+
try {
|
|
2395
|
+
val timestamp = System.currentTimeMillis()
|
|
2396
|
+
|
|
2397
|
+
// Build touches array matching iOS format for web player compatibility
|
|
2398
|
+
val touchPoint = mapOf(
|
|
2399
|
+
"x" to x,
|
|
2400
|
+
"y" to y,
|
|
2401
|
+
"timestamp" to timestamp,
|
|
2402
|
+
"force" to 0f
|
|
2403
|
+
)
|
|
2404
|
+
|
|
2405
|
+
val eventMap = mapOf(
|
|
2406
|
+
"type" to EventType.GESTURE, // Use gesture type for web player compatibility
|
|
2407
|
+
"timestamp" to timestamp,
|
|
2408
|
+
"gestureType" to "rage_tap",
|
|
2409
|
+
"touches" to listOf(touchPoint), // Required by web player TouchOverlay
|
|
2410
|
+
"tapCount" to tapCount,
|
|
2411
|
+
"x" to x,
|
|
2412
|
+
"y" to y
|
|
2413
|
+
)
|
|
2414
|
+
addEventWithPersistence(eventMap)
|
|
2415
|
+
} catch (e: Exception) {
|
|
2416
|
+
Logger.error("Failed to record rage tap", e)
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
override fun isCurrentlyRecording(): Boolean = isRecording
|
|
2421
|
+
|
|
2422
|
+
override fun isKeyboardCurrentlyVisible(): Boolean = isKeyboardVisible
|
|
2423
|
+
|
|
2424
|
+
override fun currentKeyboardHeight(): Int = lastKeyboardHeight
|
|
2425
|
+
|
|
2426
|
+
// ==================== NetworkMonitorListener ====================
|
|
2427
|
+
|
|
2428
|
+
override fun onNetworkChanged(quality: com.rejourney.network.NetworkQuality) {
|
|
2429
|
+
if (!isRecording) return
|
|
2430
|
+
|
|
2431
|
+
val qualityMap = quality.toMap()
|
|
2432
|
+
val networkType = qualityMap["networkType"] as? String ?: "none"
|
|
2433
|
+
val cellularGeneration = qualityMap["cellularGeneration"] as? String ?: "unknown"
|
|
2434
|
+
|
|
2435
|
+
val eventMap = mutableMapOf<String, Any?>(
|
|
2436
|
+
"type" to "network_change",
|
|
2437
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2438
|
+
"status" to if (networkType == "none") "disconnected" else "connected",
|
|
2439
|
+
"networkType" to networkType,
|
|
2440
|
+
"isConstrained" to (qualityMap["isConstrained"] as? Boolean ?: false),
|
|
2441
|
+
"isExpensive" to (qualityMap["isExpensive"] as? Boolean ?: false)
|
|
2442
|
+
)
|
|
2443
|
+
|
|
2444
|
+
if (cellularGeneration != "unknown") {
|
|
2445
|
+
eventMap["cellularGeneration"] = cellularGeneration
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
addEventWithPersistence(eventMap)
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// ==================== KeyboardTrackerListener ====================
|
|
2452
|
+
|
|
2453
|
+
override fun onKeyboardShown(keyboardHeight: Int) {
|
|
2454
|
+
if (!isRecording) return
|
|
2455
|
+
|
|
2456
|
+
Logger.debug("[KEYBOARD] Keyboard shown (height=$keyboardHeight)")
|
|
2457
|
+
isKeyboardVisible = true
|
|
2458
|
+
lastKeyboardHeight = keyboardHeight
|
|
2459
|
+
|
|
2460
|
+
val eventMap = mapOf(
|
|
2461
|
+
"type" to EventType.KEYBOARD_SHOW,
|
|
2462
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2463
|
+
"keyboardHeight" to keyboardHeight
|
|
2464
|
+
)
|
|
2465
|
+
addEventWithPersistence(eventMap)
|
|
2466
|
+
|
|
2467
|
+
// Schedule capture after keyboard settles
|
|
2468
|
+
captureEngine?.notifyKeyboardEvent("keyboard_shown")
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
override fun onKeyboardHidden() {
|
|
2472
|
+
if (!isRecording) return
|
|
2473
|
+
|
|
2474
|
+
Logger.debug("[KEYBOARD] Keyboard hidden (keyPresses=$keyPressCount)")
|
|
2475
|
+
isKeyboardVisible = false
|
|
2476
|
+
|
|
2477
|
+
// Match iOS/player behavior: emit a recent typing signal if we recorded keypresses
|
|
2478
|
+
if (keyPressCount > 0) {
|
|
2479
|
+
addEventWithPersistence(
|
|
2480
|
+
mapOf(
|
|
2481
|
+
"type" to EventType.KEYBOARD_TYPING,
|
|
2482
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2483
|
+
"keyPressCount" to keyPressCount
|
|
2484
|
+
)
|
|
2485
|
+
)
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
val eventMap = mapOf(
|
|
2489
|
+
"type" to EventType.KEYBOARD_HIDE,
|
|
2490
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2491
|
+
"keyPressCount" to keyPressCount
|
|
2492
|
+
)
|
|
2493
|
+
addEventWithPersistence(eventMap)
|
|
2494
|
+
|
|
2495
|
+
// Reset key press count when keyboard hides
|
|
2496
|
+
keyPressCount = 0
|
|
2497
|
+
|
|
2498
|
+
// Schedule capture after keyboard settles
|
|
2499
|
+
captureEngine?.notifyKeyboardEvent("keyboard_hidden")
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
override fun onKeyPress() {
|
|
2503
|
+
keyPressCount++
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// ==================== TextInputTrackerListener ====================
|
|
2507
|
+
|
|
2508
|
+
override fun onTextChanged(characterCount: Int) {
|
|
2509
|
+
if (!isRecording) return
|
|
2510
|
+
if (characterCount <= 0) return
|
|
2511
|
+
|
|
2512
|
+
// Accumulate key presses
|
|
2513
|
+
keyPressCount += characterCount
|
|
2514
|
+
|
|
2515
|
+
// Emit typing events so the player can animate typing indicators.
|
|
2516
|
+
// (No actual text content is captured.)
|
|
2517
|
+
if (isKeyboardVisible) {
|
|
2518
|
+
addEventWithPersistence(
|
|
2519
|
+
mapOf(
|
|
2520
|
+
"type" to EventType.KEYBOARD_TYPING,
|
|
2521
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2522
|
+
"keyPressCount" to characterCount
|
|
2523
|
+
)
|
|
2524
|
+
)
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// ==================== ANRHandler.ANRListener ====================
|
|
2529
|
+
|
|
2530
|
+
override fun onANRDetected(durationMs: Long, threadState: String?) {
|
|
2531
|
+
// CRASH PREVENTION: Wrap in try-catch to never crash host app
|
|
2532
|
+
try {
|
|
2533
|
+
if (!isRecording) return
|
|
2534
|
+
|
|
2535
|
+
Logger.debug("ANR callback: duration=${durationMs}ms")
|
|
2536
|
+
|
|
2537
|
+
// Log ANR as an event for timeline display
|
|
2538
|
+
val eventMap = mutableMapOf<String, Any?>(
|
|
2539
|
+
"type" to "anr",
|
|
2540
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2541
|
+
"durationMs" to durationMs
|
|
2542
|
+
)
|
|
2543
|
+
threadState?.let { eventMap["threadState"] = it }
|
|
2544
|
+
addEventWithPersistence(eventMap)
|
|
2545
|
+
|
|
2546
|
+
// Increment telemetry counter
|
|
2547
|
+
Telemetry.getInstance().recordANR()
|
|
2548
|
+
} catch (e: Exception) {
|
|
2549
|
+
Logger.error("SDK error in onANRDetected (non-fatal)", e)
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// ==================== CaptureEngineDelegate ====================
|
|
2554
|
+
|
|
2555
|
+
override fun onSegmentReady(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int) {
|
|
2556
|
+
// CRITICAL FIX: Do NOT delete segment if shutting down - we want to persist it for recovery!
|
|
2557
|
+
if (!isRecording && !isShuttingDown) {
|
|
2558
|
+
// Clean up the segment file if we're not recording (and not shutting down)
|
|
2559
|
+
try {
|
|
2560
|
+
segmentFile.delete()
|
|
2561
|
+
} catch (_: Exception) {}
|
|
2562
|
+
return
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
if (isShuttingDown) {
|
|
2566
|
+
Logger.debug("Segment ready during shutdown - preserving file for recovery: ${segmentFile.name}")
|
|
2567
|
+
// Do not attempt upload now as scope is cancelled. WorkManager/Recovery will handle it.
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
if (!remoteRecordingEnabled) {
|
|
2572
|
+
try {
|
|
2573
|
+
segmentFile.delete()
|
|
2574
|
+
} catch (_: Exception) {}
|
|
2575
|
+
Logger.debug("Segment upload skipped - recording disabled")
|
|
2576
|
+
return
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
Logger.debug("Segment ready: frames=$frameCount, file=${segmentFile.absolutePath}")
|
|
2580
|
+
|
|
2581
|
+
scope.launch(Dispatchers.IO) {
|
|
2582
|
+
try {
|
|
2583
|
+
val success = uploadManager?.uploadVideoSegment(
|
|
2584
|
+
segmentFile = segmentFile,
|
|
2585
|
+
startTime = startTime,
|
|
2586
|
+
endTime = endTime,
|
|
2587
|
+
frameCount = frameCount
|
|
2588
|
+
) ?: false
|
|
2589
|
+
|
|
2590
|
+
if (success) {
|
|
2591
|
+
Logger.debug("Segment uploaded successfully")
|
|
2592
|
+
} else {
|
|
2593
|
+
Logger.warning("Segment upload failed")
|
|
2594
|
+
}
|
|
2595
|
+
} catch (e: Exception) {
|
|
2596
|
+
Logger.error("Failed to upload segment", e)
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
override fun onCaptureError(error: Exception) {
|
|
2602
|
+
Logger.error("Capture error: ${error.message}", error)
|
|
2603
|
+
|
|
2604
|
+
// Log capture error as an event
|
|
2605
|
+
val eventMap = mutableMapOf<String, Any?>(
|
|
2606
|
+
"type" to "capture_error",
|
|
2607
|
+
"timestamp" to System.currentTimeMillis(),
|
|
2608
|
+
"error" to error.message
|
|
2609
|
+
)
|
|
2610
|
+
addEventWithPersistence(eventMap)
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
override fun onHierarchySnapshotsReady(snapshotsJson: ByteArray, timestamp: Long) {
|
|
2614
|
+
Logger.debug("[HIERARCHY] onHierarchySnapshotsReady: START (size=${snapshotsJson.size} bytes, timestamp=$timestamp)")
|
|
2615
|
+
Logger.debug("[HIERARCHY] isRecording=$isRecording, isShuttingDown=$isShuttingDown, currentSessionId=$currentSessionId")
|
|
2616
|
+
|
|
2617
|
+
if (!isRecording || isShuttingDown) {
|
|
2618
|
+
Logger.warning("[HIERARCHY] onHierarchySnapshotsReady: Skipping - isRecording=$isRecording, isShuttingDown=$isShuttingDown")
|
|
2619
|
+
return
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
if (!remoteRecordingEnabled) {
|
|
2623
|
+
Logger.debug("[HIERARCHY] Skipping upload - recording disabled")
|
|
2624
|
+
return
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// CRITICAL FIX: Capture current session ID at callback time
|
|
2628
|
+
// This prevents stale session ID issues where UploadManager.sessionId
|
|
2629
|
+
// may still reference a previous session
|
|
2630
|
+
val sid = currentSessionId ?: run {
|
|
2631
|
+
Logger.error("[HIERARCHY] onHierarchySnapshotsReady: No current session ID, cannot upload hierarchy")
|
|
2632
|
+
return
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
Logger.debug("[HIERARCHY] onHierarchySnapshotsReady: Hierarchy snapshots ready for session: $sid")
|
|
2636
|
+
Logger.debug("[HIERARCHY] JSON size: ${snapshotsJson.size} bytes, uploadManager=${uploadManager != null}")
|
|
2637
|
+
|
|
2638
|
+
scope.launch(Dispatchers.IO) {
|
|
2639
|
+
try {
|
|
2640
|
+
Logger.debug("[HIERARCHY] Starting hierarchy upload (sessionId=$sid)")
|
|
2641
|
+
val uploadStartTime = System.currentTimeMillis()
|
|
2642
|
+
|
|
2643
|
+
val success = uploadManager?.uploadHierarchy(
|
|
2644
|
+
hierarchyData = snapshotsJson,
|
|
2645
|
+
timestamp = timestamp,
|
|
2646
|
+
sessionId = sid // Pass session ID explicitly
|
|
2647
|
+
) ?: false
|
|
2648
|
+
|
|
2649
|
+
val uploadDuration = System.currentTimeMillis() - uploadStartTime
|
|
2650
|
+
|
|
2651
|
+
if (success) {
|
|
2652
|
+
Logger.debug("[HIERARCHY] ✅ Hierarchy snapshots uploaded successfully in ${uploadDuration}ms (sessionId=$sid)")
|
|
2653
|
+
} else {
|
|
2654
|
+
Logger.error("[HIERARCHY] ❌ Hierarchy snapshots upload FAILED after ${uploadDuration}ms (sessionId=$sid)")
|
|
2655
|
+
}
|
|
2656
|
+
} catch (e: Exception) {
|
|
2657
|
+
Logger.error("[HIERARCHY] ❌ Exception during hierarchy upload (sessionId=$sid): ${e.message}", e)
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// ==================== AuthFailureListener ====================
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* Called when authentication fails due to security errors (403/404).
|
|
2666
|
+
*
|
|
2667
|
+
* - 403 (security): Stop immediately and permanently (package name mismatch)
|
|
2668
|
+
* - 404 (not found): Retry with exponential backoff (could be temporary)
|
|
2669
|
+
*/
|
|
2670
|
+
override fun onAuthenticationFailure(errorCode: Int, errorMessage: String, domain: String) {
|
|
2671
|
+
Logger.error("Authentication failure: code=$errorCode, message=$errorMessage, domain=$domain")
|
|
2672
|
+
|
|
2673
|
+
when (errorCode) {
|
|
2674
|
+
403 -> {
|
|
2675
|
+
// SECURITY: Package name mismatch or access forbidden - PERMANENT failure
|
|
2676
|
+
Logger.error("SECURITY: Access forbidden - stopping recording permanently")
|
|
2677
|
+
authPermanentlyFailed = true
|
|
2678
|
+
handleAuthenticationFailurePermanent(errorCode, errorMessage, domain)
|
|
2679
|
+
}
|
|
2680
|
+
else -> {
|
|
2681
|
+
// 404 and other errors - retry with exponential backoff
|
|
2682
|
+
// Recording continues locally, events queued for later upload
|
|
2683
|
+
scheduleAuthRetry(errorCode, errorMessage, domain)
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/**
|
|
2689
|
+
* Schedule auth retry with exponential backoff.
|
|
2690
|
+
* Recording continues locally while retrying.
|
|
2691
|
+
*/
|
|
2692
|
+
private fun scheduleAuthRetry(errorCode: Int, errorMessage: String, domain: String) {
|
|
2693
|
+
if (authPermanentlyFailed || isShuttingDown) {
|
|
2694
|
+
return
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
authRetryCount++
|
|
2698
|
+
|
|
2699
|
+
// Check max retries
|
|
2700
|
+
if (authRetryCount > MAX_AUTH_RETRIES) {
|
|
2701
|
+
Logger.error("Auth failed after $MAX_AUTH_RETRIES retries. Recording continues locally.")
|
|
2702
|
+
|
|
2703
|
+
// Emit warning (not error) - recording continues
|
|
2704
|
+
emitAuthWarningEvent(errorCode, "Auth failed after max retries. Recording locally.", authRetryCount)
|
|
2705
|
+
|
|
2706
|
+
// Schedule long background retry (5 minutes)
|
|
2707
|
+
scheduleBackgroundAuthRetry(AUTH_BACKGROUND_RETRY_DELAY_MS)
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// Calculate exponential backoff: 2s, 4s, 8s, 16s, 32s, capped at 60s
|
|
2712
|
+
val delay = minOf(
|
|
2713
|
+
AUTH_RETRY_BASE_DELAY_MS * (1L shl (authRetryCount - 1)),
|
|
2714
|
+
AUTH_RETRY_MAX_DELAY_MS
|
|
2715
|
+
)
|
|
2716
|
+
|
|
2717
|
+
Logger.info("Auth failed (attempt $authRetryCount/$MAX_AUTH_RETRIES), retrying in ${delay}ms. " +
|
|
2718
|
+
"Recording continues locally. Error: $errorMessage")
|
|
2719
|
+
|
|
2720
|
+
// After 2 failed attempts, clear cached auth data and re-register fresh
|
|
2721
|
+
if (authRetryCount >= 2) {
|
|
2722
|
+
Logger.info("Clearing cached auth data and re-registering fresh...")
|
|
2723
|
+
deviceAuthManager?.clearCredentials()
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
scheduleBackgroundAuthRetry(delay)
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Schedule a background auth retry after specified delay.
|
|
2731
|
+
*/
|
|
2732
|
+
private fun scheduleBackgroundAuthRetry(delayMs: Long) {
|
|
2733
|
+
// Cancel any existing retry job
|
|
2734
|
+
authRetryJob?.cancel()
|
|
2735
|
+
|
|
2736
|
+
authRetryJob = scope.launch {
|
|
2737
|
+
delay(delayMs)
|
|
2738
|
+
if (!authPermanentlyFailed && !isShuttingDown) {
|
|
2739
|
+
Logger.info("Retrying auth (attempt ${authRetryCount + 1})...")
|
|
2740
|
+
performAuthRetry()
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
/**
|
|
2746
|
+
* Perform the auth retry - re-initialize device auth.
|
|
2747
|
+
*/
|
|
2748
|
+
private fun performAuthRetry() {
|
|
2749
|
+
if (savedApiUrl.isNotEmpty() && savedPublicKey.isNotEmpty()) {
|
|
2750
|
+
deviceAuthManager?.registerDevice(
|
|
2751
|
+
projectKey = savedPublicKey,
|
|
2752
|
+
bundleId = reactContext.packageName,
|
|
2753
|
+
platform = "android",
|
|
2754
|
+
sdkVersion = Constants.SDK_VERSION,
|
|
2755
|
+
apiUrl = savedApiUrl
|
|
2756
|
+
) { success, credentialId, error ->
|
|
2757
|
+
if (success) {
|
|
2758
|
+
Logger.debug("Auth retry successful: device registered: $credentialId")
|
|
2759
|
+
resetAuthRetryState()
|
|
2760
|
+
// Get upload token after successful registration
|
|
2761
|
+
deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
|
|
2762
|
+
if (tokenSuccess) {
|
|
2763
|
+
Logger.debug("Upload token obtained after auth retry")
|
|
2764
|
+
} else {
|
|
2765
|
+
Logger.warning("Failed to get upload token after auth retry: $tokenError")
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
} else {
|
|
2769
|
+
Logger.warning("Auth retry failed: $error")
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* Reset auth retry state (called when auth succeeds).
|
|
2777
|
+
*/
|
|
2778
|
+
private fun resetAuthRetryState() {
|
|
2779
|
+
authRetryCount = 0
|
|
2780
|
+
authPermanentlyFailed = false
|
|
2781
|
+
authRetryJob?.cancel()
|
|
2782
|
+
authRetryJob = null
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2786
|
+
* Handle PERMANENT authentication failure (403 security errors only).
|
|
2787
|
+
* Stops recording, clears credentials, and emits error event to JS.
|
|
2788
|
+
*/
|
|
2789
|
+
private fun handleAuthenticationFailurePermanent(errorCode: Int, errorMessage: String, domain: String) {
|
|
2790
|
+
// Must run on main thread for React Native event emission
|
|
2791
|
+
Handler(Looper.getMainLooper()).post {
|
|
2792
|
+
try {
|
|
2793
|
+
// Stop recording immediately
|
|
2794
|
+
if (isRecording) {
|
|
2795
|
+
Logger.warning("Stopping recording due to security authentication failure")
|
|
2796
|
+
|
|
2797
|
+
// Stop capture engine
|
|
2798
|
+
captureEngine?.stopSession()
|
|
2799
|
+
|
|
2800
|
+
// Disable touch tracking
|
|
2801
|
+
touchInterceptor?.disableGlobalTracking()
|
|
2802
|
+
|
|
2803
|
+
// Stop keyboard and text input tracking
|
|
2804
|
+
keyboardTracker?.stopTracking()
|
|
2805
|
+
textInputTracker?.stopTracking()
|
|
2806
|
+
|
|
2807
|
+
// Stop timers
|
|
2808
|
+
stopBatchUploadTimer()
|
|
2809
|
+
stopDurationLimitTimer()
|
|
2810
|
+
|
|
2811
|
+
// Clear session state
|
|
2812
|
+
isRecording = false
|
|
2813
|
+
currentSessionId = null
|
|
2814
|
+
userId = null
|
|
2815
|
+
sessionEvents.clear()
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// Clear stored credentials
|
|
2819
|
+
deviceAuthManager?.clearCredentials()
|
|
2820
|
+
|
|
2821
|
+
// Emit error event to JavaScript layer
|
|
2822
|
+
emitAuthErrorEvent(errorCode, errorMessage, domain)
|
|
2823
|
+
|
|
2824
|
+
} catch (e: Exception) {
|
|
2825
|
+
Logger.error("Error handling authentication failure", e)
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
/**
|
|
2831
|
+
* Emit auth warning event (for retryable failures).
|
|
2832
|
+
*/
|
|
2833
|
+
private fun emitAuthWarningEvent(errorCode: Int, errorMessage: String, retryCount: Int) {
|
|
2834
|
+
try {
|
|
2835
|
+
val params = Arguments.createMap().apply {
|
|
2836
|
+
putInt("code", errorCode)
|
|
2837
|
+
putString("message", errorMessage)
|
|
2838
|
+
putInt("retryCount", retryCount)
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
reactContext
|
|
2842
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
2843
|
+
?.emit("rejourneyAuthWarning", params)
|
|
2844
|
+
|
|
2845
|
+
Logger.debug("Emitted rejourneyAuthWarning event to JS: code=$errorCode, retryCount=$retryCount")
|
|
2846
|
+
} catch (e: Exception) {
|
|
2847
|
+
Logger.error("Failed to emit auth warning event", e)
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
/**
|
|
2852
|
+
* Emit rejourneyAuthError event to JavaScript layer.
|
|
2853
|
+
*/
|
|
2854
|
+
private fun emitAuthErrorEvent(errorCode: Int, errorMessage: String, domain: String) {
|
|
2855
|
+
try {
|
|
2856
|
+
val params = Arguments.createMap().apply {
|
|
2857
|
+
putInt("code", errorCode)
|
|
2858
|
+
putString("message", errorMessage)
|
|
2859
|
+
putString("domain", domain)
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
reactContext
|
|
2863
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
2864
|
+
?.emit("rejourneyAuthError", params)
|
|
2865
|
+
|
|
2866
|
+
Logger.debug("Emitted rejourneyAuthError event to JS: code=$errorCode")
|
|
2867
|
+
} catch (e: Exception) {
|
|
2868
|
+
Logger.error("Failed to emit auth error event", e)
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
/**
|
|
2873
|
+
* Check if the app was killed in the previous session using ApplicationExitInfo (Android 11+).
|
|
2874
|
+
* This is a fallback mechanism when onTaskRemoved() doesn't fire.
|
|
2875
|
+
*/
|
|
2876
|
+
private fun checkPreviousAppKill() {
|
|
2877
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
2878
|
+
// ApplicationExitInfo is only available on Android 11+ (API 30)
|
|
2879
|
+
return
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
try {
|
|
2883
|
+
val activityManager = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as? android.app.ActivityManager
|
|
2884
|
+
if (activityManager == null) {
|
|
2885
|
+
Logger.debug("ActivityManager not available for exit info check")
|
|
2886
|
+
return
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// Get historical exit reasons for this process
|
|
2890
|
+
val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1)
|
|
2891
|
+
|
|
2892
|
+
if (exitReasons.isNotEmpty()) {
|
|
2893
|
+
val exitInfo = exitReasons[0]
|
|
2894
|
+
val reason = exitInfo.reason
|
|
2895
|
+
val timestamp = exitInfo.timestamp
|
|
2896
|
+
|
|
2897
|
+
Logger.debug("Previous app exit: reason=$reason, timestamp=$timestamp")
|
|
2898
|
+
|
|
2899
|
+
// Check if app was killed by user (swipe away, force stop, etc.)
|
|
2900
|
+
// REASON_USER_REQUESTED includes swipe-away from recent apps
|
|
2901
|
+
if (reason == android.app.ApplicationExitInfo.REASON_USER_REQUESTED) {
|
|
2902
|
+
Logger.debug("App was killed by user (likely swipe-away) - checking for unclosed session")
|
|
2903
|
+
// This will be handled by checkForUnclosedSessions()
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
} catch (e: Exception) {
|
|
2907
|
+
Logger.warning("Failed to check previous app kill: ${e.message}")
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/**
|
|
2912
|
+
* Check for unclosed sessions from previous app launch.
|
|
2913
|
+
* If a session was active but never properly ended, end it now.
|
|
2914
|
+
*/
|
|
2915
|
+
private fun checkForUnclosedSessions() {
|
|
2916
|
+
try {
|
|
2917
|
+
val prefs = reactContext.getSharedPreferences("rejourney", Context.MODE_PRIVATE)
|
|
2918
|
+
val lastSessionId = prefs.getString("rj_current_session_id", null)
|
|
2919
|
+
val lastSessionStartTime = prefs.getLong("rj_session_start_time", 0)
|
|
2920
|
+
|
|
2921
|
+
if (lastSessionId != null && lastSessionStartTime > 0) {
|
|
2922
|
+
// Check if session was never closed (no end timestamp stored)
|
|
2923
|
+
val sessionEndTime = prefs.getLong("rj_session_end_time_$lastSessionId", 0)
|
|
2924
|
+
|
|
2925
|
+
if (sessionEndTime == 0L) {
|
|
2926
|
+
Logger.debug("Found unclosed session: $lastSessionId (started at $lastSessionStartTime)")
|
|
2927
|
+
|
|
2928
|
+
// Session was never properly closed - likely app was killed
|
|
2929
|
+
// End the session asynchronously using the upload manager
|
|
2930
|
+
backgroundScope.launch {
|
|
2931
|
+
try {
|
|
2932
|
+
// Reconstruct upload manager state if needed
|
|
2933
|
+
uploadManager?.let { um ->
|
|
2934
|
+
// Set the session ID temporarily to allow endSession to work
|
|
2935
|
+
val originalSessionId = um.sessionId
|
|
2936
|
+
um.sessionId = lastSessionId
|
|
2937
|
+
|
|
2938
|
+
// Try to end the session with the last known timestamp
|
|
2939
|
+
// Use a timestamp slightly before now to account for the gap
|
|
2940
|
+
val estimatedEndTime = System.currentTimeMillis() - 1000 // 1 second before now
|
|
2941
|
+
|
|
2942
|
+
Logger.debug("Ending unclosed session: $lastSessionId at $estimatedEndTime")
|
|
2943
|
+
|
|
2944
|
+
// Use the upload manager's endSession with override timestamp
|
|
2945
|
+
val success = um.endSession(endedAtOverride = estimatedEndTime)
|
|
2946
|
+
|
|
2947
|
+
// Restore original session ID
|
|
2948
|
+
um.sessionId = originalSessionId
|
|
2949
|
+
|
|
2950
|
+
if (success) {
|
|
2951
|
+
Logger.debug("Successfully ended unclosed session: $lastSessionId")
|
|
2952
|
+
// Clear the session markers
|
|
2953
|
+
um.clearSessionRecovery(lastSessionId)
|
|
2954
|
+
|
|
2955
|
+
// Update prefs to mark session as closed
|
|
2956
|
+
prefs.edit()
|
|
2957
|
+
.putLong("rj_session_end_time_$lastSessionId", estimatedEndTime)
|
|
2958
|
+
.remove("rj_current_session_id")
|
|
2959
|
+
.remove("rj_session_start_time")
|
|
2960
|
+
.apply()
|
|
2961
|
+
} else {
|
|
2962
|
+
Logger.warning("Failed to end unclosed session: $lastSessionId")
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
} catch (e: Exception) {
|
|
2966
|
+
Logger.error("Error ending unclosed session: ${e.message}", e)
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
} else {
|
|
2970
|
+
// Session was properly closed, clear old markers
|
|
2971
|
+
prefs.edit()
|
|
2972
|
+
.remove("rj_current_session_id")
|
|
2973
|
+
.remove("rj_session_start_time")
|
|
2974
|
+
.apply()
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
} catch (e: Exception) {
|
|
2978
|
+
Logger.error("Failed to check for unclosed sessions: ${e.message}", e)
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|