@rejourneyco/react-native 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +709 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Rejourney
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shared implementation for Rejourney React Native module.
|
|
19
|
+
*
|
|
20
|
+
* Architecture aligned with iOS RejourneyImpl.swift
|
|
21
|
+
* Uses the new recording/engine/utility package structure.
|
|
22
|
+
*/
|
|
23
|
+
package com.rejourney
|
|
24
|
+
|
|
25
|
+
import android.app.Activity
|
|
26
|
+
import android.app.Application
|
|
27
|
+
import android.content.Context
|
|
28
|
+
import android.content.Intent
|
|
29
|
+
import android.os.Build
|
|
30
|
+
import android.os.Bundle
|
|
31
|
+
import android.os.Handler
|
|
32
|
+
import android.os.Looper
|
|
33
|
+
import android.provider.Settings
|
|
34
|
+
import android.view.View
|
|
35
|
+
import android.view.ViewGroup
|
|
36
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
37
|
+
import androidx.lifecycle.LifecycleOwner
|
|
38
|
+
import androidx.lifecycle.ProcessLifecycleOwner
|
|
39
|
+
import com.facebook.react.bridge.*
|
|
40
|
+
import com.rejourney.engine.DeviceRegistrar
|
|
41
|
+
import com.rejourney.engine.DiagnosticLog
|
|
42
|
+
import com.rejourney.platform.OEMDetector
|
|
43
|
+
import com.rejourney.platform.SessionLifecycleService
|
|
44
|
+
import com.rejourney.platform.TaskRemovedListener
|
|
45
|
+
import com.rejourney.recording.*
|
|
46
|
+
import kotlinx.coroutines.*
|
|
47
|
+
import java.security.MessageDigest
|
|
48
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
49
|
+
import kotlin.concurrent.withLock
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Session state machine
|
|
53
|
+
*/
|
|
54
|
+
sealed class SessionState {
|
|
55
|
+
object Idle : SessionState()
|
|
56
|
+
data class Active(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
57
|
+
data class Paused(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
58
|
+
object Terminated : SessionState()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class RejourneyModuleImpl(
|
|
62
|
+
private val reactContext: ReactApplicationContext,
|
|
63
|
+
private val isNewArchitecture: Boolean
|
|
64
|
+
) : Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
|
|
65
|
+
|
|
66
|
+
companion object {
|
|
67
|
+
const val NAME = "Rejourney"
|
|
68
|
+
var sdkVersion = "1.0.1"
|
|
69
|
+
|
|
70
|
+
private const val SESSION_TIMEOUT_MS = 60_000L // 60 seconds
|
|
71
|
+
|
|
72
|
+
private const val PREFS_NAME = "com.rejourney.prefs"
|
|
73
|
+
private const val KEY_USER_IDENTITY = "user_identity"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// State machine
|
|
77
|
+
private var state: SessionState = SessionState.Idle
|
|
78
|
+
private val stateLock = ReentrantLock()
|
|
79
|
+
|
|
80
|
+
// Internal storage
|
|
81
|
+
private var currentUserIdentity: String? = null
|
|
82
|
+
private var backgroundEntryTimeMs: Long? = null
|
|
83
|
+
private var lastSessionConfig: Map<String, Any>? = null
|
|
84
|
+
private var lastApiUrl: String? = null
|
|
85
|
+
private var lastPublicKey: String? = null
|
|
86
|
+
|
|
87
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
88
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
89
|
+
private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
90
|
+
|
|
91
|
+
@Volatile
|
|
92
|
+
private var isInitialized = false
|
|
93
|
+
private val initLock = Any()
|
|
94
|
+
|
|
95
|
+
@Volatile
|
|
96
|
+
private var isShuttingDown = false
|
|
97
|
+
|
|
98
|
+
init {
|
|
99
|
+
DiagnosticLog.trace("[Rejourney] RejourneyModuleImpl constructor")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lazy initialization - called on first method invocation
|
|
104
|
+
*/
|
|
105
|
+
private fun ensureInitialized() {
|
|
106
|
+
if (isInitialized) return
|
|
107
|
+
|
|
108
|
+
synchronized(initLock) {
|
|
109
|
+
if (isInitialized) return
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Initialize core components
|
|
113
|
+
DiagnosticLog.notice("[Rejourney] ensureInitialized: Creating core components...")
|
|
114
|
+
|
|
115
|
+
// Load persisted identity
|
|
116
|
+
try {
|
|
117
|
+
val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
118
|
+
val persistedIdentity = prefs.getString(KEY_USER_IDENTITY, null)
|
|
119
|
+
if (!persistedIdentity.isNullOrBlank()) {
|
|
120
|
+
currentUserIdentity = persistedIdentity
|
|
121
|
+
DiagnosticLog.notice("[Rejourney] Restored persisted user identity: $persistedIdentity")
|
|
122
|
+
}
|
|
123
|
+
} catch (e: Exception) {
|
|
124
|
+
DiagnosticLog.fault("[Rejourney] Failed to load persisted identity: ${e.message}")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
DeviceRegistrar.getInstance(reactContext)
|
|
128
|
+
DiagnosticLog.notice("[Rejourney] ensureInitialized: DeviceRegistrar OK")
|
|
129
|
+
SegmentDispatcher.shared // Uses lazy singleton
|
|
130
|
+
TelemetryPipeline.getInstance(reactContext)
|
|
131
|
+
DiagnosticLog.notice("[Rejourney] ensureInitialized: TelemetryPipeline OK")
|
|
132
|
+
ReplayOrchestrator.getInstance(reactContext)
|
|
133
|
+
DiagnosticLog.notice("[Rejourney] ensureInitialized: ReplayOrchestrator OK")
|
|
134
|
+
VisualCapture.getInstance(reactContext)
|
|
135
|
+
DiagnosticLog.notice("[Rejourney] ensureInitialized: VisualCapture OK, shared=${VisualCapture.shared != null}")
|
|
136
|
+
EventBuffer.getInstance(reactContext)
|
|
137
|
+
InteractionRecorder.getInstance(reactContext)
|
|
138
|
+
ViewHierarchyScanner.shared // Uses lazy singleton
|
|
139
|
+
StabilityMonitor.getInstance(reactContext)
|
|
140
|
+
AnrSentinel.shared
|
|
141
|
+
|
|
142
|
+
// Register lifecycle callbacks
|
|
143
|
+
registerActivityLifecycleCallbacks()
|
|
144
|
+
registerProcessLifecycleObserver()
|
|
145
|
+
|
|
146
|
+
// Transmit any stored crash reports
|
|
147
|
+
StabilityMonitor.getInstance(reactContext).transmitStoredReport()
|
|
148
|
+
|
|
149
|
+
// Android-specific: OEM detection and task removed handling
|
|
150
|
+
setupOEMSpecificHandling()
|
|
151
|
+
|
|
152
|
+
DiagnosticLog.notice("[Rejourney] SDK initialized (version: $sdkVersion)")
|
|
153
|
+
isInitialized = true
|
|
154
|
+
|
|
155
|
+
} catch (e: Exception) {
|
|
156
|
+
DiagnosticLog.fault("[Rejourney] Init failed: ${e.message}")
|
|
157
|
+
isInitialized = true // Mark as initialized to prevent retry loops
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Android-specific: Set up OEM-aware task removal detection
|
|
164
|
+
* Different Android OEMs have different behaviors for app lifecycle
|
|
165
|
+
*/
|
|
166
|
+
private fun setupOEMSpecificHandling() {
|
|
167
|
+
val oem = OEMDetector.getOEM()
|
|
168
|
+
DiagnosticLog.trace("[Rejourney] Device OEM: $oem")
|
|
169
|
+
DiagnosticLog.trace("[Rejourney] OEM Recommendations: ${OEMDetector.getRecommendations()}")
|
|
170
|
+
DiagnosticLog.trace("[Rejourney] onTaskRemoved() reliable: ${OEMDetector.isTaskRemovedReliable()}")
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
SessionLifecycleService.taskRemovedListener = object : TaskRemovedListener {
|
|
174
|
+
override fun onTaskRemoved() {
|
|
175
|
+
DiagnosticLog.notice("[Rejourney] App terminated via swipe-away (OEM: $oem)")
|
|
176
|
+
// CRITICAL: Do NOT attempt synchronous network calls here.
|
|
177
|
+
// It causes ANRs. The session recovery will handle on next launch.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (e: Exception) {
|
|
181
|
+
DiagnosticLog.fault("[Rejourney] Failed to set up task removed listener: ${e.message}")
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private fun registerActivityLifecycleCallbacks() {
|
|
186
|
+
try {
|
|
187
|
+
val application = reactContext.applicationContext as? Application
|
|
188
|
+
application?.registerActivityLifecycleCallbacks(this)
|
|
189
|
+
} catch (e: Exception) {
|
|
190
|
+
DiagnosticLog.fault("[Rejourney] Failed to register activity callbacks: ${e.message}")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun registerProcessLifecycleObserver() {
|
|
195
|
+
mainHandler.post {
|
|
196
|
+
try {
|
|
197
|
+
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
|
198
|
+
} catch (e: Exception) {
|
|
199
|
+
DiagnosticLog.fault("[Rejourney] Failed to register lifecycle observer: ${e.message}")
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// MARK: - Lifecycle Handlers
|
|
205
|
+
|
|
206
|
+
override fun onStop(owner: LifecycleOwner) {
|
|
207
|
+
handleBackgrounding()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
override fun onStart(owner: LifecycleOwner) {
|
|
211
|
+
handleForegrounding()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private fun handleBackgrounding() {
|
|
215
|
+
stateLock.withLock {
|
|
216
|
+
val currentState = state
|
|
217
|
+
if (currentState is SessionState.Active) {
|
|
218
|
+
state = SessionState.Paused(currentState.sessionId, currentState.startTimeMs)
|
|
219
|
+
backgroundEntryTimeMs = System.currentTimeMillis()
|
|
220
|
+
DiagnosticLog.notice("[Rejourney] ⏸️ Session '${currentState.sessionId}' paused (app backgrounded)")
|
|
221
|
+
|
|
222
|
+
// Flush pending data
|
|
223
|
+
TelemetryPipeline.shared?.dispatchNow()
|
|
224
|
+
SegmentDispatcher.shared.shipPending()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fun handleForegrounding() {
|
|
230
|
+
mainHandler.post { processForegrounding() }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private fun processForegrounding() {
|
|
234
|
+
stateLock.withLock {
|
|
235
|
+
val currentState = state
|
|
236
|
+
if (currentState !is SessionState.Paused) {
|
|
237
|
+
DiagnosticLog.trace("[Rejourney] Foreground: not in paused state, ignoring")
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
val backgroundDuration = backgroundEntryTimeMs?.let {
|
|
242
|
+
System.currentTimeMillis() - it
|
|
243
|
+
} ?: 0L
|
|
244
|
+
backgroundEntryTimeMs = null
|
|
245
|
+
|
|
246
|
+
DiagnosticLog.notice("[Rejourney] App foregrounded after ${backgroundDuration / 1000}s (timeout: ${SESSION_TIMEOUT_MS / 1000}s)")
|
|
247
|
+
|
|
248
|
+
if (backgroundDuration > SESSION_TIMEOUT_MS) {
|
|
249
|
+
// End current session and start a new one
|
|
250
|
+
state = SessionState.Idle
|
|
251
|
+
val oldSessionId = currentState.sessionId
|
|
252
|
+
|
|
253
|
+
DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
|
|
254
|
+
|
|
255
|
+
backgroundScope.launch {
|
|
256
|
+
ReplayOrchestrator.shared?.endReplay { success, uploaded ->
|
|
257
|
+
DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
|
|
258
|
+
mainHandler.post { startNewSessionAfterTimeout() }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
// Resume existing session
|
|
263
|
+
state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
|
|
264
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
|
|
265
|
+
|
|
266
|
+
// Record foreground event
|
|
267
|
+
TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
|
|
268
|
+
StabilityMonitor.shared?.transmitStoredReport()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private fun startNewSessionAfterTimeout() {
|
|
274
|
+
val apiUrl = lastApiUrl ?: return
|
|
275
|
+
val publicKey = lastPublicKey ?: return
|
|
276
|
+
val savedUserId = currentUserIdentity
|
|
277
|
+
|
|
278
|
+
DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: $savedUserId)")
|
|
279
|
+
|
|
280
|
+
// Try fast path with cached credentials
|
|
281
|
+
val existingCred = DeviceRegistrar.shared?.uploadCredential
|
|
282
|
+
if (existingCred != null && DeviceRegistrar.shared?.credentialValid == true) {
|
|
283
|
+
DiagnosticLog.notice("[Rejourney] Using cached credentials for fast session restart")
|
|
284
|
+
ReplayOrchestrator.shared?.beginReplayFast(
|
|
285
|
+
apiToken = publicKey,
|
|
286
|
+
serverEndpoint = apiUrl,
|
|
287
|
+
credential = existingCred,
|
|
288
|
+
captureSettings = lastSessionConfig
|
|
289
|
+
)
|
|
290
|
+
} else {
|
|
291
|
+
DiagnosticLog.notice("[Rejourney] No cached credentials, doing full session start")
|
|
292
|
+
ReplayOrchestrator.shared?.beginReplay(
|
|
293
|
+
apiToken = publicKey,
|
|
294
|
+
serverEndpoint = apiUrl,
|
|
295
|
+
captureSettings = lastSessionConfig
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Poll for session ready
|
|
300
|
+
waitForSessionReady(savedUserId, 0)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private fun waitForSessionReady(savedUserId: String?, attempts: Int) {
|
|
304
|
+
val maxAttempts = 30 // 3 seconds max
|
|
305
|
+
|
|
306
|
+
mainHandler.postDelayed({
|
|
307
|
+
val newSid = ReplayOrchestrator.shared?.replayId
|
|
308
|
+
if (!newSid.isNullOrEmpty()) {
|
|
309
|
+
stateLock.withLock {
|
|
310
|
+
state = SessionState.Active(newSid, System.currentTimeMillis())
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
ReplayOrchestrator.shared?.activateGestureRecording()
|
|
314
|
+
|
|
315
|
+
// Restore user identity
|
|
316
|
+
if (!savedUserId.isNullOrBlank() && savedUserId != "anonymous" && !savedUserId.startsWith("anon_")) {
|
|
317
|
+
ReplayOrchestrator.shared?.associateUser(savedUserId)
|
|
318
|
+
DiagnosticLog.notice("[Rejourney] ✅ Restored user identity '$savedUserId' to new session $newSid")
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
DiagnosticLog.replayBegan(newSid)
|
|
322
|
+
DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
|
|
323
|
+
} else if (attempts < maxAttempts) {
|
|
324
|
+
waitForSessionReady(savedUserId, attempts + 1)
|
|
325
|
+
} else {
|
|
326
|
+
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
327
|
+
}
|
|
328
|
+
}, 100)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// MARK: - Public API
|
|
332
|
+
|
|
333
|
+
fun startSession(userId: String, apiUrl: String, publicKey: String, promise: Promise) {
|
|
334
|
+
startSessionWithOptions(
|
|
335
|
+
Arguments.createMap().apply {
|
|
336
|
+
putString("userId", userId)
|
|
337
|
+
putString("apiUrl", apiUrl)
|
|
338
|
+
putString("publicKey", publicKey)
|
|
339
|
+
},
|
|
340
|
+
promise
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
fun startSessionWithOptions(options: ReadableMap, promise: Promise) {
|
|
345
|
+
ensureInitialized()
|
|
346
|
+
|
|
347
|
+
if (isShuttingDown) {
|
|
348
|
+
promise.resolve(createResultMap(false, "", "Module is shutting down"))
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
val debug = options.getBooleanSafe("debug", false)
|
|
353
|
+
if (debug) {
|
|
354
|
+
DiagnosticLog.setVerbose(true)
|
|
355
|
+
DiagnosticLog.notice("[Rejourney] Debug mode ENABLED - verbose logging active")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
val userId = options.getStringSafe("userId", "anonymous")
|
|
359
|
+
val apiUrl = options.getStringSafe("apiUrl", "https://api.rejourney.co")
|
|
360
|
+
val publicKey = options.getStringSafe("publicKey", "")
|
|
361
|
+
|
|
362
|
+
if (publicKey.isEmpty()) {
|
|
363
|
+
promise.reject("INVALID_KEY", "publicKey is required")
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Build config from options
|
|
368
|
+
val config = mutableMapOf<String, Any>()
|
|
369
|
+
if (options.hasKey("captureScreen")) config["captureScreen"] = options.getBoolean("captureScreen")
|
|
370
|
+
if (options.hasKey("captureAnalytics")) config["captureAnalytics"] = options.getBoolean("captureAnalytics")
|
|
371
|
+
if (options.hasKey("captureCrashes")) config["captureCrashes"] = options.getBoolean("captureCrashes")
|
|
372
|
+
if (options.hasKey("captureANR")) config["captureANR"] = options.getBoolean("captureANR")
|
|
373
|
+
if (options.hasKey("wifiOnly")) config["wifiOnly"] = options.getBoolean("wifiOnly")
|
|
374
|
+
|
|
375
|
+
if (options.hasKey("fps")) {
|
|
376
|
+
val fps = options.getInt("fps").coerceIn(1, 30)
|
|
377
|
+
config["captureRate"] = 1.0 / fps
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (options.hasKey("quality")) {
|
|
381
|
+
when (options.getString("quality")?.lowercase()) {
|
|
382
|
+
"low" -> config["imgCompression"] = 0.4
|
|
383
|
+
"high" -> config["imgCompression"] = 0.7
|
|
384
|
+
else -> config["imgCompression"] = 0.5
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
mainHandler.post {
|
|
389
|
+
// Check if already active
|
|
390
|
+
stateLock.withLock {
|
|
391
|
+
val currentState = state
|
|
392
|
+
if (currentState is SessionState.Active) {
|
|
393
|
+
promise.resolve(createResultMap(true, currentState.sessionId))
|
|
394
|
+
return@post
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
|
|
399
|
+
currentUserIdentity = userId
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Store for session restart
|
|
403
|
+
lastSessionConfig = config
|
|
404
|
+
lastApiUrl = apiUrl
|
|
405
|
+
lastPublicKey = publicKey
|
|
406
|
+
|
|
407
|
+
// Configure endpoints and tokens
|
|
408
|
+
TelemetryPipeline.shared?.endpoint = apiUrl
|
|
409
|
+
TelemetryPipeline.shared?.apiToken = publicKey
|
|
410
|
+
SegmentDispatcher.shared.endpoint = apiUrl
|
|
411
|
+
DeviceRegistrar.shared?.endpoint = apiUrl
|
|
412
|
+
|
|
413
|
+
// Set current activity on capture components before starting
|
|
414
|
+
val activity = reactContext.currentActivity
|
|
415
|
+
DiagnosticLog.notice("[Rejourney] startSession: currentActivity=${activity?.javaClass?.simpleName ?: "NULL"}, VisualCapture.shared=${VisualCapture.shared != null}")
|
|
416
|
+
if (activity != null) {
|
|
417
|
+
DiagnosticLog.notice("[Rejourney] Setting activity on capture components")
|
|
418
|
+
VisualCapture.shared?.setCurrentActivity(activity)
|
|
419
|
+
ViewHierarchyScanner.shared?.setCurrentActivity(activity)
|
|
420
|
+
InteractionRecorder.shared?.setCurrentActivity(activity)
|
|
421
|
+
DiagnosticLog.notice("[Rejourney] Activity set on all components")
|
|
422
|
+
} else {
|
|
423
|
+
DiagnosticLog.fault("[Rejourney] CRITICAL: No current activity available for capture!")
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Pre-generate session ID to ensure consistency between JS and native
|
|
427
|
+
val sid = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
|
|
428
|
+
ReplayOrchestrator.shared?.replayId = sid
|
|
429
|
+
TelemetryPipeline.shared?.currentReplayId = sid
|
|
430
|
+
|
|
431
|
+
// Begin replay
|
|
432
|
+
ReplayOrchestrator.shared?.beginReplay(
|
|
433
|
+
apiToken = publicKey,
|
|
434
|
+
serverEndpoint = apiUrl,
|
|
435
|
+
captureSettings = config
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
// Android-specific: Start SessionLifecycleService for task removal detection
|
|
439
|
+
startSessionLifecycleService()
|
|
440
|
+
|
|
441
|
+
// Allow orchestrator time to spin up
|
|
442
|
+
mainHandler.postDelayed({
|
|
443
|
+
stateLock.withLock {
|
|
444
|
+
state = SessionState.Active(sid, System.currentTimeMillis())
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
ReplayOrchestrator.shared?.activateGestureRecording()
|
|
448
|
+
|
|
449
|
+
if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
|
|
450
|
+
ReplayOrchestrator.shared?.associateUser(userId)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
DiagnosticLog.replayBegan(sid)
|
|
454
|
+
promise.resolve(createResultMap(true, sid))
|
|
455
|
+
}, 300)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Android-specific: Start the SessionLifecycleService for task removal detection
|
|
461
|
+
*/
|
|
462
|
+
private fun startSessionLifecycleService() {
|
|
463
|
+
try {
|
|
464
|
+
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
465
|
+
reactContext.startService(serviceIntent)
|
|
466
|
+
DiagnosticLog.trace("[Rejourney] SessionLifecycleService started")
|
|
467
|
+
} catch (e: Exception) {
|
|
468
|
+
DiagnosticLog.caution("[Rejourney] Failed to start SessionLifecycleService: ${e.message}")
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Android-specific: Stop the SessionLifecycleService
|
|
474
|
+
*/
|
|
475
|
+
private fun stopSessionLifecycleService() {
|
|
476
|
+
try {
|
|
477
|
+
val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
|
|
478
|
+
reactContext.stopService(serviceIntent)
|
|
479
|
+
DiagnosticLog.trace("[Rejourney] SessionLifecycleService stopped")
|
|
480
|
+
} catch (e: Exception) {
|
|
481
|
+
DiagnosticLog.caution("[Rejourney] Failed to stop SessionLifecycleService: ${e.message}")
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
fun stopSession(promise: Promise) {
|
|
486
|
+
mainHandler.post {
|
|
487
|
+
var targetSid = ""
|
|
488
|
+
|
|
489
|
+
stateLock.withLock {
|
|
490
|
+
val currentState = state
|
|
491
|
+
if (currentState is SessionState.Active) {
|
|
492
|
+
targetSid = currentState.sessionId
|
|
493
|
+
}
|
|
494
|
+
state = SessionState.Idle
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Android-specific: Stop SessionLifecycleService
|
|
498
|
+
stopSessionLifecycleService()
|
|
499
|
+
|
|
500
|
+
if (targetSid.isEmpty()) {
|
|
501
|
+
promise.resolve(createResultMap(true, "", uploadSuccess = true))
|
|
502
|
+
return@post
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
ReplayOrchestrator.shared?.endReplay { success, uploaded ->
|
|
506
|
+
DiagnosticLog.replayEnded(targetSid)
|
|
507
|
+
|
|
508
|
+
promise.resolve(Arguments.createMap().apply {
|
|
509
|
+
putBoolean("success", success)
|
|
510
|
+
putString("sessionId", targetSid)
|
|
511
|
+
putBoolean("uploadSuccess", uploaded)
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
fun getSessionId(promise: Promise) {
|
|
518
|
+
stateLock.withLock {
|
|
519
|
+
when (val currentState = state) {
|
|
520
|
+
is SessionState.Active -> promise.resolve(currentState.sessionId)
|
|
521
|
+
is SessionState.Paused -> promise.resolve(currentState.sessionId)
|
|
522
|
+
else -> promise.resolve(null)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
fun setUserIdentity(userId: String, promise: Promise) {
|
|
528
|
+
if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
|
|
529
|
+
currentUserIdentity = userId
|
|
530
|
+
|
|
531
|
+
// Persist natively
|
|
532
|
+
try {
|
|
533
|
+
val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
534
|
+
prefs.edit().putString(KEY_USER_IDENTITY, userId).apply()
|
|
535
|
+
DiagnosticLog.notice("[Rejourney] Persisted user identity: $userId")
|
|
536
|
+
} catch (e: Exception) {
|
|
537
|
+
DiagnosticLog.fault("[Rejourney] Failed to persist identity: ${e.message}")
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
ReplayOrchestrator.shared?.associateUser(userId)
|
|
541
|
+
} else if (userId == "anonymous" || userId.isNullOrBlank()) {
|
|
542
|
+
// Clear identity
|
|
543
|
+
currentUserIdentity = null
|
|
544
|
+
try {
|
|
545
|
+
val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
546
|
+
prefs.edit().remove(KEY_USER_IDENTITY).apply()
|
|
547
|
+
} catch (e: Exception) {}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
promise.resolve(createResultMap(true))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
fun getUserIdentity(promise: Promise) {
|
|
554
|
+
promise.resolve(currentUserIdentity)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
|
|
558
|
+
// Handle network_request events specially
|
|
559
|
+
if (eventType == "network_request") {
|
|
560
|
+
val detailsMap = details.toHashMap().filterValues { it != null }.mapValues { it.value!! }
|
|
561
|
+
TelemetryPipeline.shared?.recordNetworkEvent(detailsMap)
|
|
562
|
+
promise.resolve(createResultMap(true))
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Handle JS error events - route through TelemetryPipeline as type:"error"
|
|
567
|
+
// so the backend ingest worker processes them into the errors table
|
|
568
|
+
if (eventType == "error") {
|
|
569
|
+
val detailsMap = details.toHashMap()
|
|
570
|
+
val message = detailsMap["message"]?.toString() ?: "Unknown error"
|
|
571
|
+
val name = detailsMap["name"]?.toString() ?: "Error"
|
|
572
|
+
val stack = detailsMap["stack"]?.toString()
|
|
573
|
+
TelemetryPipeline.shared?.recordJSErrorEvent(name, message, stack)
|
|
574
|
+
promise.resolve(createResultMap(true))
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Handle dead_tap events from JS-side detection
|
|
579
|
+
// Native view hierarchy inspection is unreliable in React Native,
|
|
580
|
+
// so dead tap detection runs in JS and reports back via logEvent.
|
|
581
|
+
if (eventType == "dead_tap") {
|
|
582
|
+
val detailsMap = details.toHashMap()
|
|
583
|
+
val x = (detailsMap["x"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0L
|
|
584
|
+
val y = (detailsMap["y"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0L
|
|
585
|
+
val label = detailsMap["label"]?.toString() ?: "unknown"
|
|
586
|
+
TelemetryPipeline.shared?.recordDeadTapEvent(label, x, y)
|
|
587
|
+
ReplayOrchestrator.shared?.incrementDeadTapTally()
|
|
588
|
+
promise.resolve(createResultMap(true))
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// All other events go through custom event recording
|
|
593
|
+
val payload = try {
|
|
594
|
+
val json = org.json.JSONObject(details.toHashMap()).toString()
|
|
595
|
+
json
|
|
596
|
+
} catch (e: Exception) {
|
|
597
|
+
"{}"
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
ReplayOrchestrator.shared?.recordCustomEvent(eventType, payload)
|
|
601
|
+
promise.resolve(createResultMap(true))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
fun screenChanged(screenName: String, promise: Promise) {
|
|
605
|
+
TelemetryPipeline.shared?.recordViewTransition(screenName, screenName, true)
|
|
606
|
+
ReplayOrchestrator.shared?.logScreenView(screenName)
|
|
607
|
+
promise.resolve(createResultMap(true))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
fun onScroll(offsetY: Double, promise: Promise) {
|
|
611
|
+
ReplayOrchestrator.shared?.logScrollAction()
|
|
612
|
+
promise.resolve(createResultMap(true))
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
fun markVisualChange(reason: String, importance: String, promise: Promise) {
|
|
616
|
+
if (importance == "high") {
|
|
617
|
+
VisualCapture.shared?.snapshotNow()
|
|
618
|
+
}
|
|
619
|
+
promise.resolve(true)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
fun onExternalURLOpened(urlScheme: String, promise: Promise) {
|
|
623
|
+
ReplayOrchestrator.shared?.recordCustomEvent("external_url_opened", "{\"scheme\":\"$urlScheme\"}")
|
|
624
|
+
promise.resolve(createResultMap(true))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
fun onOAuthStarted(provider: String, promise: Promise) {
|
|
628
|
+
ReplayOrchestrator.shared?.recordCustomEvent("oauth_started", "{\"provider\":\"$provider\"}")
|
|
629
|
+
promise.resolve(createResultMap(true))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
fun onOAuthCompleted(provider: String, success: Boolean, promise: Promise) {
|
|
633
|
+
ReplayOrchestrator.shared?.recordCustomEvent("oauth_completed", "{\"provider\":\"$provider\",\"success\":$success}")
|
|
634
|
+
promise.resolve(createResultMap(true))
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
fun maskViewByNativeID(nativeID: String, promise: Promise) {
|
|
638
|
+
mainHandler.post {
|
|
639
|
+
findViewByNativeID(nativeID)?.let { view ->
|
|
640
|
+
ReplayOrchestrator.shared?.redactView(view)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
promise.resolve(createResultMap(true))
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
fun unmaskViewByNativeID(nativeID: String, promise: Promise) {
|
|
647
|
+
mainHandler.post {
|
|
648
|
+
findViewByNativeID(nativeID)?.let { view ->
|
|
649
|
+
ReplayOrchestrator.shared?.unredactView(view)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
promise.resolve(createResultMap(true))
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private fun findViewByNativeID(nativeID: String): View? {
|
|
656
|
+
val activity = reactContext.currentActivity ?: return null
|
|
657
|
+
val rootView = activity.window?.decorView?.rootView as? ViewGroup ?: return null
|
|
658
|
+
return scanViewForNativeID(rootView, nativeID)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private fun scanViewForNativeID(view: View, nativeID: String): View? {
|
|
662
|
+
if (view.contentDescription?.toString() == nativeID) {
|
|
663
|
+
return view
|
|
664
|
+
}
|
|
665
|
+
if (view is ViewGroup) {
|
|
666
|
+
for (i in 0 until view.childCount) {
|
|
667
|
+
val result = scanViewForNativeID(view.getChildAt(i), nativeID)
|
|
668
|
+
if (result != null) return result
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return null
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fun setDebugMode(enabled: Boolean, promise: Promise) {
|
|
675
|
+
DiagnosticLog.setVerbose(enabled)
|
|
676
|
+
promise.resolve(createResultMap(true))
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
fun setRemoteConfig(
|
|
680
|
+
rejourneyEnabled: Boolean,
|
|
681
|
+
recordingEnabled: Boolean,
|
|
682
|
+
sampleRate: Double,
|
|
683
|
+
maxRecordingMinutes: Double,
|
|
684
|
+
promise: Promise
|
|
685
|
+
) {
|
|
686
|
+
try {
|
|
687
|
+
ReplayOrchestrator.shared?.setRemoteConfig(
|
|
688
|
+
rejourneyEnabled = rejourneyEnabled,
|
|
689
|
+
recordingEnabled = recordingEnabled,
|
|
690
|
+
sampleRate = sampleRate.toInt(),
|
|
691
|
+
maxRecordingMinutes = maxRecordingMinutes.toInt()
|
|
692
|
+
)
|
|
693
|
+
DiagnosticLog.notice("[Rejourney] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min")
|
|
694
|
+
promise.resolve(createResultMap(true))
|
|
695
|
+
} catch (e: Exception) {
|
|
696
|
+
DiagnosticLog.fault("[Rejourney] Failed to set remote config: ${e.message}")
|
|
697
|
+
promise.resolve(createResultMap(false, error = "Failed to set remote config: ${e.message}"))
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
fun setSDKVersion(version: String) {
|
|
702
|
+
sdkVersion = version
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
fun getSDKVersion(promise: Promise) {
|
|
706
|
+
promise.resolve(sdkVersion)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
fun getSDKMetrics(promise: Promise) {
|
|
710
|
+
val dispatcher = SegmentDispatcher.shared
|
|
711
|
+
val pipeline = TelemetryPipeline.shared
|
|
712
|
+
|
|
713
|
+
promise.resolve(Arguments.createMap().apply {
|
|
714
|
+
putInt("uploadSuccessCount", dispatcher.uploadSuccessCount)
|
|
715
|
+
putInt("uploadFailureCount", dispatcher.uploadFailureCount)
|
|
716
|
+
putInt("retryAttemptCount", 0)
|
|
717
|
+
putInt("circuitBreakerOpenCount", dispatcher.circuitBreakerOpenCount)
|
|
718
|
+
putInt("memoryEvictionCount", 0)
|
|
719
|
+
putInt("offlinePersistCount", 0)
|
|
720
|
+
putInt("sessionStartCount", if (ReplayOrchestrator.shared?.replayId != null) 1 else 0)
|
|
721
|
+
putInt("crashCount", 0)
|
|
722
|
+
|
|
723
|
+
val total = dispatcher.uploadSuccessCount + dispatcher.uploadFailureCount
|
|
724
|
+
putDouble("uploadSuccessRate", if (total > 0) dispatcher.uploadSuccessCount.toDouble() / total else 1.0)
|
|
725
|
+
|
|
726
|
+
putDouble("avgUploadDurationMs", 0.0)
|
|
727
|
+
putInt("currentQueueDepth", pipeline?.getQueueDepth() ?: 0)
|
|
728
|
+
putNull("lastUploadTime")
|
|
729
|
+
putNull("lastRetryTime")
|
|
730
|
+
putDouble("totalBytesUploaded", dispatcher.totalBytesUploaded.toDouble())
|
|
731
|
+
putInt("totalBytesEvicted", 0)
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
fun getDeviceInfo(promise: Promise) {
|
|
736
|
+
val deviceHash = computeDeviceHash()
|
|
737
|
+
|
|
738
|
+
promise.resolve(Arguments.createMap().apply {
|
|
739
|
+
putString("platform", "android")
|
|
740
|
+
putString("osVersion", Build.VERSION.RELEASE)
|
|
741
|
+
putString("model", Build.MODEL)
|
|
742
|
+
putString("brand", Build.MANUFACTURER)
|
|
743
|
+
putInt("screenWidth", reactContext.resources.displayMetrics.widthPixels)
|
|
744
|
+
putInt("screenHeight", reactContext.resources.displayMetrics.heightPixels)
|
|
745
|
+
putDouble("screenScale", reactContext.resources.displayMetrics.density.toDouble())
|
|
746
|
+
putString("deviceHash", deviceHash)
|
|
747
|
+
putString("bundleId", reactContext.packageName ?: "unknown")
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
fun debugCrash() {
|
|
752
|
+
mainHandler.post {
|
|
753
|
+
throw RuntimeException("Rejourney debug crash triggered")
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
fun debugTriggerANR(durationMs: Double) {
|
|
758
|
+
mainHandler.post {
|
|
759
|
+
Thread.sleep(durationMs.toLong())
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
fun setUserData(key: String, value: String, promise: Promise) {
|
|
764
|
+
ReplayOrchestrator.shared?.attachAttribute(key, value)
|
|
765
|
+
promise.resolve(null)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// MARK: - Utility Methods
|
|
769
|
+
|
|
770
|
+
private fun computeDeviceHash(): String {
|
|
771
|
+
val androidId = Settings.Secure.getString(
|
|
772
|
+
reactContext.contentResolver,
|
|
773
|
+
Settings.Secure.ANDROID_ID
|
|
774
|
+
) ?: "unknown"
|
|
775
|
+
|
|
776
|
+
return try {
|
|
777
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
778
|
+
val hash = digest.digest(androidId.toByteArray())
|
|
779
|
+
hash.joinToString("") { "%02x".format(it) }
|
|
780
|
+
} catch (e: Exception) {
|
|
781
|
+
""
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private fun createResultMap(success: Boolean, sessionId: String = "", error: String? = null, uploadSuccess: Boolean? = null): WritableMap {
|
|
786
|
+
return Arguments.createMap().apply {
|
|
787
|
+
putBoolean("success", success)
|
|
788
|
+
putString("sessionId", sessionId)
|
|
789
|
+
error?.let { putString("error", it) }
|
|
790
|
+
uploadSuccess?.let { putBoolean("uploadSuccess", it) }
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// MARK: - Activity Lifecycle Callbacks
|
|
795
|
+
|
|
796
|
+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
|
797
|
+
override fun onActivityStarted(activity: Activity) {}
|
|
798
|
+
override fun onActivityResumed(activity: Activity) {
|
|
799
|
+
// Set current activity on capture components so they can capture the screen
|
|
800
|
+
DiagnosticLog.notice("[Rejourney] onActivityResumed: ${activity.javaClass.simpleName}")
|
|
801
|
+
VisualCapture.shared?.setCurrentActivity(activity)
|
|
802
|
+
ViewHierarchyScanner.shared?.setCurrentActivity(activity)
|
|
803
|
+
InteractionRecorder.shared?.setCurrentActivity(activity)
|
|
804
|
+
}
|
|
805
|
+
override fun onActivityPaused(activity: Activity) {
|
|
806
|
+
// DO NOT clear activity references on pause!
|
|
807
|
+
// Activities can be paused during normal operation (dialogs, config changes, etc.)
|
|
808
|
+
// Clearing the activity here breaks screen capture during async credential fetch.
|
|
809
|
+
// Activity will be updated when a new activity resumes or when the app is destroyed.
|
|
810
|
+
DiagnosticLog.trace("[Rejourney] onActivityPaused: ${activity.javaClass.simpleName} (keeping activity reference)")
|
|
811
|
+
}
|
|
812
|
+
override fun onActivityStopped(activity: Activity) {
|
|
813
|
+
// Only clear when stopped (not visible) to avoid breaking capture during pause states
|
|
814
|
+
DiagnosticLog.trace("[Rejourney] onActivityStopped: ${activity.javaClass.simpleName}")
|
|
815
|
+
}
|
|
816
|
+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
|
817
|
+
override fun onActivityDestroyed(activity: Activity) {
|
|
818
|
+
// Clear references only on destroy to avoid leaks
|
|
819
|
+
DiagnosticLog.trace("[Rejourney] onActivityDestroyed: ${activity.javaClass.simpleName}")
|
|
820
|
+
VisualCapture.shared?.setCurrentActivity(null)
|
|
821
|
+
ViewHierarchyScanner.shared?.setCurrentActivity(null)
|
|
822
|
+
InteractionRecorder.shared?.setCurrentActivity(null)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// MARK: - Event Emission (no-ops, dead tap detection is native-side)
|
|
826
|
+
|
|
827
|
+
fun addListener(eventName: String) {
|
|
828
|
+
// No-op: dead tap detection is handled natively in TelemetryPipeline
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
fun removeListeners(count: Double) {
|
|
832
|
+
// No-op: dead tap detection is handled natively in TelemetryPipeline
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// MARK: - Cleanup
|
|
836
|
+
|
|
837
|
+
fun invalidate() {
|
|
838
|
+
isShuttingDown = true
|
|
839
|
+
scope.cancel()
|
|
840
|
+
backgroundScope.cancel()
|
|
841
|
+
|
|
842
|
+
val application = reactContext.applicationContext as? Application
|
|
843
|
+
application?.unregisterActivityLifecycleCallbacks(this)
|
|
844
|
+
|
|
845
|
+
mainHandler.post {
|
|
846
|
+
try {
|
|
847
|
+
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
|
848
|
+
} catch (_: Exception) {}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Extension functions for safe ReadableMap access
|
|
854
|
+
private fun ReadableMap.getStringSafe(key: String, default: String): String {
|
|
855
|
+
return if (hasKey(key) && !isNull(key)) getString(key) ?: default else default
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private fun ReadableMap.getBooleanSafe(key: String, default: Boolean): Boolean {
|
|
859
|
+
return if (hasKey(key) && !isNull(key)) getBoolean(key) else default
|
|
860
|
+
}
|