@rejourneyco/react-native 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/android/build.gradle.kts +135 -0
  2. package/android/consumer-rules.pro +10 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/AndroidManifest.xml +15 -0
  5. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
  6. package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
  7. package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
  8. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
  9. package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
  10. package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
  11. package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
  12. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
  13. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
  14. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
  15. package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
  16. package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
  17. package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
  18. package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
  19. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
  20. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
  21. package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
  22. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
  23. package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
  24. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
  25. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
  26. package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
  27. package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
  28. package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
  29. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
  30. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
  31. package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
  32. package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
  33. package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
  34. package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
  35. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
  36. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  37. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
  38. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  39. package/ios/Capture/RJANRHandler.h +42 -0
  40. package/ios/Capture/RJANRHandler.m +328 -0
  41. package/ios/Capture/RJCaptureEngine.h +275 -0
  42. package/ios/Capture/RJCaptureEngine.m +2062 -0
  43. package/ios/Capture/RJCaptureHeuristics.h +80 -0
  44. package/ios/Capture/RJCaptureHeuristics.m +903 -0
  45. package/ios/Capture/RJCrashHandler.h +46 -0
  46. package/ios/Capture/RJCrashHandler.m +313 -0
  47. package/ios/Capture/RJMotionEvent.h +183 -0
  48. package/ios/Capture/RJMotionEvent.m +183 -0
  49. package/ios/Capture/RJPerformanceManager.h +100 -0
  50. package/ios/Capture/RJPerformanceManager.m +373 -0
  51. package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
  52. package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
  53. package/ios/Capture/RJSegmentUploader.h +146 -0
  54. package/ios/Capture/RJSegmentUploader.m +778 -0
  55. package/ios/Capture/RJVideoEncoder.h +247 -0
  56. package/ios/Capture/RJVideoEncoder.m +1036 -0
  57. package/ios/Capture/RJViewControllerTracker.h +73 -0
  58. package/ios/Capture/RJViewControllerTracker.m +508 -0
  59. package/ios/Capture/RJViewHierarchyScanner.h +215 -0
  60. package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
  61. package/ios/Capture/RJViewSerializer.h +119 -0
  62. package/ios/Capture/RJViewSerializer.m +498 -0
  63. package/ios/Core/RJConstants.h +124 -0
  64. package/ios/Core/RJConstants.m +88 -0
  65. package/ios/Core/RJLifecycleManager.h +85 -0
  66. package/ios/Core/RJLifecycleManager.m +308 -0
  67. package/ios/Core/RJLogger.h +61 -0
  68. package/ios/Core/RJLogger.m +211 -0
  69. package/ios/Core/RJTypes.h +176 -0
  70. package/ios/Core/RJTypes.m +66 -0
  71. package/ios/Core/Rejourney.h +64 -0
  72. package/ios/Core/Rejourney.mm +2495 -0
  73. package/ios/Network/RJDeviceAuthManager.h +94 -0
  74. package/ios/Network/RJDeviceAuthManager.m +967 -0
  75. package/ios/Network/RJNetworkMonitor.h +68 -0
  76. package/ios/Network/RJNetworkMonitor.m +267 -0
  77. package/ios/Network/RJRetryManager.h +73 -0
  78. package/ios/Network/RJRetryManager.m +325 -0
  79. package/ios/Network/RJUploadManager.h +267 -0
  80. package/ios/Network/RJUploadManager.m +2296 -0
  81. package/ios/Privacy/RJPrivacyMask.h +163 -0
  82. package/ios/Privacy/RJPrivacyMask.m +922 -0
  83. package/ios/Rejourney.h +63 -0
  84. package/ios/Touch/RJGestureClassifier.h +130 -0
  85. package/ios/Touch/RJGestureClassifier.m +333 -0
  86. package/ios/Touch/RJTouchInterceptor.h +169 -0
  87. package/ios/Touch/RJTouchInterceptor.m +772 -0
  88. package/ios/Utils/RJEventBuffer.h +112 -0
  89. package/ios/Utils/RJEventBuffer.m +358 -0
  90. package/ios/Utils/RJGzipUtils.h +33 -0
  91. package/ios/Utils/RJGzipUtils.m +89 -0
  92. package/ios/Utils/RJKeychainManager.h +48 -0
  93. package/ios/Utils/RJKeychainManager.m +111 -0
  94. package/ios/Utils/RJPerfTiming.h +209 -0
  95. package/ios/Utils/RJPerfTiming.m +264 -0
  96. package/ios/Utils/RJTelemetry.h +92 -0
  97. package/ios/Utils/RJTelemetry.m +320 -0
  98. package/ios/Utils/RJWindowUtils.h +66 -0
  99. package/ios/Utils/RJWindowUtils.m +133 -0
  100. package/lib/commonjs/NativeRejourney.js +40 -0
  101. package/lib/commonjs/components/Mask.js +79 -0
  102. package/lib/commonjs/index.js +1381 -0
  103. package/lib/commonjs/sdk/autoTracking.js +1259 -0
  104. package/lib/commonjs/sdk/constants.js +151 -0
  105. package/lib/commonjs/sdk/errorTracking.js +199 -0
  106. package/lib/commonjs/sdk/index.js +50 -0
  107. package/lib/commonjs/sdk/metricsTracking.js +204 -0
  108. package/lib/commonjs/sdk/navigation.js +151 -0
  109. package/lib/commonjs/sdk/networkInterceptor.js +412 -0
  110. package/lib/commonjs/sdk/utils.js +363 -0
  111. package/lib/commonjs/types/expo-router.d.js +2 -0
  112. package/lib/commonjs/types/index.js +2 -0
  113. package/lib/module/NativeRejourney.js +38 -0
  114. package/lib/module/components/Mask.js +72 -0
  115. package/lib/module/index.js +1284 -0
  116. package/lib/module/sdk/autoTracking.js +1233 -0
  117. package/lib/module/sdk/constants.js +145 -0
  118. package/lib/module/sdk/errorTracking.js +189 -0
  119. package/lib/module/sdk/index.js +12 -0
  120. package/lib/module/sdk/metricsTracking.js +187 -0
  121. package/lib/module/sdk/navigation.js +143 -0
  122. package/lib/module/sdk/networkInterceptor.js +401 -0
  123. package/lib/module/sdk/utils.js +342 -0
  124. package/lib/module/types/expo-router.d.js +2 -0
  125. package/lib/module/types/index.js +2 -0
  126. package/lib/typescript/NativeRejourney.d.ts +147 -0
  127. package/lib/typescript/components/Mask.d.ts +39 -0
  128. package/lib/typescript/index.d.ts +117 -0
  129. package/lib/typescript/sdk/autoTracking.d.ts +204 -0
  130. package/lib/typescript/sdk/constants.d.ts +120 -0
  131. package/lib/typescript/sdk/errorTracking.d.ts +32 -0
  132. package/lib/typescript/sdk/index.d.ts +9 -0
  133. package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
  134. package/lib/typescript/sdk/navigation.d.ts +33 -0
  135. package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
  136. package/lib/typescript/sdk/utils.d.ts +148 -0
  137. package/lib/typescript/types/index.d.ts +624 -0
  138. package/package.json +102 -0
  139. package/rejourney.podspec +21 -0
  140. package/src/NativeRejourney.ts +165 -0
  141. package/src/components/Mask.tsx +80 -0
  142. package/src/index.ts +1459 -0
  143. package/src/sdk/autoTracking.ts +1373 -0
  144. package/src/sdk/constants.ts +134 -0
  145. package/src/sdk/errorTracking.ts +231 -0
  146. package/src/sdk/index.ts +11 -0
  147. package/src/sdk/metricsTracking.ts +232 -0
  148. package/src/sdk/navigation.ts +157 -0
  149. package/src/sdk/networkInterceptor.ts +440 -0
  150. package/src/sdk/utils.ts +369 -0
  151. package/src/types/expo-router.d.ts +7 -0
  152. package/src/types/index.ts +739 -0
