@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.
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
- package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
- package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
- package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
- package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
- package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
- package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
- package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
- package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
- package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
- package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
- package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
- package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
- package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
- package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
- package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
- package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
- package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Capture/RJANRHandler.h +42 -0
- package/ios/Capture/RJANRHandler.m +328 -0
- package/ios/Capture/RJCaptureEngine.h +275 -0
- package/ios/Capture/RJCaptureEngine.m +2062 -0
- package/ios/Capture/RJCaptureHeuristics.h +80 -0
- package/ios/Capture/RJCaptureHeuristics.m +903 -0
- package/ios/Capture/RJCrashHandler.h +46 -0
- package/ios/Capture/RJCrashHandler.m +313 -0
- package/ios/Capture/RJMotionEvent.h +183 -0
- package/ios/Capture/RJMotionEvent.m +183 -0
- package/ios/Capture/RJPerformanceManager.h +100 -0
- package/ios/Capture/RJPerformanceManager.m +373 -0
- package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
- package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
- package/ios/Capture/RJSegmentUploader.h +146 -0
- package/ios/Capture/RJSegmentUploader.m +778 -0
- package/ios/Capture/RJVideoEncoder.h +247 -0
- package/ios/Capture/RJVideoEncoder.m +1036 -0
- package/ios/Capture/RJViewControllerTracker.h +73 -0
- package/ios/Capture/RJViewControllerTracker.m +508 -0
- package/ios/Capture/RJViewHierarchyScanner.h +215 -0
- package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
- package/ios/Capture/RJViewSerializer.h +119 -0
- package/ios/Capture/RJViewSerializer.m +498 -0
- package/ios/Core/RJConstants.h +124 -0
- package/ios/Core/RJConstants.m +88 -0
- package/ios/Core/RJLifecycleManager.h +85 -0
- package/ios/Core/RJLifecycleManager.m +308 -0
- package/ios/Core/RJLogger.h +61 -0
- package/ios/Core/RJLogger.m +211 -0
- package/ios/Core/RJTypes.h +176 -0
- package/ios/Core/RJTypes.m +66 -0
- package/ios/Core/Rejourney.h +64 -0
- package/ios/Core/Rejourney.mm +2495 -0
- package/ios/Network/RJDeviceAuthManager.h +94 -0
- package/ios/Network/RJDeviceAuthManager.m +967 -0
- package/ios/Network/RJNetworkMonitor.h +68 -0
- package/ios/Network/RJNetworkMonitor.m +267 -0
- package/ios/Network/RJRetryManager.h +73 -0
- package/ios/Network/RJRetryManager.m +325 -0
- package/ios/Network/RJUploadManager.h +267 -0
- package/ios/Network/RJUploadManager.m +2296 -0
- package/ios/Privacy/RJPrivacyMask.h +163 -0
- package/ios/Privacy/RJPrivacyMask.m +922 -0
- package/ios/Rejourney.h +63 -0
- package/ios/Touch/RJGestureClassifier.h +130 -0
- package/ios/Touch/RJGestureClassifier.m +333 -0
- package/ios/Touch/RJTouchInterceptor.h +169 -0
- package/ios/Touch/RJTouchInterceptor.m +772 -0
- package/ios/Utils/RJEventBuffer.h +112 -0
- package/ios/Utils/RJEventBuffer.m +358 -0
- package/ios/Utils/RJGzipUtils.h +33 -0
- package/ios/Utils/RJGzipUtils.m +89 -0
- package/ios/Utils/RJKeychainManager.h +48 -0
- package/ios/Utils/RJKeychainManager.m +111 -0
- package/ios/Utils/RJPerfTiming.h +209 -0
- package/ios/Utils/RJPerfTiming.m +264 -0
- package/ios/Utils/RJTelemetry.h +92 -0
- package/ios/Utils/RJTelemetry.m +320 -0
- package/ios/Utils/RJWindowUtils.h +66 -0
- package/ios/Utils/RJWindowUtils.m +133 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +79 -0
- package/lib/commonjs/index.js +1381 -0
- package/lib/commonjs/sdk/autoTracking.js +1259 -0
- package/lib/commonjs/sdk/constants.js +151 -0
- package/lib/commonjs/sdk/errorTracking.js +199 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +204 -0
- package/lib/commonjs/sdk/navigation.js +151 -0
- package/lib/commonjs/sdk/networkInterceptor.js +412 -0
- package/lib/commonjs/sdk/utils.js +363 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +72 -0
- package/lib/module/index.js +1284 -0
- package/lib/module/sdk/autoTracking.js +1233 -0
- package/lib/module/sdk/constants.js +145 -0
- package/lib/module/sdk/errorTracking.js +189 -0
- package/lib/module/sdk/index.js +12 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +143 -0
- package/lib/module/sdk/networkInterceptor.js +401 -0
- package/lib/module/sdk/utils.js +342 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +147 -0
- package/lib/typescript/components/Mask.d.ts +39 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +204 -0
- package/lib/typescript/sdk/constants.d.ts +120 -0
- package/lib/typescript/sdk/errorTracking.d.ts +32 -0
- package/lib/typescript/sdk/index.d.ts +9 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
- package/lib/typescript/sdk/navigation.d.ts +33 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
- package/lib/typescript/sdk/utils.d.ts +148 -0
- package/lib/typescript/types/index.d.ts +624 -0
- package/package.json +102 -0
- package/rejourney.podspec +21 -0
- package/src/NativeRejourney.ts +165 -0
- package/src/components/Mask.tsx +80 -0
- package/src/index.ts +1459 -0
- package/src/sdk/autoTracking.ts +1373 -0
- package/src/sdk/constants.ts +134 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +11 -0
- package/src/sdk/metricsTracking.ts +232 -0
- package/src/sdk/navigation.ts +157 -0
- package/src/sdk/networkInterceptor.ts +440 -0
- package/src/sdk/utils.ts +369 -0
- package/src/types/expo-router.d.ts +7 -0
- 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
|
+
}
|