@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.
Files changed (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. 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
+ }