@@ -0,0 +1,1363 @@
1
+ /**
2
+ * Upload management for events and video segment coordination.
3
+ * Uses presigned S3 URLs for production-ready uploads (matching iOS).
4
+ *
5
+ * Video segments are uploaded via SegmentUploader.
6
+ * Events are uploaded directly here via presigned URL flow.
7
+ *
8
+ * Flow:
9
+ * 1. Request presigned URL from /api/ingest/presign
10
+ * 2. Gzip payload and upload directly to S3
11
+ * 3. Notify backend via /api/ingest/batch/complete
12
+ * 4. On session end: /api/ingest/session/end
13
+ */
14
+ package com.rejourney.network
15
+
16
+ import android.content.Context
17
+ import android.os.Build
18
+ import com.rejourney.core.Constants
19
+ import com.rejourney.core.Logger
20
+ import com.rejourney.utils.Telemetry
21
+ import com.rejourney.utils.TelemetryEventType
22
+ import com.rejourney.utils.WindowUtils
23
+ import com.rejourney.capture.SegmentUploader
24
+ import kotlinx.coroutines.*
25
+ import kotlinx.coroutines.sync.Mutex
26
+ import kotlinx.coroutines.sync.withLock
27
+ import okhttp3.*
28
+ import okhttp3.MediaType.Companion.toMediaType
29
+ import okhttp3.RequestBody.Companion.toRequestBody
30
+ import org.json.JSONArray
31
+ import org.json.JSONObject
32
+ import java.io.File
33
+ import java.io.FileOutputStream
34
+ import java.io.ByteArrayOutputStream
35
+ import java.io.IOException
36
+ import java.util.concurrent.TimeUnit
37
+ import java.util.concurrent.atomic.AtomicInteger
38
+ import java.util.zip.GZIPOutputStream
39
+
40
+ class UploadManager(
41
+ private val context: Context,
42
+ var apiUrl: String
43
+ ) {
44
+ // Configuration
45
+ var publicKey: String = ""
46
+ var deviceHash: String = ""
47
+
48
+ // NUCLEAR FIX: Two session ID fields to prevent recovery from corrupting the current session
49
+ // - sessionId: Used by uploadGzippedContent/uploadContent for recovery operations (can be temporarily changed)
50
+ // - activeSessionId: The REAL current session ID, never modified by recovery - use this for current session operations
51
+ var sessionId: String? = null
52
+ private var activeSessionId: String? = null
53
+
54
+ var userId: String = ""
55
+ var sessionStartTime: Long = 0
56
+ var projectId: String? = null
57
+
58
+ // Background time tracking for billing exclusion
59
+ var totalBackgroundTimeMs: Long = 0
60
+
61
+ // Billing blocked flag - set when 402 is received
62
+ var billingBlocked: Boolean = false
63
+
64
+ private var batchNumber = AtomicInteger(0)
65
+
66
+ // Video segment uploader
67
+ private var segmentUploader: SegmentUploader? = null
68
+
69
+ // Circuit breaker state
70
+ private var consecutiveFailures = AtomicInteger(0)
71
+ private var circuitOpen = false
72
+ private var circuitOpenTime: Long = 0
73
+ private val circuitBreakerThreshold = 5
74
+ private val circuitResetTimeMs = 60_000L
75
+
76
+ // Retry configuration
77
+ private val maxRetries = 3
78
+ private val initialRetryDelayMs = 1000L
79
+
80
+ // Use shared client (SSL pinning removed)
81
+ private val client = HttpClientProvider.shared
82
+
83
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
84
+
85
+ // Serialize uploads + endSession so stopSession can't race an in-flight timer upload.
86
+ private val uploadMutex = Mutex()
87
+
88
+ // Crash-safe persistence (disk-backed pending uploads)
89
+ private val pendingRootDir: File by lazy {
90
+ File(context.filesDir, "rejourney/pending_uploads").apply { mkdirs() }
91
+ }
92
+
93
+ /**
94
+ * Reset state for new session. Should be called when sessionId changes.
95
+ */
96
+ fun resetForNewSession() {
97
+ batchNumber.set(0)
98
+ totalBackgroundTimeMs = 0
99
+ billingBlocked = false // Reset billing blocked state for new session
100
+ // CRITICAL FIX: Clear cached SegmentUploader to prevent stale session data
101
+ segmentUploader = null
102
+ }
103
+
104
+ /**
105
+ * Set the active session ID. This is the REAL current session ID.
106
+ * Call this instead of setting sessionId directly when starting a new session.
107
+ * The activeSessionId is protected from recovery operations.
108
+ */
109
+ fun setActiveSessionId(newSessionId: String) {
110
+ activeSessionId = newSessionId
111
+ sessionId = newSessionId // Keep in sync initially
112
+ Logger.debug("[UploadManager] sessionId SET to: $newSessionId (activeSessionId=$activeSessionId)")
113
+ }
114
+
115
+ /**
116
+ * Get the current active session ID.
117
+ * This is protected from recovery operations and always returns the real current session.
118
+ */
119
+ fun getCurrentSessionId(): String? = activeSessionId
120
+
121
+ /**
122
+ * Mark a session as active for crash recovery (written to disk).
123
+ * If the process dies before session end, recovery can close it next launch.
124
+ */
125
+ fun markSessionActive(sessionId: String, sessionStartTime: Long) {
126
+ Logger.debug("[UploadManager] markSessionActive: START (sessionId=$sessionId, sessionStartTime=$sessionStartTime, totalBackgroundTimeMs=$totalBackgroundTimeMs)")
127
+ try {
128
+ val dir = File(pendingRootDir, sessionId).apply { mkdirs() }
129
+ val metaFile = File(dir, "session.json")
130
+ val meta = JSONObject().apply {
131
+ put("sessionId", sessionId)
132
+ put("sessionStartTime", sessionStartTime)
133
+ put("totalBackgroundTimeMs", totalBackgroundTimeMs)
134
+ put("updatedAt", System.currentTimeMillis())
135
+ }
136
+ metaFile.writeText(meta.toString())
137
+ Logger.debug("[UploadManager] markSessionActive: ✅ Session metadata written to ${metaFile.absolutePath}")
138
+ Logger.debug("[UploadManager] markSessionActive: Metadata: sessionId=$sessionId, startTime=$sessionStartTime, bgTime=$totalBackgroundTimeMs")
139
+ } catch (e: Exception) {
140
+ Logger.error("[UploadManager] markSessionActive: ❌ Failed to mark session active: ${e.message}", e)
141
+ }
142
+ }
143
+
144
+ fun updateSessionRecoveryMeta(sessionId: String) {
145
+ Logger.debug("[UploadManager] updateSessionRecoveryMeta: START (sessionId=$sessionId, totalBackgroundTimeMs=$totalBackgroundTimeMs)")
146
+ try {
147
+ val dir = File(pendingRootDir, sessionId)
148
+ val metaFile = File(dir, "session.json")
149
+ if (!metaFile.exists()) {
150
+ Logger.warning("[UploadManager] updateSessionRecoveryMeta: ⚠️ Session metadata file does not exist: ${metaFile.absolutePath}")
151
+ return
152
+ }
153
+
154
+ val meta = JSONObject(metaFile.readText())
155
+ val oldBgTime = meta.optLong("totalBackgroundTimeMs", 0)
156
+ meta.put("totalBackgroundTimeMs", totalBackgroundTimeMs)
157
+ meta.put("updatedAt", System.currentTimeMillis())
158
+ metaFile.writeText(meta.toString())
159
+ Logger.debug("[UploadManager] updateSessionRecoveryMeta: ✅ Updated session metadata (bgTime: $oldBgTime -> $totalBackgroundTimeMs)")
160
+ } catch (e: Exception) {
161
+ Logger.error("[UploadManager] updateSessionRecoveryMeta: ❌ Failed to update session recovery meta: ${e.message}", e)
162
+ }
163
+ }
164
+
165
+ fun clearSessionRecovery(sessionId: String) {
166
+ try {
167
+ val dir = File(pendingRootDir, sessionId)
168
+ if (dir.exists()) {
169
+ dir.deleteRecursively()
170
+ }
171
+ } catch (e: Exception) {
172
+ Logger.warning("Failed to clear session recovery: ${e.message}")
173
+ }
174
+ }
175
+
176
+ /**
177
+ * @deprecated Recovery is now handled by WorkManager.UploadWorker.scheduleRecoveryUpload()
178
+ * which runs independently without blocking the current session's uploads.
179
+ * This function held the uploadMutex for the entire recovery duration, blocking all
180
+ * current session uploads and causing timeouts.
181
+ *
182
+ * DO NOT CALL THIS FUNCTION. It remains only for reference.
183
+ */
184
+ @Deprecated("Use UploadWorker.scheduleRecoveryUpload() instead - this blocks all uploads")
185
+ suspend fun recoverPendingSessions(): Boolean {
186
+ Logger.warning("[UploadManager] recoverPendingSessions is DEPRECATED - use WorkManager recovery instead")
187
+ return true // No-op, return success
188
+ }
189
+
190
+ /**
191
+ * Upload a batch of events using presigned S3 URLs.
192
+ * Video segments are uploaded separately via uploadVideoSegment().
193
+ *
194
+ * NOTE: Mutex removed as part of the nuclear rewrite. Recovery is now handled
195
+ * by WorkManager independently, so there's no mutex contention.
196
+ */
197
+ suspend fun uploadBatch(
198
+ events: List<Map<String, Any?>>,
199
+ isFinal: Boolean = false
200
+ ): Boolean {
201
+ // CRITICAL FIX: Use activeSessionId for current session operations.
202
+ // sessionId can be temporarily modified by recoverPendingSessions(), causing events
203
+ // to be uploaded to the WRONG session. activeSessionId is protected from recovery.
204
+ val effectiveSessionId = activeSessionId ?: sessionId
205
+ Logger.debug("[UploadManager] uploadBatch: START (eventCount=${events.size}, isFinal=$isFinal, effectiveSessionId=$effectiveSessionId)")
206
+
207
+ // Skip uploads if billing is blocked
208
+ if (billingBlocked) {
209
+ Logger.warning("[UploadManager] uploadBatch: Upload skipped - billing blocked")
210
+ return false
211
+ }
212
+
213
+ if (events.isEmpty() && !isFinal) {
214
+ Logger.debug("[UploadManager] uploadBatch: No events and not final, returning success")
215
+ return true
216
+ }
217
+
218
+ val startTime = System.currentTimeMillis()
219
+ val currentBatch = batchNumber.getAndIncrement()
220
+ var success = true
221
+ var persistedOk = true
222
+ val canUploadNow = canUpload()
223
+
224
+ Logger.debug("[UploadManager] uploadBatch: batchNumber=$currentBatch, canUploadNow=$canUploadNow, effectiveSessionId=$effectiveSessionId")
225
+
226
+ // If we're online, first flush any previously persisted pending payloads
227
+ // for the *current* session (offline queue drain).
228
+ if (canUploadNow) {
229
+ effectiveSessionId?.takeIf { it.isNotBlank() }?.let { sid ->
230
+ val ok = flushPendingForSession(sid)
231
+ if (!ok) {
232
+ // Don't block new data persistence; just mark as failure.
233
+ success = false
234
+ }
235
+ }
236
+ }
237
+
238
+ try {
239
+ // Upload events if present OR if this is the final batch (to send duration/endTime)
240
+ if (events.isNotEmpty() || isFinal) {
241
+ Logger.debug("[UploadManager] uploadBatch: Building payload (eventCount=${events.size}, isFinal=$isFinal)")
242
+ val payload = buildEventsPayload(events, currentBatch, isFinal)
243
+ Logger.debug("[UploadManager] uploadBatch: Payload built, size=${payload.size} bytes")
244
+
245
+ if (canUploadNow) {
246
+ Logger.debug("[UploadManager] uploadBatch: Uploading events batch $currentBatch online")
247
+ val eventsSuccess = uploadContent(
248
+ contentType = "events",
249
+ batchNumber = currentBatch,
250
+ content = payload,
251
+ eventCount = events.size,
252
+ frameCount = 0
253
+ )
254
+ if (!eventsSuccess) {
255
+ Logger.error("[UploadManager] uploadBatch: ❌ Events upload failed for batch $currentBatch")
256
+ success = false
257
+ } else {
258
+ Logger.debug("[UploadManager] uploadBatch: ✅ Events uploaded successfully for batch $currentBatch")
259
+ }
260
+ } else {
261
+ Logger.debug("[UploadManager] uploadBatch: Offline/circuit-open, persisting batch $currentBatch to disk")
262
+ val ok = persistOnly(
263
+ contentType = "events",
264
+ batchNumber = currentBatch,
265
+ content = payload,
266
+ eventCount = events.size,
267
+ frameCount = 0,
268
+ isKeyframe = false
269
+ )
270
+ if (!ok) {
271
+ Logger.error("[UploadManager] uploadBatch: ❌ Failed to persist batch $currentBatch")
272
+ persistedOk = false
273
+ success = false
274
+ } else {
275
+ Logger.debug("[UploadManager] uploadBatch: ✅ Queued events batch $currentBatch (offline/circuit-open)")
276
+ }
277
+ }
278
+ } else {
279
+ Logger.debug("[UploadManager] uploadBatch: No events to upload and not final, skipping")
280
+ }
281
+
282
+ val duration = System.currentTimeMillis() - startTime
283
+ if (success) {
284
+ Logger.debug("[UploadManager] uploadBatch: ✅ Upload success in ${duration}ms (batch=$currentBatch)")
285
+ onUploadSuccess(duration)
286
+ } else {
287
+ Logger.error("[UploadManager] uploadBatch: ❌ Upload failed after ${duration}ms (batch=$currentBatch)")
288
+ onUploadFailure()
289
+ }
290
+ } catch (e: CancellationException) {
291
+ // Normal cancellation (e.g., app going to background) - not an error
292
+ // The data is persisted on disk and WorkManager will handle the upload
293
+ Logger.debug("[UploadManager] uploadBatch: Batch upload cancelled - WorkManager will handle upload")
294
+ throw e // Re-throw to propagate cancellation properly
295
+ } catch (e: Exception) {
296
+ Logger.error("[UploadManager] uploadBatch: ❌ Exception during batch upload: ${e.message}", e)
297
+ onUploadFailure()
298
+ success = false
299
+ }
300
+
301
+ // Semantics: return true when data is either uploaded (online) or safely persisted (offline).
302
+ val result = if (canUploadNow) {
303
+ success
304
+ } else {
305
+ persistedOk
306
+ }
307
+
308
+ Logger.debug("[UploadManager] uploadBatch: END (result=$result, batch=$currentBatch)")
309
+ return result
310
+ }
311
+
312
+ /**
313
+ * Upload a video segment file.
314
+ * This delegates to SegmentUploader for presigned URL flow.
315
+ */
316
+ suspend fun uploadVideoSegment(
317
+ segmentFile: File,
318
+ startTime: Long,
319
+ endTime: Long,
320
+ frameCount: Int
321
+ ): Boolean {
322
+ // CRITICAL FIX: Extract session ID from segment filename instead of using this.sessionId
323
+ // The segment filename format is: seg_<sessionId>_<timestamp>.mp4
324
+ // This ensures we upload to the correct session even when this.sessionId is stale
325
+ // (e.g., from a previous session that wasn't properly cleaned up)
326
+ val sid = extractSessionIdFromFilename(segmentFile.name) ?: sessionId ?: return false
327
+
328
+ // DEBUG: Log to trace stale session ID issue
329
+ Logger.debug("[UploadManager] uploadVideoSegment START (file=${segmentFile.name}, frames=$frameCount)")
330
+ Logger.debug("[UploadManager] sessionId from filename=$sid, this.sessionId=$sessionId")
331
+
332
+ // CRITICAL FIX: Ensure valid token before segment upload
333
+ // Same pattern as UploadWorker fix - token may have expired since uploader was created
334
+ val authManager = DeviceAuthManager.getInstance(context)
335
+ val tokenValid = authManager.ensureValidToken()
336
+ if (!tokenValid) {
337
+ Logger.warning("[UploadManager] No valid token for video segment upload - upload may fail")
338
+ }
339
+
340
+ val uploader = getOrCreateSegmentUploader()
341
+ // Always update token before each upload (may have been refreshed)
342
+ uploader.uploadToken = authManager.getCurrentUploadToken()
343
+
344
+ val result = uploader.uploadVideoSegment(
345
+ segmentFile = segmentFile,
346
+ sessionId = sid,
347
+ startTime = startTime,
348
+ endTime = endTime,
349
+ frameCount = frameCount
350
+ )
351
+
352
+ if (result.success) {
353
+ Logger.debug("[UploadManager] Video segment uploaded: ${segmentFile.name}")
354
+ } else {
355
+ Logger.warning("[UploadManager] Video segment upload failed: ${result.error}")
356
+ }
357
+
358
+ return result.success
359
+ }
360
+
361
+ /**
362
+ * Extract session ID from segment filename.
363
+ * Filename format: seg_<sessionId>_<timestamp>.mp4
364
+ * Example: seg_session_1768582930679_9510E45F_1768582931692.mp4
365
+ * Returns: session_1768582930679_9510E45F
366
+ */
367
+ private fun extractSessionIdFromFilename(filename: String): String? {
368
+ // Remove prefix and extension
369
+ // Format: seg_session_<timestamp>_<hash>_<segmentTimestamp>.mp4
370
+ if (!filename.startsWith("seg_") || !filename.endsWith(".mp4")) {
371
+ return null
372
+ }
373
+
374
+ val withoutPrefix = filename.removePrefix("seg_").removeSuffix(".mp4")
375
+
376
+ // The session ID is everything except the last underscore-separated component (segment timestamp)
377
+ val lastUnderscore = withoutPrefix.lastIndexOf('_')
378
+ if (lastUnderscore <= 0) {
379
+ return null
380
+ }
381
+
382
+ return withoutPrefix.substring(0, lastUnderscore)
383
+ }
384
+
385
+ /**
386
+ * Upload a view hierarchy snapshot.
387
+ *
388
+ * @param sessionId The session ID to upload under. Must be provided explicitly
389
+ * to avoid stale session ID issues where this.sessionId may
390
+ * still reference a previous session.
391
+ */
392
+ suspend fun uploadHierarchy(
393
+ hierarchyData: ByteArray,
394
+ timestamp: Long,
395
+ sessionId: String
396
+ ): Boolean {
397
+ Logger.debug("[UploadManager] ===== UPLOAD HIERARCHY START =====")
398
+ Logger.debug("[UploadManager] uploadHierarchy: size=${hierarchyData.size} bytes, timestamp=$timestamp, sessionId=$sessionId")
399
+ Logger.debug("[UploadManager] uploadHierarchy: this.sessionId=$sessionId, activeSessionId=$activeSessionId")
400
+
401
+ // CRITICAL FIX: Ensure valid token before hierarchy upload
402
+ // Same pattern as uploadVideoSegment - token may have expired since uploader was created
403
+ val authManager = DeviceAuthManager.getInstance(context)
404
+ Logger.debug("[UploadManager] uploadHierarchy: Ensuring valid auth token...")
405
+ val tokenValid = authManager.ensureValidToken()
406
+ if (!tokenValid) {
407
+ Logger.error("[UploadManager] uploadHierarchy: ❌ No valid token for hierarchy upload - upload may fail!")
408
+ } else {
409
+ Logger.debug("[UploadManager] uploadHierarchy: ✅ Auth token is valid")
410
+ }
411
+
412
+ val uploader = getOrCreateSegmentUploader()
413
+ // Always update token before each upload (may have been refreshed)
414
+ uploader.uploadToken = authManager.getCurrentUploadToken()
415
+ Logger.debug("[UploadManager] uploadHierarchy: SegmentUploader ready, apiKey=${uploader.apiKey?.take(8)}..., projectId=${uploader.projectId}")
416
+
417
+ Logger.debug("[UploadManager] uploadHierarchy: Calling SegmentUploader.uploadHierarchy...")
418
+ val uploadStartTime = System.currentTimeMillis()
419
+ val result = uploader.uploadHierarchy(
420
+ hierarchyData = hierarchyData,
421
+ sessionId = sessionId,
422
+ timestamp = timestamp
423
+ )
424
+ val uploadDuration = System.currentTimeMillis() - uploadStartTime
425
+
426
+ if (result.success) {
427
+ Logger.debug("[UploadManager] ✅ Hierarchy uploaded successfully in ${uploadDuration}ms: sessionId=$sessionId")
428
+ } else {
429
+ Logger.error("[UploadManager] ❌ Hierarchy upload FAILED after ${uploadDuration}ms: ${result.error}, sessionId=$sessionId")
430
+ }
431
+
432
+ Logger.debug("[UploadManager] ===== UPLOAD HIERARCHY END =====")
433
+ return result.success
434
+ }
435
+
436
+ private fun getOrCreateSegmentUploader(): SegmentUploader {
437
+ return segmentUploader ?: SegmentUploader(apiUrl).also {
438
+ it.apiKey = publicKey
439
+ it.projectId = projectId
440
+ it.uploadToken = DeviceAuthManager.getInstance(context).getCurrentUploadToken()
441
+ segmentUploader = it
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Persist a payload to disk for crash/offline recovery without attempting any network.
447
+ */
448
+ private fun persistOnly(
449
+ contentType: String,
450
+ batchNumber: Int,
451
+ content: ByteArray,
452
+ eventCount: Int,
453
+ frameCount: Int,
454
+ isKeyframe: Boolean
455
+ ): Boolean {
456
+ val gzipped = gzipData(content) ?: return false
457
+ // CRITICAL FIX: Use activeSessionId for current session operations
458
+ val sidForPersist = (activeSessionId ?: sessionId ?: "").ifBlank { "unknown" }
459
+ persistPendingUpload(
460
+ sessionId = sidForPersist,
461
+ contentType = contentType,
462
+ batchNumber = batchNumber,
463
+ isKeyframe = isKeyframe,
464
+ gzipped = gzipped,
465
+ eventCount = eventCount,
466
+ frameCount = frameCount
467
+ )
468
+ markSessionActive(sidForPersist, sessionStartTime)
469
+ updateSessionRecoveryMeta(sidForPersist)
470
+ return true
471
+ }
472
+
473
+ /**
474
+ * Flush pending .gz payloads for a specific session (including current session)
475
+ * when network is available.
476
+ */
477
+ private suspend fun flushPendingForSession(sessionId: String): Boolean {
478
+ val dir = File(pendingRootDir, sessionId)
479
+ if (!dir.exists() || !dir.isDirectory) return true
480
+
481
+ val pendingFiles = dir.listFiles()?.filter { it.isFile && it.name.endsWith(".gz") } ?: emptyList()
482
+ if (pendingFiles.isEmpty()) return true
483
+
484
+ var allOk = true
485
+ val sorted = pendingFiles.sortedWith(compareBy({ parseBatchNumberFromName(it.name) }, { it.name }))
486
+
487
+ for (file in sorted) {
488
+ val parsed = parsePendingFilename(file.name) ?: continue
489
+ val metaFile = File(dir, file.name + ".meta.json")
490
+ val meta = if (metaFile.exists()) {
491
+ try { JSONObject(metaFile.readText()) } catch (_: Exception) { JSONObject() }
492
+ } else JSONObject()
493
+ val eventCount = meta.optInt("eventCount", 0)
494
+ val frameCount = meta.optInt("frameCount", 0)
495
+ val isKeyframe = meta.optBoolean("isKeyframe", parsed.isKeyframe)
496
+
497
+ val gzipped = try { file.readBytes() } catch (e: Exception) {
498
+ Logger.warning("Failed to read pending upload ${file.name}: ${e.message}")
499
+ allOk = false
500
+ continue
501
+ }
502
+
503
+ val ok = uploadGzippedContent(
504
+ contentType = parsed.contentType,
505
+ batchNumber = parsed.batchNumber,
506
+ gzipped = gzipped,
507
+ eventCount = eventCount,
508
+ frameCount = frameCount,
509
+ isKeyframe = isKeyframe
510
+ )
511
+
512
+ if (ok) {
513
+ try {
514
+ file.delete()
515
+ metaFile.delete()
516
+ } catch (_: Exception) {
517
+ // ignore
518
+ }
519
+ } else {
520
+ allOk = false
521
+ }
522
+ }
523
+
524
+ return allOk
525
+ }
526
+
527
+ /**
528
+ * Upload content using presigned URL flow.
529
+ */
530
+ private suspend fun uploadContent(
531
+ contentType: String,
532
+ batchNumber: Int,
533
+ content: ByteArray,
534
+ eventCount: Int,
535
+ frameCount: Int,
536
+ isKeyframe: Boolean = false
537
+ ): Boolean {
538
+ // Step 1: Gzip the content
539
+ val gzipped = gzipData(content)
540
+ if (gzipped == null) {
541
+ Logger.error("Failed to gzip $contentType data")
542
+ return false
543
+ }
544
+
545
+ // Crash-safe persistence: write pending gzipped payload before any network
546
+ // CRITICAL FIX: Use activeSessionId for current session operations
547
+ val sidForPersist = (activeSessionId ?: sessionId ?: "").ifBlank { "unknown" }
548
+ val pendingFile = persistPendingUpload(
549
+ sessionId = sidForPersist,
550
+ contentType = contentType,
551
+ batchNumber = batchNumber,
552
+ isKeyframe = isKeyframe,
553
+ gzipped = gzipped,
554
+ eventCount = eventCount,
555
+ frameCount = frameCount
556
+ )
557
+
558
+ Logger.debug("$contentType batch $batchNumber: ${content.size} bytes -> ${gzipped.size} gzipped")
559
+
560
+ // Step 2: Request presigned URL
561
+ val presignResult = presignForContentType(contentType, batchNumber, gzipped.size, isKeyframe)
562
+ if (presignResult == null) {
563
+ Logger.error("Failed to get presigned URL for $contentType")
564
+ return false
565
+ }
566
+
567
+ // Check if server says to skip this upload (recording disabled for frames)
568
+ val skipUpload = presignResult.optBoolean("skipUpload", false)
569
+ if (skipUpload) {
570
+ Logger.debug("$contentType upload skipped - recording disabled for project")
571
+ // Clean up pending file since we don't need to upload
572
+ try {
573
+ pendingFile?.delete()
574
+ pendingFile?.let { File(it.parentFile, it.name + ".meta.json").delete() }
575
+ } catch (_: Exception) {}
576
+ return true // Return success - skip is intentional, not an error
577
+ }
578
+
579
+ val presignedUrl = presignResult.optString("presignedUrl")
580
+ val batchId = presignResult.optString("batchId")
581
+
582
+ // NUCLEAR FIX: Do NOT update this.sessionId from server response!
583
+ // This was corrupting the current session ID when uploading old/recovered sessions.
584
+ // The sessionId should only be set by RejourneyModuleImpl.startSession()
585
+
586
+ if (presignedUrl.isEmpty() || batchId.isEmpty()) {
587
+ Logger.error("Invalid presign response: $presignResult")
588
+ return false
589
+ }
590
+
591
+ // Step 3: Upload to S3
592
+ val uploadSuccess = uploadToS3(presignedUrl, gzipped)
593
+ if (!uploadSuccess) {
594
+ Logger.error("Failed to upload $contentType to S3")
595
+ return false
596
+ }
597
+
598
+ // Step 4: Complete batch
599
+ val completeSuccess = completeBatch(batchId, gzipped.size, eventCount, frameCount)
600
+ if (!completeSuccess) {
601
+ Logger.warning("Failed to complete batch $batchId (data uploaded to S3)")
602
+ } else {
603
+ // Only delete pending file when complete succeeds (safe to retry otherwise)
604
+ try {
605
+ pendingFile?.delete()
606
+ pendingFile?.let { File(it.parentFile, it.name + ".meta.json").delete() }
607
+ } catch (_: Exception) {
608
+ }
609
+ }
610
+
611
+ return true
612
+ }
613
+
614
+ private data class PendingName(
615
+ val contentType: String,
616
+ val batchNumber: Int,
617
+ val isKeyframe: Boolean
618
+ )
619
+
620
+ private fun parseBatchNumberFromName(name: String): Int {
621
+ val parsed = parsePendingFilename(name)
622
+ return parsed?.batchNumber ?: Int.MAX_VALUE
623
+ }
624
+
625
+ private fun parsePendingFilename(name: String): PendingName? {
626
+ // Format: <contentType>_<batchNumber>_<k|n>.gz
627
+ if (!name.endsWith(".gz")) return null
628
+ val base = name.removeSuffix(".gz")
629
+ val parts = base.split("_")
630
+ if (parts.size < 3) return null
631
+ val contentType = parts[0]
632
+ val batchNumber = parts[1].toIntOrNull() ?: return null
633
+ val keyFlag = parts[2]
634
+ val isKeyframe = keyFlag == "k"
635
+ return PendingName(contentType, batchNumber, isKeyframe)
636
+ }
637
+
638
+ private fun persistPendingUpload(
639
+ sessionId: String,
640
+ contentType: String,
641
+ batchNumber: Int,
642
+ isKeyframe: Boolean,
643
+ gzipped: ByteArray,
644
+ eventCount: Int,
645
+ frameCount: Int
646
+ ): File? {
647
+ return try {
648
+ val dir = File(pendingRootDir, sessionId).apply { mkdirs() }
649
+ val keyFlag = if (isKeyframe) "k" else "n"
650
+ val file = File(dir, "${contentType}_${batchNumber}_${keyFlag}.gz")
651
+ FileOutputStream(file).use { it.write(gzipped) }
652
+
653
+ val meta = JSONObject().apply {
654
+ put("contentType", contentType)
655
+ put("batchNumber", batchNumber)
656
+ put("isKeyframe", isKeyframe)
657
+ put("eventCount", eventCount)
658
+ put("frameCount", frameCount)
659
+ put("createdAt", System.currentTimeMillis())
660
+ }
661
+ File(dir, file.name + ".meta.json").writeText(meta.toString())
662
+ file
663
+ } catch (e: Exception) {
664
+ Logger.warning("Failed to persist pending upload: ${e.message}")
665
+ null
666
+ }
667
+ }
668
+
669
+ private suspend fun uploadGzippedContent(
670
+ contentType: String,
671
+ batchNumber: Int,
672
+ gzipped: ByteArray,
673
+ eventCount: Int,
674
+ frameCount: Int,
675
+ isKeyframe: Boolean
676
+ ): Boolean {
677
+ if (!canUpload()) return false
678
+
679
+ val presignResult = presignForContentType(contentType, batchNumber, gzipped.size, isKeyframe)
680
+ ?: return false
681
+
682
+ val presignedUrl = presignResult.optString("presignedUrl")
683
+ val batchId = presignResult.optString("batchId")
684
+ // NUCLEAR FIX: Do NOT update this.sessionId from server response!
685
+ // This was corrupting the current session ID when uploading old/recovered sessions.
686
+ if (presignedUrl.isEmpty() || batchId.isEmpty()) return false
687
+
688
+ val uploadSuccess = uploadToS3(presignedUrl, gzipped)
689
+ if (!uploadSuccess) return false
690
+
691
+ // Treat completion as required for deleting pending content
692
+ return completeBatch(batchId, gzipped.size, eventCount, frameCount)
693
+ }
694
+
695
+ /**
696
+ * Request presigned URL from backend.
697
+ */
698
+ private suspend fun presignForContentType(
699
+ contentType: String,
700
+ batchNumber: Int,
701
+ sizeBytes: Int,
702
+ isKeyframe: Boolean
703
+ ): JSONObject? {
704
+ Logger.debug("[UploadManager] presignForContentType START (type=$contentType, batch=$batchNumber, size=$sizeBytes, sessionId=${sessionId?.take(20)}...)")
705
+
706
+ // CRITICAL FIX: Use activeSessionId for current session operations
707
+ val effectiveSessionId = activeSessionId ?: sessionId ?: ""
708
+ val body = JSONObject().apply {
709
+ put("batchNumber", batchNumber)
710
+ put("contentType", contentType)
711
+ put("sizeBytes", sizeBytes)
712
+ put("userId", userId.ifEmpty { "anonymous" })
713
+ put("sessionId", effectiveSessionId)
714
+ put("sessionStartTime", sessionStartTime)
715
+ if (contentType == "frames") {
716
+ put("isKeyframe", isKeyframe)
717
+ }
718
+ }
719
+
720
+ return try {
721
+ withContext(Dispatchers.IO) {
722
+ val request = buildAuthenticatedRequest("/api/ingest/presign", body.toString())
723
+ Logger.debug("[UploadManager] Sending presign request to: ${request.url}")
724
+
725
+ val response = client.newCall(request).execute()
726
+
727
+ response.use {
728
+ Logger.debug("[UploadManager] Presign response code: ${it.code}")
729
+ if (it.isSuccessful) {
730
+ val responseBody = it.body?.string() ?: "{}"
731
+ val result = JSONObject(responseBody)
732
+ Logger.debug("[UploadManager] Presign SUCCESS - got batchId: ${result.optString("batchId", "null")}")
733
+ result
734
+ } else if (it.code == 402) {
735
+ // Payment required - billing blocked, stop uploads
736
+ Logger.warning("[UploadManager] Presign BLOCKED (402) - billing issue, stopping uploads")
737
+ billingBlocked = true
738
+ null
739
+ } else if (it.code == 401) {
740
+ val errorBody = it.body?.string() ?: ""
741
+ Logger.error("[UploadManager] Presign UNAUTHORIZED (401) - token likely invalid. Body: $errorBody")
742
+ null
743
+ } else {
744
+ val errorBody = it.body?.string() ?: ""
745
+ Logger.warning("[UploadManager] Presign FAILED: ${it.code} - $errorBody")
746
+ null
747
+ }
748
+ }
749
+ }
750
+ } catch (e: CancellationException) {
751
+ // Expected during shutdown - don't log as error
752
+ Logger.debug("[UploadManager] Presign request cancelled (shutdown)")
753
+ null
754
+ } catch (e: Exception) {
755
+ Logger.error("[UploadManager] Presign request EXCEPTION", e)
756
+ null
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Upload gzipped data to S3 via presigned URL.
762
+ */
763
+ private suspend fun uploadToS3(presignedUrl: String, data: ByteArray): Boolean {
764
+ Logger.debug("[UploadManager] uploadToS3 START (size=${data.size} bytes)")
765
+
766
+ return try {
767
+ withContext(Dispatchers.IO) {
768
+ val request = Request.Builder()
769
+ .url(presignedUrl)
770
+ .put(data.toRequestBody("application/gzip".toMediaType()))
771
+ .build()
772
+
773
+ val response = client.newCall(request).execute()
774
+
775
+ response.use {
776
+ if (it.isSuccessful) {
777
+ Logger.debug("[UploadManager] S3 upload SUCCESS (code=${it.code})")
778
+ true
779
+ } else {
780
+ val errorBody = it.body?.string() ?: ""
781
+ Logger.error("[UploadManager] S3 upload FAILED: ${it.code} - $errorBody")
782
+ false
783
+ }
784
+ }
785
+ }
786
+ } catch (e: Exception) {
787
+ Logger.error("[UploadManager] S3 upload EXCEPTION", e)
788
+ false
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Notify backend that batch upload is complete.
794
+ */
795
+ private suspend fun completeBatch(
796
+ batchId: String,
797
+ actualSizeBytes: Int,
798
+ eventCount: Int,
799
+ frameCount: Int
800
+ ): Boolean {
801
+ Logger.debug("[UploadManager] completeBatch START (batchId=$batchId, events=$eventCount, frames=$frameCount)")
802
+
803
+ val body = JSONObject().apply {
804
+ put("batchId", batchId)
805
+ put("actualSizeBytes", actualSizeBytes)
806
+ put("eventCount", eventCount)
807
+ put("frameCount", frameCount)
808
+ put("timestamp", System.currentTimeMillis())
809
+ put("userId", userId.ifEmpty { "anonymous" })
810
+ }
811
+
812
+ return try {
813
+ withContext(Dispatchers.IO) {
814
+ val request = buildAuthenticatedRequest("/api/ingest/batch/complete", body.toString())
815
+ val response = client.newCall(request).execute()
816
+ response.use {
817
+ if (it.isSuccessful) {
818
+ Logger.debug("[UploadManager] completeBatch SUCCESS")
819
+ true
820
+ } else {
821
+ val errorBody = it.body?.string() ?: ""
822
+ Logger.error("[UploadManager] completeBatch FAILED: ${it.code} - $errorBody")
823
+ false
824
+ }
825
+ }
826
+ }
827
+ } catch (e: Exception) {
828
+ Logger.error("[UploadManager] completeBatch EXCEPTION", e)
829
+ false
830
+ }
831
+ }
832
+
833
+ /**
834
+ * End session - send final metrics to backend.
835
+ * @param endedAtOverride If provided, use this timestamp as endedAt instead of current time.
836
+ * Used for crash recovery where we want to use the saved timestamp.
837
+ *
838
+ * NOTE: Mutex removed as part of the nuclear rewrite. Recovery is now handled
839
+ * by WorkManager independently, so there's no mutex contention.
840
+ */
841
+ suspend fun endSession(
842
+ metrics: Map<String, Any?>? = null,
843
+ endedAtOverride: Long? = null,
844
+ sessionIdOverride: String? = null
845
+ ): Boolean {
846
+ Logger.debug("[UploadManager] ===== END SESSION START =====")
847
+
848
+ // Use explicit sessionId if provided, otherwise fall back to current sessionId
849
+ val effectiveSessionId = sessionIdOverride ?: sessionId
850
+ Logger.debug("[UploadManager] endSession: sessionId=$effectiveSessionId, totalBackgroundTimeMs=$totalBackgroundTimeMs")
851
+
852
+ if (effectiveSessionId.isNullOrEmpty()) {
853
+ Logger.error("[UploadManager] endSession: ❌ Called with empty sessionId")
854
+ return true
855
+ }
856
+
857
+ Logger.debug("[UploadManager] endSession: Sending session end for: $effectiveSessionId")
858
+
859
+ // Use provided endedAt if available (for crash recovery), otherwise current time
860
+ val endedAt = endedAtOverride ?: System.currentTimeMillis()
861
+ Logger.debug("[UploadManager] endSession: endedAt=$endedAt (override=${endedAtOverride != null})")
862
+
863
+ val body = JSONObject().apply {
864
+ put("sessionId", effectiveSessionId)
865
+ put("endedAt", endedAt)
866
+ if (totalBackgroundTimeMs > 0) {
867
+ put("totalBackgroundTimeMs", totalBackgroundTimeMs)
868
+ Logger.debug("[UploadManager] endSession: Including totalBackgroundTimeMs=$totalBackgroundTimeMs")
869
+ }
870
+ metrics?.let { m ->
871
+ Logger.debug("[UploadManager] endSession: Including metrics: ${m.keys.joinToString(", ")}")
872
+ put("metrics", JSONObject().apply {
873
+ m.forEach { (key, value) ->
874
+ if (value != null) put(key, value)
875
+ }
876
+ })
877
+ }
878
+ }
879
+
880
+ Logger.debug("[UploadManager] endSession: Request body: ${body.toString().take(200)}...")
881
+
882
+ return try {
883
+ withContext(Dispatchers.IO) {
884
+ Logger.debug("[UploadManager] endSession: Building authenticated request...")
885
+ val request = buildAuthenticatedRequest("/api/ingest/session/end", body.toString())
886
+
887
+ Logger.debug("[UploadManager] endSession: Executing request to /api/ingest/session/end...")
888
+ val requestStartTime = System.currentTimeMillis()
889
+ val response = client.newCall(request).execute()
890
+ val requestDuration = System.currentTimeMillis() - requestStartTime
891
+
892
+ response.use {
893
+ if (it.isSuccessful) {
894
+ Logger.debug("[UploadManager] ✅ Session end signal sent successfully in ${requestDuration}ms for $effectiveSessionId")
895
+ true
896
+ } else {
897
+ val errorBody = it.body?.string() ?: "no body"
898
+ Logger.error("[UploadManager] ❌ Session end signal FAILED: code=${it.code}, body=$errorBody")
899
+ Logger.warning("Session end failed: ${it.code} - $errorBody")
900
+ false
901
+ }
902
+ }
903
+ }
904
+ } catch (e: Exception) {
905
+ Logger.error("[UploadManager] endSession: ❌ Exception for $effectiveSessionId: ${e.message}", e)
906
+ false
907
+ } finally {
908
+ Logger.debug("[UploadManager] ===== END SESSION END =====")
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Upload a crash report using presigned URL flow.
914
+ */
915
+ suspend fun uploadCrashReport(crashReport: Map<String, Any?>): Boolean {
916
+ if (!canUpload()) return false
917
+
918
+ val crashSessionId = crashReport["sessionId"] as? String ?: sessionId ?: ""
919
+
920
+ val payload = JSONObject().apply {
921
+ put("crashes", JSONArray().apply {
922
+ put(JSONObject().apply {
923
+ crashReport.forEach { (key, value) ->
924
+ if (value != null) put(key, value)
925
+ }
926
+ })
927
+ })
928
+ put("sessionId", crashSessionId)
929
+ put("timestamp", crashReport["timestamp"] ?: System.currentTimeMillis())
930
+ }
931
+
932
+ val content = payload.toString().toByteArray(Charsets.UTF_8)
933
+ val gzipped = gzipData(content)
934
+ if (gzipped == null) {
935
+ Logger.error("Failed to gzip crash report")
936
+ return false
937
+ }
938
+
939
+ // Temporarily use crash session ID for presign
940
+ val originalSessionId = sessionId
941
+ sessionId = crashSessionId
942
+
943
+ try {
944
+ val presignResult = presignForContentType("crashes", 0, gzipped.size, false)
945
+ if (presignResult == null) {
946
+ Logger.error("Failed to get presigned URL for crash report")
947
+ return false
948
+ }
949
+
950
+ val presignedUrl = presignResult.optString("presignedUrl")
951
+ val batchId = presignResult.optString("batchId")
952
+
953
+ if (presignedUrl.isEmpty()) {
954
+ Logger.error("Invalid crash presign response")
955
+ return false
956
+ }
957
+
958
+ val uploadSuccess = uploadToS3(presignedUrl, gzipped)
959
+ if (!uploadSuccess) {
960
+ Logger.error("Failed to upload crash to S3")
961
+ return false
962
+ }
963
+
964
+ completeBatch(batchId, gzipped.size, 0, 0)
965
+ Logger.debug("Crash report uploaded")
966
+ return true
967
+ } finally {
968
+ // Restore original session ID
969
+ sessionId = originalSessionId
970
+ }
971
+ }
972
+
973
+ /**
974
+ * Upload an ANR report using presigned URL flow.
975
+ */
976
+ suspend fun uploadANRReport(anrReport: Map<String, Any?>): Boolean {
977
+ if (!canUpload()) return false
978
+
979
+ val anrSessionId = anrReport["sessionId"] as? String ?: sessionId ?: ""
980
+
981
+ val payload = JSONObject().apply {
982
+ put("anrs", JSONArray().apply {
983
+ put(JSONObject().apply {
984
+ anrReport.forEach { (key, value) ->
985
+ if (value != null) put(key, value)
986
+ }
987
+ })
988
+ })
989
+ put("sessionId", anrSessionId)
990
+ put("timestamp", anrReport["timestamp"] ?: System.currentTimeMillis())
991
+ }
992
+
993
+ val content = payload.toString().toByteArray(Charsets.UTF_8)
994
+ val gzipped = gzipData(content)
995
+ if (gzipped == null) {
996
+ Logger.error("Failed to gzip ANR report")
997
+ return false
998
+ }
999
+
1000
+ // Temporarily use ANR session ID for presign
1001
+ val originalSessionId = sessionId
1002
+ sessionId = anrSessionId
1003
+
1004
+ try {
1005
+ val presignResult = presignForContentType("anrs", 0, gzipped.size, false)
1006
+ if (presignResult == null) {
1007
+ Logger.error("Failed to get presigned URL for ANR report")
1008
+ return false
1009
+ }
1010
+
1011
+ val presignedUrl = presignResult.optString("presignedUrl")
1012
+ val batchId = presignResult.optString("batchId")
1013
+
1014
+ if (presignedUrl.isEmpty()) {
1015
+ Logger.error("Invalid ANR presign response")
1016
+ return false
1017
+ }
1018
+
1019
+ val uploadSuccess = uploadToS3(presignedUrl, gzipped)
1020
+ if (!uploadSuccess) {
1021
+ Logger.error("Failed to upload ANR to S3")
1022
+ return false
1023
+ }
1024
+
1025
+ completeBatch(batchId, gzipped.size, 0, 0)
1026
+ Logger.debug("ANR report uploaded")
1027
+ return true
1028
+ } finally {
1029
+ // Restore original session ID
1030
+ sessionId = originalSessionId
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * Fetch project configuration from server.
1036
+ */
1037
+ fun fetchProjectConfig(callback: (success: Boolean, config: Map<String, Any?>?) -> Unit) {
1038
+ if (publicKey.isEmpty()) {
1039
+ callback(false, null)
1040
+ return
1041
+ }
1042
+
1043
+ val request = Request.Builder()
1044
+ .url("$apiUrl/api/sdk/config")
1045
+ .get()
1046
+ .addHeader("x-public-key", publicKey)
1047
+ .addHeader("x-bundle-id", context.packageName)
1048
+ .addHeader("x-platform", "android")
1049
+ .build()
1050
+
1051
+ client.newCall(request).enqueue(object : Callback {
1052
+ override fun onFailure(call: Call, e: IOException) {
1053
+ Logger.error("Project config fetch failed", e)
1054
+ callback(false, null)
1055
+ }
1056
+
1057
+ override fun onResponse(call: Call, response: Response) {
1058
+ response.use {
1059
+ if (it.isSuccessful) {
1060
+ try {
1061
+ val json = JSONObject(it.body?.string() ?: "{}")
1062
+ projectId = json.optString("projectId")
1063
+
1064
+ val config = mutableMapOf<String, Any?>()
1065
+ json.keys().forEach { key ->
1066
+ config[key] = json.get(key)
1067
+ }
1068
+ callback(true, config)
1069
+ } catch (e: Exception) {
1070
+ callback(false, null)
1071
+ }
1072
+ } else {
1073
+ callback(false, null)
1074
+ }
1075
+ }
1076
+ }
1077
+ })
1078
+ }
1079
+
1080
+ private fun buildEventsPayload(
1081
+ events: List<Map<String, Any?>>,
1082
+ batchNumber: Int,
1083
+ isFinal: Boolean
1084
+ ): ByteArray {
1085
+ Logger.debug("[UploadManager] buildEventsPayload: START (eventCount=${events.size}, batchNumber=$batchNumber, isFinal=$isFinal, sessionId=$sessionId)")
1086
+
1087
+ val currentTime = System.currentTimeMillis()
1088
+
1089
+ // CRITICAL FIX: Use activeSessionId for current session operations
1090
+ // sessionId can be temporarily modified by recoverPendingSessions()
1091
+ val effectiveSessionId = activeSessionId ?: sessionId ?: ""
1092
+
1093
+ val payload = JSONObject().apply {
1094
+ put("sessionId", effectiveSessionId)
1095
+ put("userId", userId.ifEmpty { "anonymous" })
1096
+ put("batchNumber", batchNumber)
1097
+ put("isFinal", isFinal)
1098
+ put("sessionStartTime", sessionStartTime)
1099
+ put("batchTime", currentTime)
1100
+ put("deviceInfo", buildDeviceInfo())
1101
+ put("events", JSONArray().apply {
1102
+ events.forEach { event ->
1103
+ put(JSONObject().apply {
1104
+ event.forEach { (key, value) ->
1105
+ put(key, toJsonValue(value))
1106
+ }
1107
+ })
1108
+ }
1109
+ })
1110
+
1111
+ if (isFinal) {
1112
+ put("endTime", currentTime)
1113
+ val duration = currentTime - sessionStartTime
1114
+ put("duration", maxOf(0, duration))
1115
+ Logger.debug("[UploadManager] buildEventsPayload: Final batch - endTime=$currentTime, duration=${duration}ms")
1116
+ }
1117
+ }
1118
+
1119
+ val payloadBytes = payload.toString().toByteArray(Charsets.UTF_8)
1120
+ Logger.debug("[UploadManager] buildEventsPayload: Payload built - size=${payloadBytes.size} bytes, sessionId=${sessionId ?: "null"}, userId=${userId.ifEmpty { "anonymous" }}, eventCount=${events.size}")
1121
+
1122
+ // Log event types in payload
1123
+ if (events.isNotEmpty()) {
1124
+ val eventTypes = events.mapNotNull { it["type"]?.toString() }.groupingBy { it }.eachCount()
1125
+ Logger.debug("[UploadManager] buildEventsPayload: Event types in payload: ${eventTypes.entries.joinToString(", ") { "${it.key}=${it.value}" }}")
1126
+ }
1127
+
1128
+ return payloadBytes
1129
+ }
1130
+
1131
+ /**
1132
+ * Recursively convert Kotlin types to JSON-compatible values.
1133
+ * Handles nested Maps (-> JSONObject) and Lists (-> JSONArray) properly.
1134
+ */
1135
+ private fun toJsonValue(value: Any?): Any? {
1136
+ return when (value) {
1137
+ null -> JSONObject.NULL
1138
+ is Map<*, *> -> {
1139
+ JSONObject().apply {
1140
+ @Suppress("UNCHECKED_CAST")
1141
+ (value as Map<String, Any?>).forEach { (k, v) ->
1142
+ put(k, toJsonValue(v))
1143
+ }
1144
+ }
1145
+ }
1146
+ is List<*> -> {
1147
+ JSONArray().apply {
1148
+ value.forEach { item -> put(toJsonValue(item)) }
1149
+ }
1150
+ }
1151
+ is Number, is String, is Boolean -> value
1152
+ else -> value.toString() // Fallback for unknown types
1153
+ }
1154
+ }
1155
+
1156
+ private fun buildDeviceInfo(): JSONObject {
1157
+ return JSONObject().apply {
1158
+ put("model", Build.MODEL)
1159
+ put("manufacturer", Build.MANUFACTURER)
1160
+ put("systemName", "Android")
1161
+ put("systemVersion", Build.VERSION.RELEASE)
1162
+ put("sdkInt", Build.VERSION.SDK_INT)
1163
+ put("platform", "android")
1164
+ put("deviceHash", deviceHash)
1165
+ put("name", Build.DEVICE)
1166
+
1167
+ // Screen info
1168
+ // IMPORTANT: Use density-independent pixels (dp) to match touch coordinate format.
1169
+ // Touch coordinates from TouchInterceptor are normalized to dp (divided by density).
1170
+ // Using dp ensures consistent coordinate system across:
1171
+ // - iOS (uses points natively)
1172
+ // - Android (raw pixels divided by density = dp)
1173
+ val displayMetrics = context.resources.displayMetrics
1174
+ val density = if (displayMetrics.density > 0f) displayMetrics.density else 1f
1175
+ var widthPx = displayMetrics.widthPixels
1176
+ var heightPx = displayMetrics.heightPixels
1177
+ val activity = WindowUtils.getCurrentActivity(context)
1178
+ val decorView = activity?.window?.decorView
1179
+ if (decorView != null && decorView.width > 0 && decorView.height > 0) {
1180
+ widthPx = decorView.width
1181
+ heightPx = decorView.height
1182
+ }
1183
+ put("screenWidth", (widthPx / density).toInt())
1184
+ put("screenHeight", (heightPx / density).toInt())
1185
+ put("screenScale", density)
1186
+
1187
+ // App info
1188
+ try {
1189
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
1190
+ put("appVersion", packageInfo.versionName)
1191
+ put("appId", context.packageName)
1192
+ } catch (e: Exception) {
1193
+ // Ignore
1194
+ }
1195
+
1196
+ // Network info
1197
+ val networkQuality = NetworkMonitor.getInstance(context).captureNetworkQuality()
1198
+ val networkMap = networkQuality.toMap()
1199
+ put("networkType", networkMap["networkType"] ?: "none")
1200
+ val cellularGeneration = networkMap["cellularGeneration"] as? String
1201
+ if (!cellularGeneration.isNullOrEmpty() && cellularGeneration != "unknown") {
1202
+ put("cellularGeneration", cellularGeneration)
1203
+ }
1204
+ put("isConstrained", networkMap["isConstrained"] ?: false)
1205
+ put("isExpensive", networkMap["isExpensive"] ?: false)
1206
+ }
1207
+ }
1208
+
1209
+ private fun buildAuthenticatedRequest(path: String, body: String): Request {
1210
+ val deviceAuthManager = DeviceAuthManager.getInstance(context)
1211
+ val token = deviceAuthManager.getCurrentUploadToken()
1212
+
1213
+ // Log token status for debugging
1214
+ Logger.debug("[UploadManager] buildAuthenticatedRequest: path=$path, token=${if (token != null) "present" else "NULL"}")
1215
+
1216
+ if (token == null) {
1217
+ Logger.warning("[UploadManager] No valid upload token available - triggering background refresh")
1218
+ scope.launch {
1219
+ try {
1220
+ deviceAuthManager.getUploadTokenWithAutoRegister { _, _, _, _ -> }
1221
+ } catch (e: Exception) {
1222
+ Logger.warning("[UploadManager] Background token refresh failed: ${e.message}")
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ return Request.Builder()
1228
+ .url("$apiUrl$path")
1229
+ .post(body.toRequestBody("application/json".toMediaType()))
1230
+ .apply {
1231
+ // Match iOS header names exactly
1232
+ if (publicKey.isNotEmpty()) {
1233
+ addHeader("X-Rejourney-Key", publicKey)
1234
+ }
1235
+ addHeader("X-Bundle-ID", context.packageName)
1236
+ addHeader("X-Rejourney-Platform", "android")
1237
+ if (deviceHash.isNotEmpty()) {
1238
+ addHeader("X-Rejourney-Device-Hash", deviceHash)
1239
+ }
1240
+ token?.let { addHeader("x-upload-token", it) }
1241
+ }
1242
+ .build()
1243
+ }
1244
+
1245
+ /**
1246
+ * Gzip compress data.
1247
+ */
1248
+ private fun gzipData(input: ByteArray): ByteArray? {
1249
+ return try {
1250
+ val bos = ByteArrayOutputStream()
1251
+ GZIPOutputStream(bos).use { gzip ->
1252
+ gzip.write(input)
1253
+ }
1254
+ bos.toByteArray()
1255
+ } catch (e: Exception) {
1256
+ Logger.error("Gzip compression failed", e)
1257
+ null
1258
+ }
1259
+ }
1260
+
1261
+ private fun canUpload(): Boolean {
1262
+ // Check circuit breaker
1263
+ if (circuitOpen) {
1264
+ if (System.currentTimeMillis() - circuitOpenTime > circuitResetTimeMs) {
1265
+ // Reset circuit breaker
1266
+ circuitOpen = false
1267
+ consecutiveFailures.set(0)
1268
+ Logger.debug("Circuit breaker reset")
1269
+ } else {
1270
+ return false
1271
+ }
1272
+ }
1273
+
1274
+ // Check network
1275
+ val networkMonitor = NetworkMonitor.getInstance(context)
1276
+ return networkMonitor.isConnected
1277
+ }
1278
+
1279
+ private fun onUploadSuccess(durationMs: Long) {
1280
+ consecutiveFailures.set(0)
1281
+ Telemetry.getInstance().recordUploadDuration(durationMs, success = true, byteCount = 0)
1282
+ }
1283
+
1284
+ private fun onUploadFailure() {
1285
+ val failures = consecutiveFailures.incrementAndGet()
1286
+ Telemetry.getInstance().recordEvent(TelemetryEventType.UPLOAD_FAILURE)
1287
+
1288
+ if (failures >= circuitBreakerThreshold) {
1289
+ circuitOpen = true
1290
+ circuitOpenTime = System.currentTimeMillis()
1291
+ Telemetry.getInstance().recordEvent(TelemetryEventType.CIRCUIT_BREAKER_OPEN)
1292
+ Logger.warning("Circuit breaker opened after $failures consecutive failures")
1293
+ }
1294
+ }
1295
+
1296
+ // =============================================================================
1297
+ // Replay Promotion
1298
+ // =============================================================================
1299
+
1300
+ /**
1301
+ * Whether this session has been promoted for replay upload.
1302
+ * Set by evaluateReplayPromotion() at session end.
1303
+ */
1304
+ var isReplayPromoted: Boolean = false
1305
+ private set
1306
+
1307
+ /**
1308
+ * Request replay promotion evaluation from backend.
1309
+ * Returns Pair of (promoted: Boolean, reason: String) indicating if frames should be uploaded.
1310
+ *
1311
+ * Call this at session end before uploading frames.
1312
+ * The backend evaluates metrics (crash, ANR, error count, etc.) and applies rate limiting.
1313
+ */
1314
+ suspend fun evaluateReplayPromotion(metrics: Map<String, Any?>): Pair<Boolean, String> {
1315
+ if (!canUpload()) {
1316
+ Logger.debug("Cannot evaluate replay promotion - network unavailable")
1317
+ return Pair(false, "network_unavailable")
1318
+ }
1319
+
1320
+ // CRITICAL FIX: Use activeSessionId for current session operations
1321
+ val effectiveSessionId = activeSessionId ?: sessionId ?: ""
1322
+
1323
+ val body = JSONObject().apply {
1324
+ put("sessionId", effectiveSessionId)
1325
+ put("metrics", JSONObject().apply {
1326
+ metrics.forEach { (key, value) ->
1327
+ if (value != null) put(key, value)
1328
+ }
1329
+ })
1330
+ }
1331
+
1332
+ return try {
1333
+ withContext(Dispatchers.IO) {
1334
+ val request = buildAuthenticatedRequest("/api/ingest/replay/evaluate", body.toString())
1335
+ val response = client.newCall(request).execute()
1336
+
1337
+ response.use {
1338
+ if (it.isSuccessful) {
1339
+ val result = JSONObject(it.body?.string() ?: "{}")
1340
+ val promoted = result.optBoolean("promoted", false)
1341
+ val reason = result.optString("reason", "unknown")
1342
+
1343
+ isReplayPromoted = promoted
1344
+
1345
+ if (promoted) {
1346
+ Logger.debug("Session promoted for replay upload (reason: $reason)")
1347
+ } else {
1348
+ Logger.debug("Session not promoted for replay (reason: $reason)")
1349
+ }
1350
+
1351
+ Pair(promoted, reason)
1352
+ } else {
1353
+ Logger.warning("Replay promotion evaluation failed: ${it.code}")
1354
+ Pair(false, "api_error")
1355
+ }
1356
+ }
1357
+ }
1358
+ } catch (e: Exception) {
1359
+ Logger.warning("Replay promotion evaluation error: ${e.message}")
1360
+ Pair(false, "exception") // Don't upload frames on evaluation failure (fail-safe)
1361
+ }
1362
+ }
1363
+ }