@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,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Rejourney
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
package com.rejourney.engine
|
|
18
|
+
|
|
19
|
+
import android.app.Activity
|
|
20
|
+
import android.app.Application
|
|
21
|
+
import android.content.Context
|
|
22
|
+
import android.os.Bundle
|
|
23
|
+
import android.os.Handler
|
|
24
|
+
import android.os.Looper
|
|
25
|
+
import android.provider.Settings
|
|
26
|
+
import android.view.View
|
|
27
|
+
import android.view.ViewGroup
|
|
28
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
29
|
+
import androidx.lifecycle.LifecycleOwner
|
|
30
|
+
import androidx.lifecycle.ProcessLifecycleOwner
|
|
31
|
+
import com.rejourney.recording.*
|
|
32
|
+
import java.security.MessageDigest
|
|
33
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
34
|
+
import kotlin.concurrent.withLock
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Session state machine aligned with iOS
|
|
38
|
+
*/
|
|
39
|
+
sealed class SessionState {
|
|
40
|
+
object Idle : SessionState()
|
|
41
|
+
data class Active(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
42
|
+
data class Paused(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
43
|
+
object Terminated : SessionState()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Main SDK implementation aligned with iOS RejourneyImpl.swift
|
|
48
|
+
*
|
|
49
|
+
* This class provides the core SDK functionality for native Android usage.
|
|
50
|
+
* For React Native, use RejourneyModuleImpl instead.
|
|
51
|
+
*/
|
|
52
|
+
class RejourneyImpl private constructor(private val context: Context) :
|
|
53
|
+
Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
|
|
54
|
+
|
|
55
|
+
companion object {
|
|
56
|
+
@Volatile
|
|
57
|
+
private var instance: RejourneyImpl? = null
|
|
58
|
+
|
|
59
|
+
fun getInstance(context: Context): RejourneyImpl {
|
|
60
|
+
return instance ?: synchronized(this) {
|
|
61
|
+
instance ?: RejourneyImpl(context.applicationContext).also { instance = it }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
val shared: RejourneyImpl?
|
|
66
|
+
get() = instance
|
|
67
|
+
|
|
68
|
+
var sdkVersion = "1.0.1"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// State machine
|
|
72
|
+
private var state: SessionState = SessionState.Idle
|
|
73
|
+
private val stateLock = ReentrantLock()
|
|
74
|
+
|
|
75
|
+
// Internal storage
|
|
76
|
+
private var currentUserIdentity: String? = null
|
|
77
|
+
private var backgroundEntryTimeMs: Long? = null
|
|
78
|
+
private var lastSessionConfig: Map<String, Any>? = null
|
|
79
|
+
private var lastApiUrl: String? = null
|
|
80
|
+
private var lastPublicKey: String? = null
|
|
81
|
+
|
|
82
|
+
// Session timeout threshold (60 seconds)
|
|
83
|
+
private val sessionTimeoutMs = 60_000L
|
|
84
|
+
|
|
85
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
86
|
+
|
|
87
|
+
@Volatile
|
|
88
|
+
private var isInitialized = false
|
|
89
|
+
|
|
90
|
+
init {
|
|
91
|
+
setupLifecycleListeners()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun setupLifecycleListeners() {
|
|
95
|
+
try {
|
|
96
|
+
// Register with ProcessLifecycleOwner
|
|
97
|
+
mainHandler.post {
|
|
98
|
+
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Register activity callbacks
|
|
102
|
+
(context.applicationContext as? Application)?.registerActivityLifecycleCallbacks(this)
|
|
103
|
+
|
|
104
|
+
} catch (e: Exception) {
|
|
105
|
+
DiagnosticLog.fault("[Rejourney] Failed to setup lifecycle listeners: ${e.message}")
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MARK: - State Transitions
|
|
110
|
+
|
|
111
|
+
override fun onStop(owner: LifecycleOwner) {
|
|
112
|
+
handleBackgrounding()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override fun onStart(owner: LifecycleOwner) {
|
|
116
|
+
handleForegrounding()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun handleBackgrounding() {
|
|
120
|
+
stateLock.withLock {
|
|
121
|
+
when (val currentState = state) {
|
|
122
|
+
is SessionState.Active -> {
|
|
123
|
+
state = SessionState.Paused(currentState.sessionId, currentState.startTimeMs)
|
|
124
|
+
backgroundEntryTimeMs = System.currentTimeMillis()
|
|
125
|
+
DiagnosticLog.notice("[Rejourney] ⏸️ Session '${currentState.sessionId}' paused (app backgrounded)")
|
|
126
|
+
|
|
127
|
+
TelemetryPipeline.shared?.dispatchNow()
|
|
128
|
+
SegmentDispatcher.shared.shipPending()
|
|
129
|
+
}
|
|
130
|
+
else -> {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private fun handleForegrounding() {
|
|
136
|
+
mainHandler.post { processForegrounding() }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun processForegrounding() {
|
|
140
|
+
stateLock.withLock {
|
|
141
|
+
val currentState = state
|
|
142
|
+
if (currentState !is SessionState.Paused) {
|
|
143
|
+
DiagnosticLog.trace("[Rejourney] Foreground: not in paused state, ignoring")
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
val backgroundDuration = backgroundEntryTimeMs?.let {
|
|
148
|
+
System.currentTimeMillis() - it
|
|
149
|
+
} ?: 0L
|
|
150
|
+
backgroundEntryTimeMs = null
|
|
151
|
+
|
|
152
|
+
DiagnosticLog.notice("[Rejourney] App foregrounded after ${backgroundDuration / 1000}s (timeout: ${sessionTimeoutMs / 1000}s)")
|
|
153
|
+
|
|
154
|
+
if (backgroundDuration > sessionTimeoutMs) {
|
|
155
|
+
// End current session and start a new one
|
|
156
|
+
state = SessionState.Idle
|
|
157
|
+
val oldSessionId = currentState.sessionId
|
|
158
|
+
|
|
159
|
+
DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
|
|
160
|
+
|
|
161
|
+
Thread {
|
|
162
|
+
ReplayOrchestrator.shared?.endReplay { success, uploaded ->
|
|
163
|
+
DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
|
|
164
|
+
mainHandler.post { startNewSessionAfterTimeout() }
|
|
165
|
+
}
|
|
166
|
+
}.start()
|
|
167
|
+
} else {
|
|
168
|
+
// Resume existing session
|
|
169
|
+
state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
|
|
170
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
|
|
171
|
+
|
|
172
|
+
TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
|
|
173
|
+
StabilityMonitor.shared?.transmitStoredReport()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fun startNewSessionAfterTimeout() {
|
|
179
|
+
val apiUrl = lastApiUrl ?: return
|
|
180
|
+
val publicKey = lastPublicKey ?: return
|
|
181
|
+
val savedUserId = currentUserIdentity
|
|
182
|
+
|
|
183
|
+
DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: $savedUserId)")
|
|
184
|
+
|
|
185
|
+
mainHandler.post {
|
|
186
|
+
// Try fast path with cached credentials
|
|
187
|
+
val existingCred = DeviceRegistrar.shared?.uploadCredential
|
|
188
|
+
if (existingCred != null && DeviceRegistrar.shared?.credentialValid == true) {
|
|
189
|
+
DiagnosticLog.notice("[Rejourney] Using cached credentials for fast session restart")
|
|
190
|
+
ReplayOrchestrator.shared?.beginReplayFast(
|
|
191
|
+
apiToken = publicKey,
|
|
192
|
+
serverEndpoint = apiUrl,
|
|
193
|
+
credential = existingCred,
|
|
194
|
+
captureSettings = lastSessionConfig
|
|
195
|
+
)
|
|
196
|
+
} else {
|
|
197
|
+
DiagnosticLog.notice("[Rejourney] No cached credentials, doing full session start")
|
|
198
|
+
ReplayOrchestrator.shared?.beginReplay(
|
|
199
|
+
apiToken = publicKey,
|
|
200
|
+
serverEndpoint = apiUrl,
|
|
201
|
+
captureSettings = lastSessionConfig
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Poll for session ready
|
|
206
|
+
waitForSessionReady(savedUserId, 0)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private fun waitForSessionReady(savedUserId: String?, attempts: Int) {
|
|
211
|
+
val maxAttempts = 30 // 3 seconds max
|
|
212
|
+
|
|
213
|
+
mainHandler.postDelayed({
|
|
214
|
+
val newSid = ReplayOrchestrator.shared?.replayId
|
|
215
|
+
if (!newSid.isNullOrEmpty()) {
|
|
216
|
+
stateLock.withLock {
|
|
217
|
+
state = SessionState.Active(newSid, System.currentTimeMillis())
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ReplayOrchestrator.shared?.activateGestureRecording()
|
|
221
|
+
|
|
222
|
+
// Restore user identity
|
|
223
|
+
if (!savedUserId.isNullOrBlank() && savedUserId != "anonymous") {
|
|
224
|
+
ReplayOrchestrator.shared?.associateUser(savedUserId)
|
|
225
|
+
DiagnosticLog.notice("[Rejourney] ✅ Restored user identity '$savedUserId' to new session $newSid")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
DiagnosticLog.replayBegan(newSid)
|
|
229
|
+
DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
|
|
230
|
+
} else if (attempts < maxAttempts) {
|
|
231
|
+
waitForSessionReady(savedUserId, attempts + 1)
|
|
232
|
+
} else {
|
|
233
|
+
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
234
|
+
}
|
|
235
|
+
}, 100)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// MARK: - Public API
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Start a session with the given configuration
|
|
242
|
+
*/
|
|
243
|
+
fun startSession(
|
|
244
|
+
userId: String = "anonymous",
|
|
245
|
+
apiUrl: String = "https://api.rejourney.co",
|
|
246
|
+
publicKey: String,
|
|
247
|
+
config: Map<String, Any>? = null,
|
|
248
|
+
callback: ((Boolean, String) -> Unit)? = null
|
|
249
|
+
) {
|
|
250
|
+
if (publicKey.isEmpty()) {
|
|
251
|
+
callback?.invoke(false, "")
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
mainHandler.post {
|
|
256
|
+
// Check if already active
|
|
257
|
+
stateLock.withLock {
|
|
258
|
+
val currentState = state
|
|
259
|
+
if (currentState is SessionState.Active) {
|
|
260
|
+
callback?.invoke(true, currentState.sessionId)
|
|
261
|
+
return@post
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
currentUserIdentity = userId
|
|
266
|
+
|
|
267
|
+
// Store for session restart
|
|
268
|
+
lastSessionConfig = config
|
|
269
|
+
lastApiUrl = apiUrl
|
|
270
|
+
lastPublicKey = publicKey
|
|
271
|
+
|
|
272
|
+
// Configure endpoints
|
|
273
|
+
TelemetryPipeline.shared?.endpoint = apiUrl
|
|
274
|
+
SegmentDispatcher.shared.endpoint = apiUrl
|
|
275
|
+
DeviceRegistrar.shared?.endpoint = apiUrl
|
|
276
|
+
|
|
277
|
+
// Pre-generate session ID
|
|
278
|
+
val sid = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
|
|
279
|
+
ReplayOrchestrator.shared?.replayId = sid
|
|
280
|
+
|
|
281
|
+
// Begin replay
|
|
282
|
+
ReplayOrchestrator.shared?.beginReplay(
|
|
283
|
+
apiToken = publicKey,
|
|
284
|
+
serverEndpoint = apiUrl,
|
|
285
|
+
captureSettings = config
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// Allow orchestrator time to spin up
|
|
289
|
+
mainHandler.postDelayed({
|
|
290
|
+
stateLock.withLock {
|
|
291
|
+
state = SessionState.Active(sid, System.currentTimeMillis())
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
ReplayOrchestrator.shared?.activateGestureRecording()
|
|
295
|
+
|
|
296
|
+
if (userId != "anonymous") {
|
|
297
|
+
ReplayOrchestrator.shared?.associateUser(userId)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
DiagnosticLog.replayBegan(sid)
|
|
301
|
+
callback?.invoke(true, sid)
|
|
302
|
+
}, 300)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stop the current session
|
|
308
|
+
*/
|
|
309
|
+
fun stopSession(callback: ((Boolean, String, Boolean) -> Unit)? = null) {
|
|
310
|
+
mainHandler.post {
|
|
311
|
+
var targetSid = ""
|
|
312
|
+
|
|
313
|
+
stateLock.withLock {
|
|
314
|
+
val currentState = state
|
|
315
|
+
if (currentState is SessionState.Active) {
|
|
316
|
+
targetSid = currentState.sessionId
|
|
317
|
+
}
|
|
318
|
+
state = SessionState.Idle
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (targetSid.isEmpty()) {
|
|
322
|
+
callback?.invoke(true, "", true)
|
|
323
|
+
return@post
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
ReplayOrchestrator.shared?.endReplay { success, uploaded ->
|
|
327
|
+
DiagnosticLog.replayEnded(targetSid)
|
|
328
|
+
callback?.invoke(success, targetSid, uploaded)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get the current session ID
|
|
335
|
+
*/
|
|
336
|
+
fun getSessionId(): String? {
|
|
337
|
+
return stateLock.withLock {
|
|
338
|
+
when (val currentState = state) {
|
|
339
|
+
is SessionState.Active -> currentState.sessionId
|
|
340
|
+
is SessionState.Paused -> currentState.sessionId
|
|
341
|
+
else -> null
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Set the user identity for the session
|
|
348
|
+
*/
|
|
349
|
+
fun setUserIdentity(userId: String) {
|
|
350
|
+
currentUserIdentity = userId
|
|
351
|
+
ReplayOrchestrator.shared?.associateUser(userId)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get the current user identity
|
|
356
|
+
*/
|
|
357
|
+
fun getUserIdentity(): String? = currentUserIdentity
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Log a custom event
|
|
361
|
+
*/
|
|
362
|
+
fun logEvent(eventType: String, details: Map<String, Any>? = null) {
|
|
363
|
+
if (eventType == "network_request") {
|
|
364
|
+
TelemetryPipeline.shared?.recordNetworkEvent(details ?: emptyMap())
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Handle JS error events - route through TelemetryPipeline as type:"error"
|
|
369
|
+
// so the backend ingest worker processes them into the errors table
|
|
370
|
+
if (eventType == "error") {
|
|
371
|
+
val message = details?.get("message")?.toString() ?: "Unknown error"
|
|
372
|
+
val name = details?.get("name")?.toString() ?: "Error"
|
|
373
|
+
val stack = details?.get("stack")?.toString()
|
|
374
|
+
TelemetryPipeline.shared?.recordJSErrorEvent(name, message, stack)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Handle dead_tap events from JS-side detection
|
|
379
|
+
if (eventType == "dead_tap") {
|
|
380
|
+
val x = (details?.get("x") as? Number)?.toLong()?.coerceAtLeast(0) ?: 0L
|
|
381
|
+
val y = (details?.get("y") as? Number)?.toLong()?.coerceAtLeast(0) ?: 0L
|
|
382
|
+
val label = details?.get("label")?.toString() ?: "unknown"
|
|
383
|
+
TelemetryPipeline.shared?.recordDeadTapEvent(label, x, y)
|
|
384
|
+
ReplayOrchestrator.shared?.incrementDeadTapTally()
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
val payload = try {
|
|
389
|
+
org.json.JSONObject(details ?: emptyMap<String, Any>()).toString()
|
|
390
|
+
} catch (e: Exception) {
|
|
391
|
+
"{}"
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
ReplayOrchestrator.shared?.recordCustomEvent(eventType, payload)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Record a screen change
|
|
399
|
+
*/
|
|
400
|
+
fun screenChanged(screenName: String) {
|
|
401
|
+
TelemetryPipeline.shared?.recordViewTransition(screenName, screenName, true)
|
|
402
|
+
ReplayOrchestrator.shared?.logScreenView(screenName)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Record a scroll action
|
|
407
|
+
*/
|
|
408
|
+
fun onScroll(offsetY: Double) {
|
|
409
|
+
ReplayOrchestrator.shared?.logScrollAction()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Mark a visual change
|
|
414
|
+
*/
|
|
415
|
+
fun markVisualChange(reason: String, importance: String) {
|
|
416
|
+
if (importance == "high") {
|
|
417
|
+
VisualCapture.shared?.snapshotNow()
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Mask a view from recording
|
|
423
|
+
*/
|
|
424
|
+
fun maskView(view: View) {
|
|
425
|
+
mainHandler.post {
|
|
426
|
+
ReplayOrchestrator.shared?.redactView(view)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Unmask a view from recording
|
|
432
|
+
*/
|
|
433
|
+
fun unmaskView(view: View) {
|
|
434
|
+
mainHandler.post {
|
|
435
|
+
ReplayOrchestrator.shared?.unredactView(view)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Set debug mode
|
|
441
|
+
*/
|
|
442
|
+
fun setDebugMode(enabled: Boolean) {
|
|
443
|
+
DiagnosticLog.setVerbose(enabled)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Set custom user data
|
|
448
|
+
*/
|
|
449
|
+
fun setUserData(key: String, value: String) {
|
|
450
|
+
ReplayOrchestrator.shared?.attachAttribute(key, value)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Get device info
|
|
455
|
+
*/
|
|
456
|
+
fun getDeviceInfo(): Map<String, Any> {
|
|
457
|
+
val androidId = Settings.Secure.getString(
|
|
458
|
+
context.contentResolver,
|
|
459
|
+
Settings.Secure.ANDROID_ID
|
|
460
|
+
) ?: "unknown"
|
|
461
|
+
|
|
462
|
+
val deviceHash = try {
|
|
463
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
464
|
+
val hash = digest.digest(androidId.toByteArray())
|
|
465
|
+
hash.joinToString("") { "%02x".format(it) }
|
|
466
|
+
} catch (e: Exception) {
|
|
467
|
+
""
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return mapOf(
|
|
471
|
+
"platform" to "android",
|
|
472
|
+
"osVersion" to android.os.Build.VERSION.RELEASE,
|
|
473
|
+
"model" to android.os.Build.MODEL,
|
|
474
|
+
"brand" to android.os.Build.MANUFACTURER,
|
|
475
|
+
"deviceHash" to deviceHash
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// MARK: - Activity Lifecycle Callbacks
|
|
480
|
+
|
|
481
|
+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
|
482
|
+
override fun onActivityStarted(activity: Activity) {}
|
|
483
|
+
override fun onActivityResumed(activity: Activity) {}
|
|
484
|
+
override fun onActivityPaused(activity: Activity) {}
|
|
485
|
+
override fun onActivityStopped(activity: Activity) {}
|
|
486
|
+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
|
487
|
+
override fun onActivityDestroyed(activity: Activity) {}
|
|
488
|
+
|
|
489
|
+
// MARK: - Cleanup
|
|
490
|
+
|
|
491
|
+
fun shutdown() {
|
|
492
|
+
stateLock.withLock {
|
|
493
|
+
when (state) {
|
|
494
|
+
is SessionState.Active, is SessionState.Paused -> {
|
|
495
|
+
state = SessionState.Terminated
|
|
496
|
+
TelemetryPipeline.shared?.finalizeAndShip()
|
|
497
|
+
SegmentDispatcher.shared.shipPending()
|
|
498
|
+
}
|
|
499
|
+
else -> {}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
(context.applicationContext as? Application)?.unregisterActivityLifecycleCallbacks(this)
|
|
505
|
+
mainHandler.post {
|
|
506
|
+
try {
|
|
507
|
+
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
|
508
|
+
} catch (_: Exception) {}
|
|
509
|
+
}
|
|
510
|
+
} catch (_: Exception) {}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
* Utility to detect Android OEM (Original Equipment Manufacturer) and handle
|
|
19
|
+
* OEM-specific quirks and behaviors.
|
|
20
|
+
*
|
|
21
|
+
* Different OEMs have different behaviors for app lifecycle, especially around
|
|
22
|
+
* task removal and service callbacks. This utility helps detect and handle
|
|
23
|
+
* these differences.
|
|
24
|
+
*
|
|
25
|
+
* ANDROID-SPECIFIC: This has no iOS equivalent - iOS has consistent behavior
|
|
26
|
+
* across all devices since Apple controls the hardware and OS.
|
|
27
|
+
*/
|
|
28
|
+
package com.rejourney.platform
|
|
29
|
+
|
|
30
|
+
import android.os.Build
|
|
31
|
+
import com.rejourney.engine.DiagnosticLog
|
|
32
|
+
|
|
33
|
+
object OEMDetector {
|
|
34
|
+
|
|
35
|
+
enum class OEM {
|
|
36
|
+
SAMSUNG,
|
|
37
|
+
XIAOMI,
|
|
38
|
+
HUAWEI,
|
|
39
|
+
ONEPLUS,
|
|
40
|
+
OPPO,
|
|
41
|
+
VIVO,
|
|
42
|
+
PIXEL,
|
|
43
|
+
STOCK_ANDROID,
|
|
44
|
+
UNKNOWN
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private val oem: OEM by lazy {
|
|
48
|
+
detectOEM()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the detected OEM.
|
|
53
|
+
*/
|
|
54
|
+
fun getOEM(): OEM = oem
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if running on Samsung device.
|
|
58
|
+
* Samsung has known bugs with onTaskRemoved() firing incorrectly.
|
|
59
|
+
*/
|
|
60
|
+
fun isSamsung(): Boolean = oem == OEM.SAMSUNG
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if running on Pixel or stock Android.
|
|
64
|
+
* These devices generally have more reliable lifecycle callbacks.
|
|
65
|
+
*/
|
|
66
|
+
fun isPixelOrStock(): Boolean = oem == OEM.PIXEL || oem == OEM.STOCK_ANDROID
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if running on OEMs with aggressive task killing.
|
|
70
|
+
* These OEMs may not reliably call onTaskRemoved().
|
|
71
|
+
*/
|
|
72
|
+
fun hasAggressiveTaskKilling(): Boolean {
|
|
73
|
+
return oem == OEM.XIAOMI ||
|
|
74
|
+
oem == OEM.HUAWEI ||
|
|
75
|
+
oem == OEM.OPPO ||
|
|
76
|
+
oem == OEM.VIVO
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if onTaskRemoved() is likely to work reliably on this device.
|
|
81
|
+
*/
|
|
82
|
+
fun isTaskRemovedReliable(): Boolean {
|
|
83
|
+
// Pixel/Stock Android: Generally reliable
|
|
84
|
+
if (isPixelOrStock()) return true
|
|
85
|
+
|
|
86
|
+
// Samsung: Has bugs but sometimes works
|
|
87
|
+
if (isSamsung()) return true // We'll add validation to filter false positives
|
|
88
|
+
|
|
89
|
+
// Aggressive OEMs: Often don't call onTaskRemoved
|
|
90
|
+
if (hasAggressiveTaskKilling()) return false
|
|
91
|
+
|
|
92
|
+
// Unknown: Assume it might work
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Detect the OEM based on manufacturer and brand.
|
|
98
|
+
*/
|
|
99
|
+
private fun detectOEM(): OEM {
|
|
100
|
+
val manufacturer = Build.MANUFACTURER.lowercase()
|
|
101
|
+
val brand = Build.BRAND.lowercase()
|
|
102
|
+
val model = Build.MODEL.lowercase()
|
|
103
|
+
|
|
104
|
+
return when {
|
|
105
|
+
// Samsung
|
|
106
|
+
manufacturer.contains("samsung") || brand.contains("samsung") -> {
|
|
107
|
+
DiagnosticLog.trace("OEM detected: Samsung")
|
|
108
|
+
OEM.SAMSUNG
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Xiaomi (includes Redmi, POCO)
|
|
112
|
+
manufacturer.contains("xiaomi") || brand.contains("xiaomi") ||
|
|
113
|
+
brand.contains("redmi") || brand.contains("poco") -> {
|
|
114
|
+
DiagnosticLog.trace("OEM detected: Xiaomi")
|
|
115
|
+
OEM.XIAOMI
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Huawei (includes Honor)
|
|
119
|
+
manufacturer.contains("huawei") || brand.contains("huawei") ||
|
|
120
|
+
brand.contains("honor") -> {
|
|
121
|
+
DiagnosticLog.trace("OEM detected: Huawei")
|
|
122
|
+
OEM.HUAWEI
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// OnePlus
|
|
126
|
+
manufacturer.contains("oneplus") || brand.contains("oneplus") -> {
|
|
127
|
+
DiagnosticLog.trace("OEM detected: OnePlus")
|
|
128
|
+
OEM.ONEPLUS
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// OPPO
|
|
132
|
+
manufacturer.contains("oppo") || brand.contains("oppo") -> {
|
|
133
|
+
DiagnosticLog.trace("OEM detected: OPPO")
|
|
134
|
+
OEM.OPPO
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Vivo
|
|
138
|
+
manufacturer.contains("vivo") || brand.contains("vivo") -> {
|
|
139
|
+
DiagnosticLog.trace("OEM detected: Vivo")
|
|
140
|
+
OEM.VIVO
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Google Pixel
|
|
144
|
+
manufacturer.contains("google") && (model.contains("pixel") || brand.contains("google")) -> {
|
|
145
|
+
DiagnosticLog.trace("OEM detected: Pixel")
|
|
146
|
+
OEM.PIXEL
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Stock Android (Google devices that aren't Pixel)
|
|
150
|
+
manufacturer.contains("google") -> {
|
|
151
|
+
DiagnosticLog.trace("OEM detected: Stock Android")
|
|
152
|
+
OEM.STOCK_ANDROID
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
else -> {
|
|
156
|
+
DiagnosticLog.trace("OEM detected: Unknown (manufacturer=$manufacturer, brand=$brand)")
|
|
157
|
+
OEM.UNKNOWN
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get OEM-specific recommendations for app termination detection.
|
|
164
|
+
*/
|
|
165
|
+
fun getRecommendations(): String {
|
|
166
|
+
return when (oem) {
|
|
167
|
+
OEM.SAMSUNG -> "Samsung devices may have onTaskRemoved() fire incorrectly on app launch. Using validation to filter false positives."
|
|
168
|
+
OEM.XIAOMI, OEM.HUAWEI, OEM.OPPO, OEM.VIVO -> "This OEM has aggressive task killing. onTaskRemoved() may not fire. Relying on ApplicationExitInfo and persistent state checks."
|
|
169
|
+
OEM.PIXEL, OEM.STOCK_ANDROID -> "Stock Android - onTaskRemoved() should work reliably."
|
|
170
|
+
else -> "Unknown OEM - using standard detection methods."
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|