@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,773 @@
1
+ /**
2
+ * H.264 video segment encoder using MediaCodec/MediaMuxer.
3
+ * Ported from iOS RJVideoEncoder.
4
+ *
5
+ * Provides continuous 2 FPS video capture with predictable CPU usage.
6
+ * Each segment is a self-contained .mp4 file that can be uploaded independently.
7
+ *
8
+ * ## Features
9
+ * - H.264 Baseline profile for maximum compatibility
10
+ * - Configurable bitrate (default 1.2 Mbps for quality)
11
+ * - Automatic segment rotation after N frames
12
+ * - Thread-safe frame appending
13
+ * - Emergency flush for crash handling
14
+ *
15
+ * Licensed under the Apache License, Version 2.0
16
+ * Copyright (c) 2026 Rejourney
17
+ */
18
+ package com.rejourney.capture
19
+
20
+ import android.graphics.Bitmap
21
+ import android.media.MediaCodec
22
+ import android.media.MediaCodecInfo
23
+ import android.media.MediaCodecList
24
+ import android.media.MediaFormat
25
+ import android.media.MediaMuxer
26
+ import android.os.Handler
27
+ import android.os.HandlerThread
28
+ import android.view.Surface
29
+ import com.rejourney.core.Logger
30
+ import java.io.File
31
+ import java.nio.ByteBuffer
32
+ import java.util.concurrent.atomic.AtomicBoolean
33
+ import java.util.concurrent.atomic.AtomicInteger
34
+ import java.util.concurrent.atomic.AtomicLong
35
+ import java.util.concurrent.locks.ReentrantLock
36
+ import kotlin.concurrent.withLock
37
+
38
+ /**
39
+ * Delegate interface for receiving completed video segment notifications.
40
+ */
41
+ interface VideoEncoderDelegate {
42
+ /**
43
+ * Called when a video segment has been finalized and is ready for upload.
44
+ *
45
+ * @param segmentFile Local file path of the completed .mp4 segment.
46
+ * @param startTime Segment start time in epoch milliseconds.
47
+ * @param endTime Segment end time in epoch milliseconds.
48
+ * @param frameCount Number of frames encoded in this segment.
49
+ */
50
+ fun onSegmentFinished(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int)
51
+
52
+ /**
53
+ * Called when encoding fails.
54
+ *
55
+ * @param error The error that occurred during encoding.
56
+ */
57
+ fun onEncodingError(error: Exception)
58
+ }
59
+
60
+ class VideoEncoder(private val segmentDir: File) {
61
+
62
+ // Configuration - matching iOS RJVideoEncoder defaults for quality and efficiency
63
+ /** Target video bitrate in bits per second. Default: 1.5 Mbps - matches iOS */
64
+ var targetBitrate: Int = 1_500_000
65
+
66
+ /** Number of frames per segment before auto-rotation. Default: 60 (4s at 15 FPS) */
67
+ var framesPerSegment: Int = 60
68
+
69
+ /** Target frames per second for video timing. Default: 15 */
70
+ var fps: Int = 15
71
+
72
+ /** Scale factor for capturing (0.0-1.0). Default: 0.35 (35% scale) - matches iOS */
73
+ var captureScale: Float = 0.35f
74
+
75
+ /** Maximum dimension in pixels (longest edge). Default: 1920 - matches iOS */
76
+ var maxDimension: Int = 1920
77
+
78
+ /** Keyframe interval in seconds. Default: 10 - matches iOS (fewer keyframes = smaller files) */
79
+ var keyframeInterval: Int = 10
80
+
81
+ /** Display density used to normalize pixel sizes (dp parity with iOS points). */
82
+ var displayDensity: Float = 1f
83
+
84
+ /** Delegate for receiving segment completion notifications */
85
+ var delegate: VideoEncoderDelegate? = null
86
+
87
+ // State
88
+ private var encoder: MediaCodec? = null
89
+ private var muxer: MediaMuxer? = null
90
+ private var inputSurface: Surface? = null
91
+ private var trackIndex: Int = -1
92
+ private var muxerStarted = AtomicBoolean(false)
93
+
94
+ private var currentSegmentFile: File? = null
95
+ private var frameCount = AtomicInteger(0)
96
+ private var segmentStartTime = AtomicLong(0)
97
+ private var segmentFirstFrameTimestamp = AtomicLong(0)
98
+ private var lastFrameTimestamp = AtomicLong(0)
99
+ private var currentFrameWidth = 0
100
+ private var currentFrameHeight = 0
101
+ private val presentationTimesUs = ArrayDeque<Long>()
102
+ private var lastPresentationTimeUs = 0L
103
+
104
+ // CRITICAL FIX: Track original unscaled dimensions for segment rotation
105
+ // Without this, finishSegmentAndContinue() would pass already-scaled dimensions
106
+ // to startSegment(), causing double-scaling and degrading video quality
107
+ private var originalRequestedWidth = 0
108
+ private var originalRequestedHeight = 0
109
+
110
+ private var sessionId: String? = null
111
+
112
+ // Encoding thread
113
+ private var encodingThread: HandlerThread? = null
114
+ private var encodingHandler: Handler? = null
115
+
116
+ // Buffer info for draining encoder
117
+ private val bufferInfo = MediaCodec.BufferInfo()
118
+
119
+ // Lock for thread-safe encoder access
120
+ // Prevents concurrent calls to drainEncoder from appendFrame and finishSegment
121
+ private val encoderLock = ReentrantLock()
122
+
123
+ // isRecording: Allow frames once encoder and surface are ready
124
+ // NOTE: The muxer starts asynchronously when the first frame is encoded and
125
+ // drainEncoder receives INFO_OUTPUT_FORMAT_CHANGED. We cannot require muxerStarted
126
+ // here because that would create a chicken-and-egg problem where no frames can be
127
+ // appended to trigger the muxer to start.
128
+ val isRecording: Boolean
129
+ get() = encoder != null && inputSurface != null
130
+
131
+ val currentFrameCount: Int
132
+ get() = frameCount.get()
133
+
134
+ // Prewarm state
135
+ private var isPrewarmed = false
136
+
137
+ init {
138
+ segmentDir.mkdirs()
139
+ }
140
+
141
+ /**
142
+ * Pre-warm the encoder to eliminate first-frame encoding spike.
143
+ * Call this during SDK initialization before first capture.
144
+ * Matches iOS prewarmPixelBufferPool behavior.
145
+ */
146
+ fun prewarm() {
147
+ if (isPrewarmed) return
148
+
149
+ try {
150
+ // Pre-query available H.264 encoders to cache codec info
151
+ val codecInfo = findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC)
152
+ if (codecInfo != null) {
153
+ Logger.debug("[VideoEncoder] Prewarm: found encoder ${codecInfo.name}")
154
+ isPrewarmed = true
155
+ } else {
156
+ Logger.warning("[VideoEncoder] Prewarm: no H.264 encoder found")
157
+ }
158
+ } catch (e: Exception) {
159
+ Logger.warning("[VideoEncoder] Prewarm failed: ${e.message}")
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Sets the session ID for the current recording session.
165
+ */
166
+ fun setSessionId(id: String) {
167
+ sessionId = id
168
+ }
169
+
170
+ /**
171
+ * Starts a new video segment with the specified frame size.
172
+ * If a segment is already in progress, it will be finished first.
173
+ *
174
+ * @param width Frame width in pixels (will be scaled by captureScale)
175
+ * @param height Frame height in pixels (will be scaled by captureScale)
176
+ * @return true if segment started successfully, false otherwise
177
+ */
178
+ fun startSegment(width: Int, height: Int): Boolean {
179
+ Logger.debug("[VideoEncoder] startSegment: ${width}x${height}")
180
+
181
+ // If already recording, finish current segment first
182
+ if (isRecording) {
183
+ Logger.debug("[VideoEncoder] Already recording, finishing previous segment")
184
+ finishSegment()
185
+ }
186
+
187
+ // CRITICAL FIX: Store original unscaled dimensions BEFORE scaling
188
+ // Used by finishSegmentInternal() during segment rotation to prevent double-scaling
189
+ originalRequestedWidth = width
190
+ originalRequestedHeight = height
191
+
192
+ // Apply scale factor using native pixels (matching iOS)
193
+ var scaledWidth = (width * captureScale).toInt()
194
+ var scaledHeight = (height * captureScale).toInt()
195
+
196
+ // Apply max dimension cap (like iOS)
197
+ val maxDim = maxOf(scaledWidth, scaledHeight)
198
+ if (maxDim > maxDimension) {
199
+ val scale = maxDimension.toFloat() / maxDim.toFloat()
200
+ scaledWidth = (scaledWidth * scale).toInt()
201
+ scaledHeight = (scaledHeight * scale).toInt()
202
+ Logger.debug("[VideoEncoder] Applied max dimension cap: ${scaledWidth}x${scaledHeight}")
203
+ }
204
+
205
+ // Ensure dimensions are even (required for H.264)
206
+ scaledWidth = (scaledWidth / 2) * 2
207
+ scaledHeight = (scaledHeight / 2) * 2
208
+
209
+ // Minimum size check
210
+ if (scaledWidth < 100 || scaledHeight < 100) {
211
+ Logger.warning("[VideoEncoder] Frame size too small, using minimum 100x100")
212
+ scaledWidth = 100
213
+ scaledHeight = 100
214
+ }
215
+
216
+ currentFrameWidth = scaledWidth
217
+ currentFrameHeight = scaledHeight
218
+
219
+ // Reset counters
220
+ frameCount.set(0)
221
+ lastFrameTimestamp.set(0)
222
+ segmentFirstFrameTimestamp.set(0)
223
+ presentationTimesUs.clear()
224
+ lastPresentationTimeUs = 0L
225
+ segmentStartTime.set(System.currentTimeMillis())
226
+
227
+ // Generate unique filename
228
+ val sessionPrefix = sessionId ?: "unknown"
229
+ val filename = "seg_${sessionPrefix}_${segmentStartTime.get()}.mp4"
230
+ currentSegmentFile = File(segmentDir, filename)
231
+
232
+ // Delete existing file if any
233
+ currentSegmentFile?.delete()
234
+
235
+ Logger.debug("[VideoEncoder] Creating segment: $filename (${scaledWidth}x${scaledHeight})")
236
+
237
+ return try {
238
+ // Start encoding thread
239
+ startEncodingThread()
240
+
241
+ // Configure encoder
242
+ configureEncoder(scaledWidth, scaledHeight)
243
+
244
+ true
245
+ } catch (e: Exception) {
246
+ Logger.error("[VideoEncoder] Failed to start segment", e)
247
+ cleanup()
248
+ delegate?.onEncodingError(e)
249
+ false
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Appends a frame to the current video segment.
255
+ * If the segment reaches framesPerSegment, it auto-rotates to a new segment.
256
+ *
257
+ * @param bitmap The screenshot bitmap to encode
258
+ * @param timestamp The capture timestamp in epoch milliseconds
259
+ * @return true if frame was appended successfully, false otherwise
260
+ */
261
+ fun appendFrame(bitmap: Bitmap, timestamp: Long): Boolean {
262
+ if (frameCount.get() % 10 == 0) {
263
+ Logger.debug("[VideoEncoder] appendFrame: count=${frameCount.get()}, isRecording=$isRecording")
264
+ }
265
+
266
+ if (!isRecording) {
267
+ Logger.warning("[VideoEncoder] Cannot append frame, not recording")
268
+ return false
269
+ }
270
+
271
+ val surface = inputSurface ?: run {
272
+ Logger.warning("[VideoEncoder] Cannot append frame, no input surface")
273
+ return false
274
+ }
275
+
276
+ try {
277
+ // Scale bitmap if needed
278
+ val scaledBitmap = if (bitmap.width != currentFrameWidth || bitmap.height != currentFrameHeight) {
279
+ Bitmap.createScaledBitmap(bitmap, currentFrameWidth, currentFrameHeight, true)
280
+ } else {
281
+ bitmap
282
+ }
283
+
284
+ // Track first frame timestamp for presentation time calculation
285
+ if (frameCount.get() == 0) {
286
+ segmentFirstFrameTimestamp.set(timestamp)
287
+ }
288
+
289
+
290
+ // Draw bitmap to encoder input surface
291
+ val canvas = surface.lockHardwareCanvas()
292
+ try {
293
+ canvas.drawBitmap(scaledBitmap, 0f, 0f, null)
294
+ } finally {
295
+ surface.unlockCanvasAndPost(canvas)
296
+ }
297
+
298
+ // Recycle scaled bitmap if we created a new one
299
+ if (scaledBitmap != bitmap) {
300
+ scaledBitmap.recycle()
301
+ }
302
+
303
+ // Drain encoder output (thread-safe)
304
+ encoderLock.withLock {
305
+ val presentationTimeUs = computePresentationTimeUs(timestamp)
306
+ presentationTimesUs.add(presentationTimeUs)
307
+ drainEncoder(false)
308
+ }
309
+
310
+ frameCount.incrementAndGet()
311
+ lastFrameTimestamp.set(timestamp)
312
+
313
+ if (frameCount.get() % 10 == 0) {
314
+ Logger.debug("[VideoEncoder] Frame appended: ${frameCount.get()}/$framesPerSegment")
315
+ }
316
+
317
+ // Auto-rotate segment if we've reached the limit
318
+ if (frameCount.get() >= framesPerSegment) {
319
+ Logger.info("[VideoEncoder] Segment full (${frameCount.get()} frames), rotating")
320
+ finishSegmentAndContinue()
321
+ }
322
+
323
+ return true
324
+ } catch (e: Exception) {
325
+ Logger.error("[VideoEncoder] Failed to append frame", e)
326
+ return false
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Finishes the current segment and notifies the delegate.
332
+ */
333
+ fun finishSegment() {
334
+ finishSegmentInternal(shouldContinue = false)
335
+ }
336
+
337
+ /**
338
+ * Finishes the current segment and starts a new one (rotation).
339
+ */
340
+ private fun finishSegmentAndContinue() {
341
+ finishSegmentInternal(shouldContinue = true)
342
+ }
343
+
344
+ private fun finishSegmentInternal(shouldContinue: Boolean) {
345
+ Logger.debug("[VideoEncoder] finishSegment: continue=$shouldContinue, frames=${frameCount.get()}")
346
+
347
+ val enc = encoder ?: run {
348
+ Logger.debug("[VideoEncoder] No encoder, nothing to finish")
349
+ return
350
+ }
351
+
352
+ val count = frameCount.get()
353
+ val segmentFile = currentSegmentFile
354
+ val startTime = if (segmentFirstFrameTimestamp.get() > 0) {
355
+ segmentFirstFrameTimestamp.get()
356
+ } else {
357
+ segmentStartTime.get()
358
+ }
359
+ val endTime = if (lastFrameTimestamp.get() > 0) {
360
+ lastFrameTimestamp.get()
361
+ } else {
362
+ System.currentTimeMillis()
363
+ }
364
+ val width = currentFrameWidth
365
+ val height = currentFrameHeight
366
+
367
+ // Skip if no frames were written
368
+ if (count == 0) {
369
+ Logger.debug("[VideoEncoder] No frames in segment, canceling")
370
+ cancelSegment()
371
+ return
372
+ }
373
+
374
+ try {
375
+ // Thread-safe: Lock to prevent race with appendFrame's drainEncoder
376
+ encoderLock.withLock {
377
+ // Signal end of stream
378
+ enc.signalEndOfInputStream()
379
+
380
+ // Drain remaining output
381
+ drainEncoder(true)
382
+ }
383
+
384
+ // Stop and release muxer
385
+ if (muxerStarted.get()) {
386
+ muxer?.stop()
387
+ }
388
+
389
+ // Get file size for logging
390
+ val fileSize = segmentFile?.length() ?: 0
391
+ Logger.info("[VideoEncoder] Segment complete - $count frames, ${fileSize / 1024.0} KB, ${(endTime - startTime) / 1000.0}s")
392
+
393
+ // Notify delegate
394
+ segmentFile?.let {
395
+ delegate?.onSegmentFinished(it, startTime, endTime, count)
396
+ }
397
+
398
+ } catch (e: Exception) {
399
+ Logger.error("[VideoEncoder] Failed to finish segment", e)
400
+ delegate?.onEncodingError(e)
401
+ } finally {
402
+ cleanup()
403
+
404
+ // Start new segment if requested
405
+ if (shouldContinue && sessionId != null) {
406
+ Logger.debug("[VideoEncoder] Starting new segment after rotation")
407
+ // CRITICAL FIX: Use original unscaled dimensions, NOT width/height
408
+ // which are the already-scaled currentFrameWidth/currentFrameHeight.
409
+ // Using scaled dimensions would cause double-scaling:
410
+ // 1080x2424 -> 356x800 (first segment) -> 142x320 (second segment)
411
+ startSegment(originalRequestedWidth, originalRequestedHeight)
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Cancels the current segment without saving.
418
+ */
419
+ fun cancelSegment() {
420
+ cleanup()
421
+ currentSegmentFile?.delete()
422
+ currentSegmentFile = null
423
+ frameCount.set(0)
424
+ lastFrameTimestamp.set(0)
425
+ segmentFirstFrameTimestamp.set(0)
426
+ presentationTimesUs.clear()
427
+ lastPresentationTimeUs = 0L
428
+ Logger.debug("[VideoEncoder] Segment canceled")
429
+ }
430
+
431
+ /**
432
+ * Cleans up encoder resources and pending segments.
433
+ */
434
+ fun cleanup() {
435
+ try {
436
+ inputSurface?.release()
437
+ inputSurface = null
438
+ } catch (e: Exception) {
439
+ Logger.debug("[VideoEncoder] Error releasing input surface: ${e.message}")
440
+ }
441
+
442
+ try {
443
+ encoder?.stop()
444
+ } catch (e: Exception) {
445
+ Logger.debug("[VideoEncoder] Error stopping encoder: ${e.message}")
446
+ }
447
+
448
+ try {
449
+ encoder?.release()
450
+ } catch (e: Exception) {
451
+ Logger.debug("[VideoEncoder] Error releasing encoder: ${e.message}")
452
+ }
453
+ encoder = null
454
+
455
+ try {
456
+ if (muxerStarted.get()) {
457
+ muxer?.release()
458
+ }
459
+ } catch (e: Exception) {
460
+ Logger.debug("[VideoEncoder] Error releasing muxer: ${e.message}")
461
+ }
462
+ muxer = null
463
+ muxerStarted.set(false)
464
+ trackIndex = -1
465
+ presentationTimesUs.clear()
466
+ lastPresentationTimeUs = 0L
467
+
468
+ stopEncodingThread()
469
+ }
470
+
471
+ /**
472
+ * Emergency synchronous flush for crash handling.
473
+ * Attempts to finalize the current segment so it can be recovered.
474
+ *
475
+ * @return true if segment was successfully finalized, false otherwise
476
+ */
477
+ fun emergencyFlushSync(): Boolean {
478
+ if (!isRecording || frameCount.get() == 0) {
479
+ return false
480
+ }
481
+
482
+ Logger.warning("[VideoEncoder] Emergency flush - attempting to save ${frameCount.get()} frames")
483
+
484
+ return try {
485
+ // Save segment metadata to disk for recovery
486
+ saveCrashSegmentMetadata()
487
+
488
+ // Thread-safe: Lock to prevent race with appendFrame
489
+ encoderLock.withLock {
490
+ // Try to finalize
491
+ encoder?.signalEndOfInputStream()
492
+ drainEncoder(true)
493
+ }
494
+
495
+ if (muxerStarted.get()) {
496
+ muxer?.stop()
497
+ }
498
+
499
+ Logger.info("[VideoEncoder] Emergency flush succeeded")
500
+ true
501
+ } catch (e: Exception) {
502
+ Logger.error("[VideoEncoder] Emergency flush failed", e)
503
+ false
504
+ } finally {
505
+ cleanup()
506
+ }
507
+ }
508
+
509
+ private fun saveCrashSegmentMetadata() {
510
+ try {
511
+ val metaFile = File(segmentDir, "pending_crash_segment.json")
512
+ val meta = """
513
+ {
514
+ "sessionId": "${sessionId ?: ""}",
515
+ "segmentFile": "${currentSegmentFile?.absolutePath ?: ""}",
516
+ "startTime": ${segmentFirstFrameTimestamp.get()},
517
+ "endTime": ${lastFrameTimestamp.get()},
518
+ "frameCount": ${frameCount.get()}
519
+ }
520
+ """.trimIndent()
521
+ metaFile.writeText(meta)
522
+ } catch (e: Exception) {
523
+ Logger.error("[VideoEncoder] Failed to save crash segment metadata", e)
524
+ }
525
+ }
526
+
527
+ private fun configureEncoder(width: Int, height: Int) {
528
+ // Find H.264 encoder
529
+ val codecInfo = findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC)
530
+ ?: throw IllegalStateException("No H.264 encoder available")
531
+
532
+ // Configure format - matching iOS RJVideoEncoder settings
533
+ val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
534
+ setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
535
+ setInteger(MediaFormat.KEY_BIT_RATE, targetBitrate)
536
+ setInteger(MediaFormat.KEY_FRAME_RATE, fps)
537
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, keyframeInterval) // 10s keyframes like iOS for smaller files
538
+
539
+ // Baseline profile to reduce encoder CPU cost (matches iOS Baseline+CAVLC change)
540
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
541
+ setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
542
+ setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
543
+ }
544
+ }
545
+
546
+ // Create encoder
547
+ encoder = MediaCodec.createByCodecName(codecInfo.name).apply {
548
+ configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
549
+ inputSurface = createInputSurface()
550
+ start()
551
+ }
552
+
553
+ // Create muxer
554
+ muxer = MediaMuxer(currentSegmentFile!!.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
555
+
556
+ Logger.debug("[VideoEncoder] Encoder configured: ${codecInfo.name} ${width}x${height} @ ${targetBitrate}bps")
557
+ }
558
+
559
+ private fun findEncoder(mimeType: String): MediaCodecInfo? {
560
+ val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
561
+ for (codecInfo in codecList.codecInfos) {
562
+ if (!codecInfo.isEncoder) continue
563
+ for (type in codecInfo.supportedTypes) {
564
+ if (type.equals(mimeType, ignoreCase = true)) {
565
+ return codecInfo
566
+ }
567
+ }
568
+ }
569
+ return null
570
+ }
571
+
572
+ private fun computePresentationTimeUs(timestamp: Long): Long {
573
+ val firstTimestamp = segmentFirstFrameTimestamp.get().takeIf { it > 0 } ?: timestamp
574
+ var presentationTimeUs = ((timestamp - firstTimestamp).coerceAtLeast(0L)) * 1000L
575
+ if (presentationTimeUs <= lastPresentationTimeUs) {
576
+ presentationTimeUs = lastPresentationTimeUs + 1
577
+ }
578
+ lastPresentationTimeUs = presentationTimeUs
579
+ return presentationTimeUs
580
+ }
581
+
582
+ private fun drainEncoder(endOfStream: Boolean) {
583
+ val enc = encoder ?: return
584
+ val mux = muxer ?: return
585
+
586
+ while (true) {
587
+ val outputBufferIndex = enc.dequeueOutputBuffer(bufferInfo, 10000)
588
+
589
+ when {
590
+ outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
591
+ if (!endOfStream) break
592
+ // Continue draining if end of stream
593
+ }
594
+
595
+ outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
596
+ if (muxerStarted.get()) {
597
+ Logger.warning("[VideoEncoder] Format changed after muxer started")
598
+ }
599
+ val newFormat = enc.outputFormat
600
+ trackIndex = mux.addTrack(newFormat)
601
+ mux.start()
602
+ muxerStarted.set(true)
603
+ Logger.debug("[VideoEncoder] Muxer started with track $trackIndex")
604
+ }
605
+
606
+ outputBufferIndex >= 0 -> {
607
+ val encodedData = enc.getOutputBuffer(outputBufferIndex)
608
+ ?: throw RuntimeException("Encoder output buffer $outputBufferIndex was null")
609
+
610
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
611
+ // Codec config data - skip
612
+ bufferInfo.size = 0
613
+ }
614
+
615
+ if (bufferInfo.size > 0 && muxerStarted.get()) {
616
+ encodedData.position(bufferInfo.offset)
617
+ encodedData.limit(bufferInfo.offset + bufferInfo.size)
618
+
619
+ val presentationTimeUs = if (presentationTimesUs.isNotEmpty()) {
620
+ presentationTimesUs.removeFirst()
621
+ } else {
622
+ (frameCount.get() * 1_000_000L) / fps
623
+ }
624
+ bufferInfo.presentationTimeUs = presentationTimeUs
625
+
626
+ mux.writeSampleData(trackIndex, encodedData, bufferInfo)
627
+ }
628
+
629
+ enc.releaseOutputBuffer(outputBufferIndex, false)
630
+
631
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
632
+ Logger.debug("[VideoEncoder] End of stream reached")
633
+ break
634
+ }
635
+ }
636
+
637
+ else -> {
638
+ Logger.warning("[VideoEncoder] Unexpected output buffer result: $outputBufferIndex")
639
+ }
640
+ }
641
+ }
642
+ }
643
+
644
+ private fun startEncodingThread() {
645
+ if (encodingThread != null) return
646
+
647
+ encodingThread = HandlerThread("VideoEncoderThread").apply {
648
+ start()
649
+ }
650
+ encodingHandler = Handler(encodingThread!!.looper)
651
+ }
652
+
653
+ private fun stopEncodingThread() {
654
+ encodingThread?.quitSafely()
655
+ try {
656
+ encodingThread?.join(100)
657
+ } catch (e: InterruptedException) {
658
+ Logger.debug("[VideoEncoder] Interrupted waiting for encoding thread")
659
+ }
660
+ encodingThread = null
661
+ encodingHandler = null
662
+ }
663
+
664
+ companion object {
665
+ // Class-level prewarm state (matching iOS +prewarmEncoderAsync)
666
+ @Volatile
667
+ private var staticPrewarmed = false
668
+
669
+ /**
670
+ * Class-level encoder pre-warm to eliminate first-frame encoding spike.
671
+ * Call this during CaptureEngine initialization before first session.
672
+ *
673
+ * This matches iOS +[RJVideoEncoder prewarmEncoderAsync] behavior:
674
+ * - Runs on background thread to avoid blocking main thread
675
+ * - Uses dispatch_once equivalent (synchronized + flag) to run only once
676
+ * - Front-loads ~50-100ms of MediaCodec initialization cost
677
+ */
678
+ fun prewarmEncoderAsync() {
679
+ if (staticPrewarmed) return
680
+
681
+ Thread {
682
+ synchronized(this) {
683
+ if (staticPrewarmed) return@Thread
684
+ staticPrewarmed = true
685
+ }
686
+
687
+ try {
688
+ val startTime = System.nanoTime()
689
+
690
+ // Query available H.264 encoders to cache codec info in system
691
+ val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
692
+ val codecInfos = codecList.codecInfos
693
+
694
+ var encoderName: String? = null
695
+ for (info in codecInfos) {
696
+ if (!info.isEncoder) continue
697
+ try {
698
+ val types = info.supportedTypes
699
+ if (types.any { it.equals(MediaFormat.MIMETYPE_VIDEO_AVC, ignoreCase = true) }) {
700
+ encoderName = info.name
701
+ // Touch capabilities to trigger JIT/caching
702
+ val caps = info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC)
703
+ caps.videoCapabilities
704
+ caps.encoderCapabilities
705
+ break
706
+ }
707
+ } catch (_: Exception) {
708
+ continue
709
+ }
710
+ }
711
+
712
+ val elapsed = (System.nanoTime() - startTime) / 1_000_000.0
713
+ Logger.info("[VideoEncoder] H.264 class prewarm completed in ${elapsed}ms (encoder: $encoderName)")
714
+
715
+ } catch (e: Exception) {
716
+ Logger.warning("[VideoEncoder] Class prewarm failed: ${e.message}")
717
+ }
718
+ }.start()
719
+ }
720
+
721
+ /**
722
+ * Checks if there is a pending video segment from a crash.
723
+ *
724
+ * @param segmentDir The segment directory to check
725
+ * @return Segment metadata if a pending segment exists, null otherwise
726
+ */
727
+ fun getPendingCrashSegmentMetadata(segmentDir: File): Map<String, Any>? {
728
+ val metaFile = File(segmentDir, "pending_crash_segment.json")
729
+ if (!metaFile.exists()) return null
730
+
731
+ return try {
732
+ val json = metaFile.readText()
733
+ val result = mutableMapOf<String, Any>()
734
+ // Simple JSON parsing (avoiding JSONObject for minimal dependencies)
735
+ json.lines().forEach { line ->
736
+ val trimmed = line.trim().removeSuffix(",")
737
+ when {
738
+ trimmed.contains("\"sessionId\"") -> {
739
+ result["sessionId"] = trimmed.substringAfter(":").trim().removeSurrounding("\"")
740
+ }
741
+ trimmed.contains("\"segmentFile\"") -> {
742
+ result["segmentFile"] = trimmed.substringAfter(":").trim().removeSurrounding("\"")
743
+ }
744
+ trimmed.contains("\"startTime\"") -> {
745
+ result["startTime"] = trimmed.substringAfter(":").trim().toLongOrNull() ?: 0L
746
+ }
747
+ trimmed.contains("\"endTime\"") -> {
748
+ result["endTime"] = trimmed.substringAfter(":").trim().toLongOrNull() ?: 0L
749
+ }
750
+ trimmed.contains("\"frameCount\"") -> {
751
+ result["frameCount"] = trimmed.substringAfter(":").trim().toIntOrNull() ?: 0
752
+ }
753
+ }
754
+ }
755
+ result.ifEmpty { null }
756
+ } catch (e: Exception) {
757
+ Logger.error("[VideoEncoder] Failed to read crash segment metadata", e)
758
+ null
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Clears the pending crash segment metadata after recovery.
764
+ */
765
+ fun clearPendingCrashSegmentMetadata(segmentDir: File) {
766
+ try {
767
+ File(segmentDir, "pending_crash_segment.json").delete()
768
+ } catch (e: Exception) {
769
+ Logger.debug("[VideoEncoder] Failed to clear crash segment metadata: ${e.message}")
770
+ }
771
+ }
772
+ }
773
+ }