@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,512 @@
1
+ /**
2
+ * Uploads finished video segments to S3/R2 storage.
3
+ * Ported from iOS RJSegmentUploader.
4
+ *
5
+ * Uses the presigned URL flow:
6
+ * 1. Request presigned URL from backend
7
+ * 2. Upload directly to S3/R2
8
+ * 3. Notify backend of completion
9
+ *
10
+ * ## Features
11
+ * - Background upload support
12
+ * - Retry with exponential backoff
13
+ * - Queue management for multiple segments
14
+ * - Automatic cleanup of uploaded files
15
+ *
16
+ * Licensed under the Apache License, Version 2.0
17
+ * Copyright (c) 2026 Rejourney
18
+ */
19
+ package com.rejourney.capture
20
+
21
+ import com.rejourney.core.Logger
22
+ import com.rejourney.network.HttpClientProvider
23
+ import kotlinx.coroutines.*
24
+ import okhttp3.*
25
+ import okhttp3.MediaType.Companion.toMediaType
26
+ import okhttp3.RequestBody.Companion.asRequestBody
27
+ import okhttp3.RequestBody.Companion.toRequestBody
28
+ import org.json.JSONObject
29
+ import java.io.ByteArrayOutputStream
30
+ import java.io.File
31
+ import java.io.IOException
32
+ import java.util.concurrent.TimeUnit
33
+ import java.util.concurrent.atomic.AtomicInteger
34
+ import java.util.zip.GZIPOutputStream
35
+
36
+ /**
37
+ * Result type for segment upload operations.
38
+ */
39
+ data class SegmentUploadResult(
40
+ val success: Boolean,
41
+ val error: String? = null
42
+ )
43
+
44
+ /**
45
+ * Uploads video segments and hierarchy snapshots to cloud storage.
46
+ */
47
+ class SegmentUploader(
48
+ /** Base URL for the Rejourney API */
49
+ var baseURL: String
50
+ ) {
51
+
52
+ /** API key (public key rj_...) for authentication */
53
+ var apiKey: String? = null
54
+
55
+ /** Project ID for the current recording session */
56
+ var projectId: String? = null
57
+
58
+ /** Upload token from device auth for authenticated uploads */
59
+ var uploadToken: String? = null
60
+
61
+ /** Maximum number of retry attempts. Default: 3 */
62
+ var maxRetries: Int = 3
63
+
64
+ /** Whether to delete local files after successful upload. Default: true */
65
+ var deleteAfterUpload: Boolean = true
66
+
67
+ /** Number of uploads currently in progress */
68
+ val pendingUploads: Int
69
+ get() = pendingUploadCount.get()
70
+
71
+ private val pendingUploadCount = AtomicInteger(0)
72
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
73
+
74
+ // Use shared client with longer timeouts for video uploads (SSL pinning removed)
75
+ private val client = HttpClientProvider.shared.newBuilder()
76
+ .connectTimeout(60, TimeUnit.SECONDS)
77
+ .readTimeout(120, TimeUnit.SECONDS)
78
+ .writeTimeout(120, TimeUnit.SECONDS)
79
+ .build()
80
+
81
+ /**
82
+ * Uploads a video segment to cloud storage.
83
+ *
84
+ * @param segmentFile Local file path of the .mp4 segment
85
+ * @param sessionId Session identifier
86
+ * @param startTime Segment start time in epoch milliseconds
87
+ * @param endTime Segment end time in epoch milliseconds
88
+ * @param frameCount Number of frames in the segment
89
+ * @return SegmentUploadResult indicating success or failure
90
+ */
91
+ suspend fun uploadVideoSegment(
92
+ segmentFile: File,
93
+ sessionId: String,
94
+ startTime: Long,
95
+ endTime: Long,
96
+ frameCount: Int
97
+ ): SegmentUploadResult = withContext(Dispatchers.IO) {
98
+ Logger.debug("[SegmentUploader] uploadVideoSegment: ${segmentFile.name}, sessionId=$sessionId, frames=$frameCount")
99
+ Logger.debug("[SegmentUploader] apiKey=${if (apiKey != null) "<set>" else "<nil>"}, projectId=$projectId")
100
+
101
+ val key = apiKey
102
+ val pid = projectId
103
+
104
+ if (key == null || pid == null) {
105
+ Logger.error("[SegmentUploader] Missing apiKey or projectId!")
106
+ return@withContext SegmentUploadResult(false, "Missing configuration")
107
+ }
108
+
109
+ if (!segmentFile.exists()) {
110
+ Logger.error("[SegmentUploader] File not found: ${segmentFile.absolutePath}")
111
+ return@withContext SegmentUploadResult(false, "File not found")
112
+ }
113
+
114
+ pendingUploadCount.incrementAndGet()
115
+
116
+ try {
117
+ // Step 1: Request presigned URL from backend
118
+ Logger.debug("[SegmentUploader] Requesting presigned URL for segment")
119
+ val presignResult = requestPresignedURL(
120
+ sessionId = sessionId,
121
+ kind = "video",
122
+ sizeBytes = segmentFile.length(),
123
+ startTime = startTime,
124
+ endTime = endTime,
125
+ frameCount = frameCount
126
+ )
127
+
128
+ if (!presignResult.success || presignResult.presignedUrl == null) {
129
+ Logger.error("[SegmentUploader] Failed to get presigned URL: ${presignResult.error}")
130
+ return@withContext SegmentUploadResult(false, presignResult.error ?: "Failed to get presigned URL")
131
+ }
132
+
133
+ val presignedUrl = presignResult.presignedUrl
134
+ val segmentId = presignResult.segmentId
135
+ val s3Key = presignResult.s3Key
136
+
137
+ Logger.debug("[SegmentUploader] Got presigned URL, segmentId=$segmentId")
138
+
139
+ // Step 2: Upload directly to S3
140
+ Logger.debug("[SegmentUploader] Uploading to S3...")
141
+ val uploadResult = uploadFileToS3(segmentFile, presignedUrl, "video/mp4")
142
+
143
+ if (!uploadResult.success) {
144
+ Logger.error("[SegmentUploader] S3 upload failed: ${uploadResult.error}")
145
+ return@withContext SegmentUploadResult(false, uploadResult.error ?: "S3 upload failed")
146
+ }
147
+
148
+ Logger.debug("[SegmentUploader] S3 upload SUCCESS, calling segment/complete")
149
+
150
+ // Step 3: Notify backend of completion
151
+ val completeResult = notifySegmentComplete(
152
+ segmentId = segmentId!!,
153
+ sessionId = sessionId,
154
+ startTime = startTime,
155
+ endTime = endTime,
156
+ frameCount = frameCount
157
+ )
158
+
159
+ if (completeResult.success) {
160
+ Logger.info("[SegmentUploader] Upload complete for ${segmentFile.name}")
161
+ if (deleteAfterUpload) {
162
+ segmentFile.delete()
163
+ }
164
+ SegmentUploadResult(true)
165
+ } else {
166
+ Logger.warning("[SegmentUploader] Completion notification failed: ${completeResult.error}")
167
+ SegmentUploadResult(false, completeResult.error)
168
+ }
169
+
170
+ } catch (e: Exception) {
171
+ Logger.error("[SegmentUploader] Upload failed with exception", e)
172
+ SegmentUploadResult(false, e.message ?: "Unknown error")
173
+ } finally {
174
+ pendingUploadCount.decrementAndGet()
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Uploads a view hierarchy snapshot to cloud storage.
180
+ *
181
+ * @param hierarchyData JSON data of the hierarchy snapshot
182
+ * @param sessionId Session identifier
183
+ * @param timestamp Snapshot timestamp in epoch milliseconds
184
+ * @return SegmentUploadResult indicating success or failure
185
+ */
186
+ suspend fun uploadHierarchy(
187
+ hierarchyData: ByteArray,
188
+ sessionId: String,
189
+ timestamp: Long
190
+ ): SegmentUploadResult = withContext(Dispatchers.IO) {
191
+ val key = apiKey
192
+ val pid = projectId
193
+
194
+ if (key == null || pid == null) {
195
+ return@withContext SegmentUploadResult(false, "Missing configuration")
196
+ }
197
+
198
+ pendingUploadCount.incrementAndGet()
199
+
200
+ try {
201
+ // Compress data
202
+ val compressedData = gzipData(hierarchyData)
203
+
204
+ // Request presigned URL for hierarchy upload with gzip compression
205
+ val presignResult = requestPresignedURL(
206
+ sessionId = sessionId,
207
+ kind = "hierarchy",
208
+ sizeBytes = compressedData.size.toLong(),
209
+ startTime = timestamp,
210
+ endTime = timestamp,
211
+ frameCount = 0,
212
+ compression = "gzip"
213
+ )
214
+
215
+ if (!presignResult.success || presignResult.presignedUrl == null) {
216
+ Logger.error("[SegmentUploader] Failed to get presigned URL for hierarchy: ${presignResult.error}")
217
+ return@withContext SegmentUploadResult(false, presignResult.error ?: "Failed to get presigned URL")
218
+ }
219
+
220
+ val presignedUrl = presignResult.presignedUrl
221
+ val segmentId = presignResult.segmentId
222
+
223
+ // Upload compressed data to S3
224
+ val uploadResult = uploadDataToS3(compressedData, presignedUrl, "application/gzip")
225
+
226
+ if (!uploadResult.success) {
227
+ Logger.error("[SegmentUploader] S3 upload failed for hierarchy: ${uploadResult.error}")
228
+ return@withContext SegmentUploadResult(false, uploadResult.error)
229
+ }
230
+
231
+ // Notify backend of completion
232
+ val completeResult = notifySegmentComplete(
233
+ segmentId = segmentId!!,
234
+ sessionId = sessionId,
235
+ startTime = timestamp,
236
+ endTime = timestamp,
237
+ frameCount = 0
238
+ )
239
+
240
+ if (completeResult.success) {
241
+ Logger.debug("[SegmentUploader] Hierarchy uploaded for $sessionId at $timestamp")
242
+ }
243
+
244
+ SegmentUploadResult(completeResult.success, completeResult.error)
245
+
246
+ } catch (e: Exception) {
247
+ Logger.error("[SegmentUploader] Hierarchy upload failed", e)
248
+ SegmentUploadResult(false, e.message)
249
+ } finally {
250
+ pendingUploadCount.decrementAndGet()
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Cancels all pending uploads.
256
+ */
257
+ fun cancelAllUploads() {
258
+ scope.coroutineContext.cancelChildren()
259
+ pendingUploadCount.set(0)
260
+ }
261
+
262
+ /**
263
+ * Cleans up any leftover segment files from previous sessions.
264
+ */
265
+ fun cleanupOrphanedSegments(segmentDir: File) {
266
+ try {
267
+ val cutoffTime = System.currentTimeMillis() - (24 * 60 * 60 * 1000) // 24 hours ago
268
+ segmentDir.listFiles()?.forEach { file ->
269
+ if (file.isFile && file.lastModified() < cutoffTime) {
270
+ file.delete()
271
+ Logger.debug("[SegmentUploader] Cleaned up orphaned segment: ${file.name}")
272
+ }
273
+ }
274
+ } catch (e: Exception) {
275
+ Logger.debug("[SegmentUploader] Error cleaning orphaned segments: ${e.message}")
276
+ }
277
+ }
278
+
279
+ // ==================== Private Methods ====================
280
+
281
+ private data class PresignResult(
282
+ val success: Boolean,
283
+ val presignedUrl: String? = null,
284
+ val segmentId: String? = null,
285
+ val s3Key: String? = null,
286
+ val error: String? = null
287
+ )
288
+
289
+ private suspend fun requestPresignedURL(
290
+ sessionId: String,
291
+ kind: String,
292
+ sizeBytes: Long,
293
+ startTime: Long,
294
+ endTime: Long,
295
+ frameCount: Int,
296
+ compression: String? = null
297
+ ): PresignResult = withContext(Dispatchers.IO) {
298
+ val url = "$baseURL/api/ingest/segment/presign"
299
+
300
+ val json = JSONObject().apply {
301
+ put("sessionId", sessionId)
302
+ put("kind", kind)
303
+ put("sizeBytes", sizeBytes)
304
+ put("startTime", startTime)
305
+ put("endTime", endTime)
306
+ put("frameCount", frameCount)
307
+ if (compression != null) {
308
+ put("compression", compression)
309
+ }
310
+ }
311
+
312
+ val mediaType = "application/json; charset=utf-8".toMediaType()
313
+ val body = json.toString().toRequestBody(mediaType)
314
+
315
+ val requestBuilder = Request.Builder()
316
+ .url(url)
317
+ .post(body)
318
+ .header("Content-Type", "application/json")
319
+
320
+ val token = uploadToken
321
+ val key = apiKey
322
+ if (!token.isNullOrEmpty() && !key.isNullOrEmpty()) {
323
+ requestBuilder.header("x-upload-token", token)
324
+ requestBuilder.header("x-rejourney-key", key)
325
+ } else if (!key.isNullOrEmpty()) {
326
+ requestBuilder.header("x-api-key", key)
327
+ }
328
+
329
+ val request = requestBuilder.build()
330
+
331
+ try {
332
+ val response = client.newCall(request).execute()
333
+ val responseBody = response.body?.string()
334
+
335
+ if (response.isSuccessful && responseBody != null) {
336
+ val jsonResponse = JSONObject(responseBody)
337
+ PresignResult(
338
+ success = true,
339
+ presignedUrl = jsonResponse.optString("presignedUrl"),
340
+ segmentId = jsonResponse.optString("segmentId"),
341
+ s3Key = jsonResponse.optString("s3Key")
342
+ )
343
+ } else {
344
+ PresignResult(
345
+ success = false,
346
+ error = "HTTP ${response.code}: ${responseBody ?: "No response body"}"
347
+ )
348
+ }
349
+ } catch (e: Exception) {
350
+ PresignResult(success = false, error = e.message)
351
+ }
352
+ }
353
+
354
+ private suspend fun uploadFileToS3(
355
+ file: File,
356
+ presignedUrl: String,
357
+ contentType: String,
358
+ attempt: Int = 1
359
+ ): SegmentUploadResult = withContext(Dispatchers.IO) {
360
+ try {
361
+ // Read file content directly (no gzip)
362
+ val fileData = file.readBytes()
363
+
364
+ val mediaType = contentType.toMediaType()
365
+ val body = fileData.toRequestBody(mediaType)
366
+
367
+ val request = Request.Builder()
368
+ .url(presignedUrl)
369
+ .put(body)
370
+ .header("Content-Type", contentType)
371
+ // Removed Content-Encoding: gzip
372
+ .build()
373
+
374
+ val response = client.newCall(request).execute()
375
+
376
+ if (response.isSuccessful) {
377
+ SegmentUploadResult(true)
378
+ } else {
379
+ val error = "S3 upload failed: HTTP ${response.code}"
380
+ if (attempt < maxRetries) {
381
+ delay(calculateBackoff(attempt))
382
+ uploadFileToS3(file, presignedUrl, contentType, attempt + 1)
383
+ } else {
384
+ SegmentUploadResult(false, error)
385
+ }
386
+ }
387
+ } catch (e: IOException) {
388
+ if (attempt < maxRetries) {
389
+ delay(calculateBackoff(attempt))
390
+ uploadFileToS3(file, presignedUrl, contentType, attempt + 1)
391
+ } else {
392
+ SegmentUploadResult(false, e.message)
393
+ }
394
+ }
395
+ }
396
+
397
+ private suspend fun uploadDataToS3(
398
+ data: ByteArray,
399
+ presignedUrl: String,
400
+ contentType: String,
401
+ attempt: Int = 1
402
+ ): SegmentUploadResult = withContext(Dispatchers.IO) {
403
+ try {
404
+ // Upload data directly (no gzip)
405
+ val mediaType = contentType.toMediaType()
406
+ val body = data.toRequestBody(mediaType)
407
+
408
+ val request = Request.Builder()
409
+ .url(presignedUrl)
410
+ .put(body)
411
+ .header("Content-Type", contentType)
412
+ // Removed Content-Encoding: gzip
413
+ .build()
414
+
415
+ val response = client.newCall(request).execute()
416
+
417
+ if (response.isSuccessful) {
418
+ SegmentUploadResult(true)
419
+ } else {
420
+ val error = "S3 upload failed: HTTP ${response.code}"
421
+ if (attempt < maxRetries) {
422
+ delay(calculateBackoff(attempt))
423
+ uploadDataToS3(data, presignedUrl, contentType, attempt + 1)
424
+ } else {
425
+ SegmentUploadResult(false, error)
426
+ }
427
+ }
428
+ } catch (e: IOException) {
429
+ if (attempt < maxRetries) {
430
+ delay(calculateBackoff(attempt))
431
+ uploadDataToS3(data, presignedUrl, contentType, attempt + 1)
432
+ } else {
433
+ SegmentUploadResult(false, e.message)
434
+ }
435
+ }
436
+ }
437
+
438
+ private suspend fun notifySegmentComplete(
439
+ segmentId: String,
440
+ sessionId: String,
441
+ startTime: Long,
442
+ endTime: Long,
443
+ frameCount: Int,
444
+ attempt: Int = 1
445
+ ): SegmentUploadResult = withContext(Dispatchers.IO) {
446
+ val url = "$baseURL/api/ingest/segment/complete"
447
+
448
+ val json = JSONObject().apply {
449
+ put("segmentId", segmentId)
450
+ put("sessionId", sessionId)
451
+ put("projectId", projectId)
452
+ put("startTime", startTime)
453
+ put("endTime", endTime)
454
+ put("frameCount", frameCount)
455
+ }
456
+
457
+ val mediaType = "application/json; charset=utf-8".toMediaType()
458
+ val body = json.toString().toRequestBody(mediaType)
459
+
460
+ val requestBuilder = Request.Builder()
461
+ .url(url)
462
+ .post(body)
463
+ .header("Content-Type", "application/json")
464
+
465
+ val token = uploadToken
466
+ val key = apiKey
467
+ if (!token.isNullOrEmpty() && !key.isNullOrEmpty()) {
468
+ requestBuilder.header("x-upload-token", token)
469
+ requestBuilder.header("x-rejourney-key", key)
470
+ } else if (!key.isNullOrEmpty()) {
471
+ requestBuilder.header("x-api-key", key)
472
+ }
473
+
474
+ val request = requestBuilder.build()
475
+
476
+ try {
477
+ val response = client.newCall(request).execute()
478
+
479
+ if (response.isSuccessful) {
480
+ SegmentUploadResult(true)
481
+ } else {
482
+ val error = "Completion notification failed: HTTP ${response.code}"
483
+ if (attempt < maxRetries) {
484
+ delay(calculateBackoff(attempt))
485
+ notifySegmentComplete(segmentId, sessionId, startTime, endTime, frameCount, attempt + 1)
486
+ } else {
487
+ SegmentUploadResult(false, error)
488
+ }
489
+ }
490
+ } catch (e: IOException) {
491
+ if (attempt < maxRetries) {
492
+ delay(calculateBackoff(attempt))
493
+ notifySegmentComplete(segmentId, sessionId, startTime, endTime, frameCount, attempt + 1)
494
+ } else {
495
+ SegmentUploadResult(false, e.message)
496
+ }
497
+ }
498
+ }
499
+
500
+ private fun gzipData(data: ByteArray): ByteArray {
501
+ val bos = ByteArrayOutputStream()
502
+ GZIPOutputStream(bos).use { gzip ->
503
+ gzip.write(data)
504
+ }
505
+ return bos.toByteArray()
506
+ }
507
+
508
+ private fun calculateBackoff(attempt: Int): Long {
509
+ // Exponential backoff: 1s, 2s, 4s, etc.
510
+ return (1000L * (1 shl (attempt - 1))).coerceAtMost(30000L)
511
+ }
512
+ }