@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,1553 @@
1
+ /**
2
+ * High-level capture orchestrator using H.264 video segments.
3
+ * Ported from iOS RJCaptureEngine.
4
+ *
5
+ * Captures screenshots and encodes them into video segments for upload.
6
+ * Uses event-driven capture model with adaptive quality based on system conditions.
7
+ *
8
+ * The capture engine is responsible for:
9
+ * - Deciding when to capture frames based on events and throttling rules
10
+ * - Encoding frames into H.264 video segments via VideoEncoder
11
+ * - Capturing view hierarchy snapshots periodically for click/hover maps
12
+ * - Adapting to system conditions (memory, thermal, battery)
13
+ * - Coordinating with SegmentUploader for upload
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.content.Context
21
+ import android.graphics.Bitmap
22
+ import android.graphics.Rect
23
+ import android.os.BatteryManager
24
+ import android.os.Build
25
+ import android.os.Handler
26
+ import android.os.Looper
27
+ import android.os.MessageQueue
28
+ import android.os.PowerManager
29
+ import android.view.PixelCopy
30
+ import android.view.SurfaceView
31
+ import android.view.TextureView
32
+ import android.graphics.Canvas
33
+ import android.opengl.GLSurfaceView
34
+ import android.view.View
35
+ import android.view.ViewGroup
36
+ import android.view.Window
37
+ import com.facebook.react.bridge.ReactApplicationContext
38
+ import com.rejourney.core.CaptureImportance
39
+ import com.rejourney.core.Constants
40
+ import com.rejourney.core.Logger
41
+ import com.rejourney.core.PerformanceLevel
42
+ import com.rejourney.privacy.PrivacyMask
43
+ import com.rejourney.utils.PerfMetric
44
+ import com.rejourney.utils.PerfTiming
45
+ import kotlinx.coroutines.*
46
+ import org.json.JSONArray
47
+ import org.json.JSONObject
48
+ import java.io.File
49
+ import java.util.concurrent.ConcurrentLinkedQueue
50
+ import java.util.concurrent.Executors
51
+ import java.util.concurrent.atomic.AtomicBoolean
52
+ import java.util.concurrent.atomic.AtomicInteger
53
+ import kotlin.math.max
54
+
55
+ /**
56
+ * Delegate for receiving segment completion notifications.
57
+ */
58
+ interface CaptureEngineDelegate {
59
+ /**
60
+ * Called when a video segment is ready for upload.
61
+ */
62
+ fun onSegmentReady(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int)
63
+
64
+ /**
65
+ * Called when capture encounters an error.
66
+ */
67
+ fun onCaptureError(error: Exception)
68
+
69
+ /**
70
+ * Called when hierarchy snapshots need to be uploaded.
71
+ */
72
+ fun onHierarchySnapshotsReady(snapshotsJson: ByteArray, timestamp: Long) {}
73
+ }
74
+
75
+ class CaptureEngine(private val context: Context) : VideoEncoderDelegate {
76
+
77
+ private data class PendingCapture(
78
+ val wantedAt: Long,
79
+ var deadline: Long,
80
+ val reason: String,
81
+ val importance: CaptureImportance,
82
+ val generation: Int,
83
+ var scanResult: ViewHierarchyScanResult? = null,
84
+ var layoutSignature: String? = null,
85
+ var lastScanTime: Long = 0L
86
+ )
87
+
88
+ // Configuration
89
+ var captureScale: Float = Constants.DEFAULT_CAPTURE_SCALE
90
+ var minFrameInterval: Double = Constants.DEFAULT_MIN_FRAME_INTERVAL
91
+ var maxFramesPerMinute: Int = Constants.DEFAULT_MAX_FRAMES_PER_MINUTE
92
+ var framesPerSegment: Int = Constants.DEFAULT_FRAMES_PER_SEGMENT
93
+ var targetBitrate: Int = Constants.DEFAULT_VIDEO_BITRATE
94
+ var targetFps: Int = Constants.DEFAULT_VIDEO_FPS
95
+ var hierarchyCaptureInterval: Int = 5 // Capture hierarchy every N frames
96
+ var adaptiveQualityEnabled: Boolean = true
97
+ var thermalThrottleEnabled: Boolean = true
98
+ var batteryAwareEnabled: Boolean = true
99
+ var privacyMaskTextInputs: Boolean = true
100
+ set(value) {
101
+ field = value
102
+ PrivacyMask.maskTextInputs = value
103
+ viewScanner?.config?.detectTextInputs = value
104
+ }
105
+ var privacyMaskCameraViews: Boolean = true
106
+ set(value) {
107
+ field = value
108
+ PrivacyMask.maskCameraViews = value
109
+ viewScanner?.config?.detectCameraViews = value
110
+ }
111
+ var privacyMaskWebViews: Boolean = true
112
+ set(value) {
113
+ field = value
114
+ PrivacyMask.maskWebViews = value
115
+ viewScanner?.config?.detectWebViews = value
116
+ }
117
+ var privacyMaskVideoLayers: Boolean = true
118
+ set(value) {
119
+ field = value
120
+ PrivacyMask.maskVideoLayers = value
121
+ viewScanner?.config?.detectVideoLayers = value
122
+ }
123
+
124
+ // Delegate for segment upload coordination
125
+ var delegate: CaptureEngineDelegate? = null
126
+
127
+ // Read-only state
128
+ var currentPerformanceLevel: PerformanceLevel = PerformanceLevel.NORMAL
129
+ private set
130
+ val frameCount: Int
131
+ get() = videoEncoder?.currentFrameCount ?: 0
132
+
133
+ // FIX: Change this property getter to use explicit function body
134
+ val isRecording: Boolean
135
+ get() {
136
+ return _isRecording && !isShuttingDown.get()
137
+ }
138
+
139
+
140
+ // Hierarchy tracking (matching iOS)
141
+ private var framesSinceHierarchy: Int = 0
142
+
143
+ // Hierarchy snapshots accumulator
144
+ private val hierarchySnapshots = mutableListOf<Map<String, Any?>>()
145
+
146
+ // Video encoder
147
+ private var videoEncoder: VideoEncoder? = null
148
+ private val segmentDir: File by lazy {
149
+ File(context.filesDir, "rejourney/segments").apply { mkdirs() }
150
+ }
151
+
152
+ // Motion tracker
153
+ private val motionTracker = MotionTracker()
154
+
155
+ private val captureHeuristics = CaptureHeuristics()
156
+
157
+ // Handler for main thread operations
158
+ private val mainHandler = Handler(Looper.getMainLooper())
159
+
160
+ // Executor for background image processing
161
+ private val processingExecutor = Executors.newSingleThreadExecutor()
162
+
163
+ // Coroutine scope for async operations
164
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
165
+
166
+ // Capture timer (1 FPS = every 1000ms, matches iOS RJCaptureEngine defaults)
167
+ private var captureRunnable: Runnable? = null
168
+ private val captureIntervalMs: Long
169
+ get() = (1000L / targetFps).coerceAtLeast(100)
170
+
171
+ // Optimized bitmap object pool to reduce GC churn
172
+ private val bitmapPool = ConcurrentLinkedQueue<Bitmap>()
173
+ private val MAX_POOL_SIZE = 8 // Increased pool size for better reuse
174
+
175
+ var onFrameCaptured: (() -> Unit)? = null
176
+
177
+ // -- STATE PROPERTIES (Restored) --
178
+ private val isShuttingDown = AtomicBoolean(false)
179
+ private var _isRecording: Boolean = false
180
+ private val captureInProgress = AtomicBoolean(false)
181
+ private var sessionId: String? = null
182
+ private var currentScreenName: String? = null
183
+ private var viewScanner: ViewHierarchyScanner? = null
184
+ private var viewSerializer: ViewSerializer? = null
185
+ private var lastSerializedSignature: String? = null
186
+ private var pendingCapture: PendingCapture? = null
187
+ private var pendingCaptureGeneration = 0
188
+ private var pendingDefensiveCaptureTime = 0L
189
+ private var pendingDefensiveCaptureGeneration = 0
190
+ private var idleCapturePending = false
191
+ private var idleCaptureGeneration = 0
192
+ private val cacheLock = Any()
193
+ private var lastCapturedBitmap: Bitmap? = null
194
+ private var lastSafeBitmap: Bitmap? = null
195
+ private var lastCapturedHadBlockedSurface = false
196
+
197
+ // Throttling & Adaptive Quality State
198
+ private var framesSinceSessionStart = 0
199
+ private var framesThisMinute = 0
200
+ private var minuteStartTime: Long = 0
201
+ private var lastCaptureTime: Long = 0
202
+ private var consecutiveCaptureCount = 0
203
+ private var cooldownUntil: Long = 0
204
+ private var didPrewarmViewCaches = false
205
+
206
+ // Battery Awareness
207
+ private var cachedBatteryLevel: Float = 1.0f
208
+ private var lastBatteryCheckTime: Long = 0L
209
+
210
+ // Device dimensions
211
+ private var deviceWidth: Int = 0
212
+ private var deviceHeight: Int = 0
213
+ private var lastMapPresenceTimeMs: Long = 0L
214
+
215
+ init {
216
+ // Pre-warm H.264 encoder in background
217
+ try {
218
+ VideoEncoder.prewarmEncoderAsync()
219
+ } catch (e: Exception) {
220
+ Logger.warning("[CaptureEngine] Encoder prewarm failed (non-critical): ${e.message}")
221
+ }
222
+
223
+ // Pre-warm render server (Optimization 4)
224
+ mainHandler.post {
225
+ prewarmRenderServer()
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Start a new capture session.
231
+ */
232
+ fun startSession(sessionId: String) {
233
+ if (isShuttingDown.get()) return
234
+
235
+ if (_isRecording) {
236
+ Logger.warning("[CaptureEngine] Session already active, stopping previous")
237
+ stopSession()
238
+ }
239
+
240
+ this.sessionId = sessionId
241
+ _isRecording = true
242
+ framesThisMinute = 0
243
+ minuteStartTime = System.currentTimeMillis()
244
+ lastCaptureTime = 0
245
+ consecutiveCaptureCount = 0
246
+ cooldownUntil = 0
247
+ lastMapPresenceTimeMs = 0L
248
+ framesSinceHierarchy = 0
249
+ framesSinceSessionStart = 0
250
+ hierarchySnapshots.clear()
251
+ viewScanner = null
252
+ viewSerializer = null
253
+ lastSerializedSignature = null
254
+ motionTracker.reset()
255
+ lastSerializedSignature = null
256
+ pendingCapture = null
257
+ pendingCaptureGeneration = 0
258
+ pendingDefensiveCaptureTime = 0L
259
+ pendingDefensiveCaptureGeneration = 0
260
+ idleCapturePending = false
261
+ idleCaptureGeneration = 0
262
+ captureHeuristics.reset()
263
+ resetCachedFrames()
264
+ viewScanner = ViewHierarchyScanner().apply {
265
+ config.detectTextInputs = privacyMaskTextInputs
266
+ config.detectCameraViews = privacyMaskCameraViews
267
+ config.detectWebViews = privacyMaskWebViews
268
+ config.detectVideoLayers = privacyMaskVideoLayers
269
+ }
270
+ viewSerializer = ViewSerializer(context.resources.displayMetrics.density)
271
+
272
+ PrivacyMask.maskTextInputs = privacyMaskTextInputs
273
+ PrivacyMask.maskCameraViews = privacyMaskCameraViews
274
+ PrivacyMask.maskWebViews = privacyMaskWebViews
275
+ PrivacyMask.maskVideoLayers = privacyMaskVideoLayers
276
+
277
+ // Pre-warm privacy scanner class caches (matching iOS prewarmClassCaches)
278
+ // This eliminates ~10-15ms of cold-cache class lookups on first frame
279
+ if (!didPrewarmViewCaches) {
280
+ viewScanner?.prewarmClassCaches()
281
+ PrivacyMask.prewarmClassCaches()
282
+ didPrewarmViewCaches = true
283
+ }
284
+
285
+ // Initialize video encoder
286
+ videoEncoder = VideoEncoder(segmentDir).apply {
287
+ this.targetBitrate = this@CaptureEngine.targetBitrate
288
+ this.framesPerSegment = this@CaptureEngine.framesPerSegment
289
+ this.fps = this@CaptureEngine.targetFps
290
+ this.captureScale = this@CaptureEngine.captureScale
291
+ this.displayDensity = context.resources.displayMetrics.density
292
+ this.maxDimension = Constants.MAX_VIDEO_DIMENSION
293
+ this.keyframeInterval = Constants.DEFAULT_KEYFRAME_INTERVAL
294
+ this.delegate = this@CaptureEngine
295
+ setSessionId(sessionId)
296
+ prewarm() // Pre-warm encoder to eliminate first-frame spike
297
+ }
298
+
299
+ // Clean up any orphaned segments from previous sessions
300
+ cleanupOldSegments()
301
+
302
+ // Start the first video segment
303
+ // This is required to set isRecording=true in the encoder so frames can be appended
304
+ mainHandler.post {
305
+ val window = getCurrentWindow()
306
+ if (window != null) {
307
+ val decorView = window.decorView
308
+ if (decorView.width > 0 && decorView.height > 0) {
309
+ videoEncoder?.startSegment(decorView.width, decorView.height)
310
+ Logger.debug("[CaptureEngine] Started first segment: ${decorView.width}x${decorView.height}")
311
+ } else {
312
+ // Defer segment start until we have valid dimensions
313
+ Logger.debug("[CaptureEngine] Deferring segment start - waiting for valid dimensions")
314
+ }
315
+ }
316
+ }
317
+
318
+ // Start capture timer
319
+ startCaptureTimer()
320
+
321
+ Logger.debug("[CaptureEngine] Session started: $sessionId")
322
+ }
323
+
324
+ /**
325
+ * Stop the current capture session.
326
+ */
327
+ fun stopSession() {
328
+ if (!_isRecording && sessionId == null) return
329
+
330
+ Logger.info("[CaptureEngine] Stopping session: $sessionId")
331
+
332
+ _isRecording = false
333
+ stopCaptureTimer()
334
+
335
+ // Finish current segment (will trigger delegate callback)
336
+ videoEncoder?.finishSegment()
337
+
338
+ // Upload any remaining hierarchy snapshots
339
+ uploadCurrentHierarchySnapshots()
340
+
341
+ // OPTIMIZATION: Clean up resources
342
+ cleanup()
343
+
344
+ Logger.debug("[CaptureEngine] Session stopped")
345
+ }
346
+
347
+ /**
348
+ * OPTIMIZATION: Comprehensive cleanup to prevent memory leaks.
349
+ */
350
+ private fun cleanup() {
351
+ // Clean up video encoder
352
+ videoEncoder = null
353
+
354
+ // Clean up bitmap resources
355
+ // Clear caches
356
+ hierarchySnapshots.clear()
357
+
358
+ // Reset counters
359
+ framesSinceSessionStart = 0
360
+ framesSinceHierarchy = 0
361
+ sessionId = null
362
+ currentScreenName = "Unknown"
363
+ pendingCapture = null
364
+ pendingCaptureGeneration = 0
365
+ pendingDefensiveCaptureTime = 0L
366
+ pendingDefensiveCaptureGeneration = 0
367
+ idleCapturePending = false
368
+ idleCaptureGeneration = 0
369
+ lastMapPresenceTimeMs = 0L
370
+ captureHeuristics.reset()
371
+ resetCachedFrames()
372
+
373
+ // Cancel any pending operations
374
+ scope.coroutineContext.cancelChildren()
375
+ }
376
+
377
+ /**
378
+ * Pause video capture (e.g., when app goes to background).
379
+ * Finishes current segment but keeps session alive.
380
+ */
381
+ fun pauseVideoCapture() {
382
+ if (!_isRecording) return
383
+
384
+ Logger.info("[CaptureEngine] Pausing video capture")
385
+
386
+ pendingCapture = null
387
+ pendingCaptureGeneration = 0
388
+ pendingDefensiveCaptureTime = 0L
389
+ pendingDefensiveCaptureGeneration = 0
390
+ idleCapturePending = false
391
+ idleCaptureGeneration = 0
392
+
393
+ stopCaptureTimer()
394
+
395
+ // Finish current segment
396
+ videoEncoder?.finishSegment()
397
+
398
+ // Upload hierarchy snapshots
399
+ uploadCurrentHierarchySnapshots()
400
+ }
401
+
402
+ /**
403
+ * Resume video capture (e.g., when app returns to foreground).
404
+ */
405
+ fun resumeVideoCapture() {
406
+ if (!_isRecording) return
407
+
408
+ Logger.info("[CaptureEngine] Resuming video capture")
409
+
410
+ captureHeuristics.reset()
411
+ resetCachedFrames()
412
+ pendingCapture = null
413
+ pendingCaptureGeneration = 0
414
+ pendingDefensiveCaptureTime = 0L
415
+ pendingDefensiveCaptureGeneration = 0
416
+ idleCapturePending = false
417
+ idleCaptureGeneration = 0
418
+
419
+ // Get window and start new segment
420
+ mainHandler.post {
421
+ val window = getCurrentWindow()
422
+ if (window != null && videoEncoder != null) {
423
+ val decorView = window.decorView
424
+ videoEncoder?.startSegment(decorView.width, decorView.height)
425
+ startCaptureTimer()
426
+ }
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Notify navigation to a new screen.
432
+ * Triggers a delayed capture to allow render.
433
+ */
434
+ fun notifyNavigationToScreen(screenName: String) {
435
+ if (!_isRecording) return
436
+
437
+ if (screenName == currentScreenName) return
438
+
439
+ currentScreenName = screenName
440
+ lastSerializedSignature = null
441
+
442
+ val now = android.os.SystemClock.elapsedRealtime()
443
+ captureHeuristics.invalidateSignature()
444
+ captureHeuristics.recordNavigationEventAtTime(now)
445
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_NAVIGATION_MS, "navigation")
446
+ }
447
+
448
+ /**
449
+ * Notify a gesture occurred.
450
+ */
451
+ fun notifyGesture(gestureType: String) {
452
+ if (!_isRecording) return
453
+ val now = android.os.SystemClock.elapsedRealtime()
454
+ val normalized = gestureType.lowercase()
455
+ val isScroll = normalized.startsWith("scroll")
456
+ val mapGesture = isMapGestureType(normalized) && hasRecentMapPresence(now)
457
+
458
+ if (isScroll) {
459
+ captureHeuristics.recordTouchEventAtTime(now)
460
+ } else {
461
+ captureHeuristics.recordInteractionEventAtTime(now)
462
+ }
463
+
464
+ if (mapGesture) {
465
+ captureHeuristics.recordMapInteractionAtTime(now)
466
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_MAP_MS, "map")
467
+ return
468
+ }
469
+
470
+ if (isScroll) {
471
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_SCROLL_MS, "scroll")
472
+ } else {
473
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_INTERACTION_MS, "interaction")
474
+ }
475
+ }
476
+
477
+ private fun isMapGestureType(gestureType: String): Boolean {
478
+ return gestureType.startsWith("scroll") ||
479
+ gestureType.startsWith("pan") ||
480
+ gestureType.startsWith("pinch") ||
481
+ gestureType.startsWith("zoom") ||
482
+ gestureType.startsWith("rotate") ||
483
+ gestureType.startsWith("swipe") ||
484
+ gestureType.startsWith("drag")
485
+ }
486
+
487
+ private fun hasRecentMapPresence(now: Long): Boolean {
488
+ return lastMapPresenceTimeMs > 0 && (now - lastMapPresenceTimeMs) <= MAP_PRESENCE_WINDOW_MS
489
+ }
490
+
491
+ /**
492
+ * Notify interaction started (touch down).
493
+ */
494
+ fun notifyInteractionStart() {
495
+ if (!_isRecording) return
496
+ val now = android.os.SystemClock.elapsedRealtime()
497
+ captureHeuristics.recordTouchEventAtTime(now)
498
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_INTERACTION_MS, "interaction_start")
499
+ }
500
+
501
+ /**
502
+ * Notify scroll offset change.
503
+ */
504
+ fun notifyScrollOffset(offset: Float) {
505
+ if (!_isRecording) return
506
+
507
+ val event = motionTracker.recordScrollOffset(offsetY = offset)
508
+ if (event != null) {
509
+ val now = android.os.SystemClock.elapsedRealtime()
510
+ captureHeuristics.recordTouchEventAtTime(now)
511
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_SCROLL_MS, "scroll")
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Notify a visual change.
517
+ */
518
+ fun notifyVisualChange(reason: String, importance: CaptureImportance) {
519
+ if (!_isRecording) return
520
+ notifyReactNativeCommit()
521
+ }
522
+
523
+ fun notifyReactNativeCommit() {
524
+ if (!_isRecording) return
525
+
526
+ val now = android.os.SystemClock.elapsedRealtime()
527
+ captureHeuristics.invalidateSignature()
528
+ captureHeuristics.recordInteractionEventAtTime(now)
529
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_INTERACTION_MS, "rn_commit")
530
+ }
531
+
532
+ fun notifyKeyboardEvent(reason: String) {
533
+ if (!_isRecording) return
534
+ val now = android.os.SystemClock.elapsedRealtime()
535
+ captureHeuristics.recordKeyboardEventAtTime(now)
536
+ requestDefensiveCaptureAfterDelay(DEFENSIVE_CAPTURE_DELAY_INTERACTION_MS, reason)
537
+ }
538
+
539
+ /**
540
+ * Force an immediate capture regardless of throttling.
541
+ */
542
+ fun forceCaptureWithReason(reason: String) {
543
+ if (!_isRecording) return
544
+ requestCapture(CaptureImportance.CRITICAL, reason, forceCapture = true)
545
+ }
546
+
547
+ /**
548
+ * Handle memory warning.
549
+ */
550
+ fun handleMemoryWarning() {
551
+ Logger.warning("[CaptureEngine] Memory warning received")
552
+ currentPerformanceLevel = PerformanceLevel.MINIMAL
553
+ }
554
+
555
+ /**
556
+ * Emergency flush for crash handling.
557
+ */
558
+ fun emergencyFlush(): Boolean {
559
+ return videoEncoder?.emergencyFlushSync() ?: false
560
+ }
561
+
562
+ // ==================== VideoEncoderDelegate ====================
563
+
564
+ override fun onSegmentFinished(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int) {
565
+ Logger.info("[CaptureEngine] Segment ready: ${segmentFile.name} ($frameCount frames, ${(endTime - startTime) / 1000.0}s)")
566
+
567
+ // Notify delegate of segment completion
568
+ delegate?.onSegmentReady(segmentFile, startTime, endTime, frameCount)
569
+
570
+ // Upload accumulated hierarchy snapshots
571
+ uploadCurrentHierarchySnapshots()
572
+
573
+ // NOTE: Do NOT start a new segment here!
574
+ // VideoEncoder.finishSegmentAndContinue() already handles starting the next segment
575
+ // during automatic rotation (when segment reaches framesPerSegment limit).
576
+ // Starting a segment here causes a race condition where we try to finish an
577
+ // encoder that was just created, crashing with IllegalStateException in
578
+ // signalEndOfInputStream().
579
+ //
580
+ // New segments are now started by:
581
+ // 1. VideoEncoder.finishSegmentAndContinue() during rotation (internal)
582
+ // 2. captureFrameInternal() when recording but encoder not started yet
583
+ }
584
+
585
+ override fun onEncodingError(error: Exception) {
586
+ Logger.error("[CaptureEngine] Encoding error", error)
587
+ delegate?.onCaptureError(error)
588
+ }
589
+
590
+ // ==================== Private Methods ====================
591
+
592
+ private fun captureFrame(importance: CaptureImportance, reason: String) {
593
+ requestCapture(importance, reason, forceCapture = false)
594
+ }
595
+ private fun requestCapture(importance: CaptureImportance, reason: String, forceCapture: Boolean) {
596
+ if (!_isRecording || isShuttingDown.get()) return
597
+ if (!forceCapture && !shouldCapture(importance)) {
598
+ Logger.debug("[CaptureEngine] Capture throttled: $reason (importance: $importance)")
599
+ return
600
+ }
601
+
602
+ val now = android.os.SystemClock.elapsedRealtime()
603
+
604
+ pendingCapture?.let {
605
+ it.deadline = now
606
+ scheduleIdleCaptureAttempt(0L)
607
+ }
608
+
609
+ var graceMs = captureHeuristics.captureGraceMs
610
+ if (captureHeuristics.animationBlocking || captureHeuristics.scrollActive || captureHeuristics.keyboardAnimating) {
611
+ graceMs = minOf(graceMs, 300L)
612
+ }
613
+
614
+ val pending = PendingCapture(
615
+ wantedAt = now,
616
+ deadline = now + graceMs,
617
+ reason = reason,
618
+ importance = importance,
619
+ generation = ++pendingCaptureGeneration
620
+ )
621
+ pendingCapture = pending
622
+ scheduleIdleCaptureAttempt(0L)
623
+ }
624
+
625
+ private fun requestDefensiveCaptureAfterDelay(delayMs: Long, reason: String) {
626
+ if (!_isRecording || isShuttingDown.get()) return
627
+
628
+ val now = android.os.SystemClock.elapsedRealtime()
629
+ val target = now + max(0L, delayMs)
630
+ if (pendingDefensiveCaptureTime > 0 && target >= pendingDefensiveCaptureTime - 10L) {
631
+ return
632
+ }
633
+
634
+ pendingDefensiveCaptureTime = target
635
+ val generation = ++pendingDefensiveCaptureGeneration
636
+
637
+ mainHandler.postDelayed({
638
+ if (!_isRecording || isShuttingDown.get()) return@postDelayed
639
+ if (pendingDefensiveCaptureGeneration != generation) return@postDelayed
640
+ pendingDefensiveCaptureTime = 0L
641
+ requestCapture(CaptureImportance.HIGH, reason, forceCapture = true)
642
+ }, max(0L, delayMs))
643
+ }
644
+
645
+ private fun attemptPendingCapture(pending: PendingCapture) {
646
+ if (pendingCapture?.generation != pending.generation) return
647
+ if (!_isRecording || isShuttingDown.get()) return
648
+
649
+ val now = android.os.SystemClock.elapsedRealtime()
650
+ if (now > pending.deadline) {
651
+ emitFrameForPendingCapture(pending, shouldRender = false)
652
+ return
653
+ }
654
+
655
+ if (captureInProgress.get()) {
656
+ scheduleIdleCaptureAttempt(captureHeuristics.pollIntervalMs)
657
+ return
658
+ }
659
+
660
+ val currentWindow = getCurrentWindow() ?: run {
661
+ pendingCapture = null
662
+ return
663
+ }
664
+ val decorView = currentWindow.decorView
665
+ if (decorView.width <= 0 || decorView.height <= 0) {
666
+ pendingCapture = null
667
+ return
668
+ }
669
+
670
+ val scanResult = try {
671
+ viewScanner?.scanAllWindowsRelativeTo(currentWindow)
672
+ } catch (e: Exception) {
673
+ Logger.warning("[CaptureEngine] View scan failed: ${e.message}")
674
+ null
675
+ }
676
+
677
+ pending.scanResult = scanResult
678
+ pending.layoutSignature = scanResult?.layoutSignature
679
+ pending.lastScanTime = now
680
+
681
+ if (scanResult?.hasMapView == true || scanResult?.mapViewFrames?.isNotEmpty() == true) {
682
+ lastMapPresenceTimeMs = now
683
+ }
684
+
685
+ captureHeuristics.updateWithScanResult(scanResult, now)
686
+
687
+ val decision = captureHeuristics.decisionForSignature(
688
+ pending.layoutSignature,
689
+ now,
690
+ hasLastFrame = lastCapturedBitmap != null
691
+ )
692
+
693
+ if (decision.action == CaptureAction.Defer) {
694
+ val deferUntil = max(decision.deferUntilMs, now + captureHeuristics.pollIntervalMs)
695
+ if (deferUntil > pending.deadline) {
696
+ emitFrameForPendingCapture(pending, shouldRender = false)
697
+ return
698
+ }
699
+ scheduleIdleCaptureAttempt(deferUntil - now)
700
+ return
701
+ }
702
+
703
+ emitFrameForPendingCapture(
704
+ pending,
705
+ shouldRender = decision.action == CaptureAction.RenderNow
706
+ )
707
+ }
708
+
709
+ private fun scheduleIdleCaptureAttempt(delayMs: Long) {
710
+ val generation = pendingCapture?.generation ?: return
711
+ if (idleCapturePending && idleCaptureGeneration == generation) return
712
+ idleCapturePending = true
713
+ idleCaptureGeneration = generation
714
+ mainHandler.postDelayed({
715
+ val pending = pendingCapture
716
+ if (pending == null || pending.generation != generation) {
717
+ idleCapturePending = false
718
+ return@postDelayed
719
+ }
720
+ Looper.getMainLooper().queue.addIdleHandler(MessageQueue.IdleHandler {
721
+ idleCapturePending = false
722
+ val currentPending = pendingCapture
723
+ if (currentPending != null && currentPending.generation == generation) {
724
+ attemptPendingCapture(currentPending)
725
+ }
726
+ false
727
+ })
728
+ }, max(0L, delayMs))
729
+ }
730
+
731
+ private fun emitFrameForPendingCapture(
732
+ pending: PendingCapture,
733
+ shouldRender: Boolean
734
+ ) {
735
+ if (pendingCapture?.generation != pending.generation) return
736
+ if (!_isRecording || isShuttingDown.get()) return
737
+
738
+ pendingCapture = null
739
+
740
+ val currentWindow = getCurrentWindow() ?: return
741
+ val decorView = currentWindow.decorView
742
+ if (decorView.width <= 0 || decorView.height <= 0) {
743
+ return
744
+ }
745
+
746
+ val scanResult = pending.scanResult
747
+ val hasBlockedSurface = scanResult?.let {
748
+ it.cameraFrames.isNotEmpty() || it.webViewFrames.isNotEmpty() || it.videoFrames.isNotEmpty()
749
+ } ?: false
750
+
751
+ if (captureInProgress.getAndSet(true)) {
752
+ return
753
+ }
754
+
755
+ if (shouldRender) {
756
+ val sensitiveRects = resolveSensitiveRects(scanResult, decorView)
757
+ captureFrameInternal(pending.reason, scanResult, sensitiveRects, currentWindow, decorView, hasBlockedSurface)
758
+ } else {
759
+ appendCachedFrame(pending.reason, scanResult, hasBlockedSurface)
760
+ }
761
+ }
762
+
763
+ private fun resolveSensitiveRects(
764
+ scanResult: ViewHierarchyScanResult?,
765
+ decorView: View
766
+ ): List<Rect> {
767
+ val shouldMask =
768
+ privacyMaskTextInputs || privacyMaskCameraViews || privacyMaskWebViews || privacyMaskVideoLayers
769
+ if (!shouldMask) return emptyList()
770
+
771
+ return try {
772
+ scanResult?.let { collectSensitiveRects(it) } ?: run {
773
+ val activity = (context as? ReactApplicationContext)?.currentActivity
774
+ if (activity != null) {
775
+ PrivacyMask.findSensitiveRectsInAllWindows(activity, decorView)
776
+ } else {
777
+ PrivacyMask.findSensitiveRects(decorView)
778
+ }
779
+ }
780
+ } catch (e: Exception) {
781
+ Logger.warning("[CaptureEngine] Sensitive rect scan failed: ${e.message}")
782
+ emptyList()
783
+ }
784
+ }
785
+
786
+ private fun appendCachedFrame(
787
+ reason: String,
788
+ scanResult: ViewHierarchyScanResult?,
789
+ hasBlockedSurface: Boolean
790
+ ) {
791
+ val cachedBitmap = synchronized(cacheLock) {
792
+ if (!hasBlockedSurface && lastCapturedHadBlockedSurface && lastSafeBitmap != null) {
793
+ lastSafeBitmap
794
+ } else {
795
+ lastCapturedBitmap
796
+ }
797
+ }
798
+
799
+ if (cachedBitmap == null || cachedBitmap.isRecycled) {
800
+ captureInProgress.set(false)
801
+ return
802
+ }
803
+
804
+ val timestamp = System.currentTimeMillis()
805
+ processingExecutor.submit {
806
+ val encoder = videoEncoder
807
+ if (encoder == null) {
808
+ captureInProgress.set(false)
809
+ return@submit
810
+ }
811
+ val success = try {
812
+ encoder.appendFrame(cachedBitmap, timestamp)
813
+ } catch (e: Exception) {
814
+ Logger.error("[CaptureEngine] Failed to append cached frame", e)
815
+ false
816
+ }
817
+
818
+ if (success) {
819
+ handleFrameAppended(scanResult, timestamp, didRender = false, hasBlockedSurface = hasBlockedSurface,
820
+ cachedBitmap = cachedBitmap)
821
+ }
822
+ captureInProgress.set(false)
823
+ }
824
+ }
825
+
826
+ private fun captureFrameInternal(
827
+ reason: String,
828
+ scanResult: ViewHierarchyScanResult?,
829
+ sensitiveRects: List<Rect>,
830
+ currentWindow: Window,
831
+ decorView: View,
832
+ hasBlockedSurface: Boolean
833
+ ) {
834
+ if (!_isRecording || isShuttingDown.get()) {
835
+ captureInProgress.set(false)
836
+ return
837
+ }
838
+
839
+ if (videoEncoder == null) {
840
+ captureInProgress.set(false)
841
+ return
842
+ }
843
+
844
+ // Check cooldown
845
+ val nowMs = System.currentTimeMillis()
846
+ if (nowMs < cooldownUntil) {
847
+ captureInProgress.set(false)
848
+ return
849
+ }
850
+
851
+ // Store device dimensions for reference
852
+ deviceWidth = decorView.width
853
+ deviceHeight = decorView.height
854
+
855
+ // Calculate scaled dimensions for bitmap
856
+ var effectiveScale = captureScale
857
+ if (!effectiveScale.isFinite() || effectiveScale <= 0f) {
858
+ effectiveScale = Constants.DEFAULT_CAPTURE_SCALE
859
+ }
860
+
861
+ if (adaptiveQualityEnabled) {
862
+ if (currentPerformanceLevel == PerformanceLevel.REDUCED) {
863
+ effectiveScale = minOf(effectiveScale, 0.25f)
864
+ } else if (currentPerformanceLevel == PerformanceLevel.MINIMAL) {
865
+ effectiveScale = minOf(effectiveScale, 0.15f)
866
+ }
867
+ }
868
+
869
+ val scaledWidth = (decorView.width * effectiveScale).toInt().coerceAtLeast(1)
870
+ val scaledHeight = (decorView.height * effectiveScale).toInt().coerceAtLeast(1)
871
+
872
+ try {
873
+ val bitmap = getBitmap(scaledWidth, scaledHeight)
874
+ val requiresPixelCopy = requiresPixelCopyCapture(decorView)
875
+
876
+ if (requiresPixelCopy) {
877
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
878
+ try {
879
+ PixelCopy.request(
880
+ currentWindow,
881
+ bitmap,
882
+ { copyResult ->
883
+ if (copyResult == PixelCopy.SUCCESS) {
884
+ processingExecutor.submit {
885
+ processCapture(
886
+ bitmap,
887
+ reason,
888
+ sensitiveRects,
889
+ decorView.width,
890
+ decorView.height,
891
+ scanResult,
892
+ hasBlockedSurface
893
+ )
894
+ }
895
+ } else {
896
+ Logger.debug("[CaptureEngine] PixelCopy failed with result: $copyResult")
897
+ captureInProgress.set(false)
898
+ returnBitmap(bitmap)
899
+ }
900
+ },
901
+ mainHandler
902
+ )
903
+ } catch (e: Exception) {
904
+ Logger.debug("[CaptureEngine] PixelCopy request failed: ${e.message}")
905
+ captureInProgress.set(false)
906
+ returnBitmap(bitmap)
907
+ }
908
+ } else {
909
+ // PixelCopy not available pre-O; skip to avoid incorrect GPU capture
910
+ captureInProgress.set(false)
911
+ returnBitmap(bitmap)
912
+ }
913
+ } else {
914
+ try {
915
+ val canvas = Canvas(bitmap)
916
+
917
+ val scaleX = bitmap.width.toFloat() / decorView.width.toFloat()
918
+ val scaleY = bitmap.height.toFloat() / decorView.height.toFloat()
919
+ if (!scaleX.isFinite() || !scaleY.isFinite() || scaleX <= 0f || scaleY <= 0f) {
920
+ captureInProgress.set(false)
921
+ returnBitmap(bitmap)
922
+ return
923
+ }
924
+ canvas.scale(scaleX, scaleY)
925
+
926
+ decorView.draw(canvas)
927
+
928
+ processingExecutor.submit {
929
+ processCapture(
930
+ bitmap,
931
+ reason,
932
+ sensitiveRects,
933
+ decorView.width,
934
+ decorView.height,
935
+ scanResult,
936
+ hasBlockedSurface
937
+ )
938
+ }
939
+ } catch (e: Exception) {
940
+ Logger.debug("[CaptureEngine] Direct draw failed: ${e.message}")
941
+ captureInProgress.set(false)
942
+ returnBitmap(bitmap)
943
+ }
944
+ }
945
+ } catch (e: Exception) {
946
+ Logger.error("[CaptureEngine] Capture failed", e)
947
+ captureInProgress.set(false)
948
+ }
949
+ }
950
+
951
+ private fun processCapture(
952
+ bitmap: Bitmap,
953
+ reason: String,
954
+ sensitiveRects: List<Rect>,
955
+ rootWidth: Int,
956
+ rootHeight: Int,
957
+ scanResult: ViewHierarchyScanResult?,
958
+ hasBlockedSurface: Boolean
959
+ ) {
960
+ if (isShuttingDown.get()) {
961
+ returnBitmap(bitmap)
962
+ captureInProgress.set(false)
963
+ return
964
+ }
965
+
966
+ val encoder = videoEncoder ?: run {
967
+ returnBitmap(bitmap)
968
+ captureInProgress.set(false)
969
+ return
970
+ }
971
+
972
+ try {
973
+ PerfTiming.time(PerfMetric.FRAME) {
974
+ // NUCLEAR FIX: Disable frame deduplication
975
+ // Previously we would skip frames with matching hashes, but this caused
976
+ // issues with MapView and other GPU-rendered content where the hash might
977
+ // not reflect visual changes. Always process every frame now.
978
+ val now = System.currentTimeMillis()
979
+
980
+ // OPTIMIZATION 2: Lazy privacy masking - only apply if sensitive rects exist
981
+ val shouldMask = privacyMaskTextInputs || privacyMaskCameraViews ||
982
+ privacyMaskWebViews || privacyMaskVideoLayers
983
+ val maskedBitmap = if (sensitiveRects.isNotEmpty() && shouldMask) {
984
+ PrivacyMask.applyMasksToBitmap(bitmap, sensitiveRects, rootWidth, rootHeight)
985
+ } else {
986
+ bitmap // No masking needed - use original bitmap
987
+ }
988
+
989
+ val timestamp = System.currentTimeMillis()
990
+
991
+ // Append frame to video encoder
992
+ val success = encoder.appendFrame(maskedBitmap, timestamp)
993
+
994
+ if (success) {
995
+ Logger.debug("[CaptureEngine] Frame captured ($reason) - ${maskedBitmap.width}x${maskedBitmap.height}")
996
+ handleFrameAppended(
997
+ scanResult = scanResult,
998
+ timestamp = timestamp,
999
+ didRender = true,
1000
+ hasBlockedSurface = hasBlockedSurface,
1001
+ cachedBitmap = maskedBitmap
1002
+ )
1003
+
1004
+ try {
1005
+ onFrameCaptured?.invoke()
1006
+ } catch (_: Exception) {
1007
+ // Best-effort only
1008
+ }
1009
+ }
1010
+
1011
+ captureInProgress.set(false)
1012
+
1013
+ if (!success) {
1014
+ if (maskedBitmap !== bitmap) {
1015
+ maskedBitmap.recycle()
1016
+ }
1017
+ returnBitmap(bitmap)
1018
+ return@time
1019
+ }
1020
+
1021
+ if (maskedBitmap !== bitmap) {
1022
+ returnBitmap(bitmap)
1023
+ }
1024
+ }
1025
+
1026
+ } catch (e: Exception) {
1027
+ Logger.error("[CaptureEngine] Failed to process capture", e)
1028
+ captureInProgress.set(false)
1029
+ returnBitmap(bitmap)
1030
+ }
1031
+ }
1032
+
1033
+ private fun handleFrameAppended(
1034
+ scanResult: ViewHierarchyScanResult?,
1035
+ timestamp: Long,
1036
+ didRender: Boolean,
1037
+ hasBlockedSurface: Boolean,
1038
+ cachedBitmap: Bitmap
1039
+ ) {
1040
+ if (didRender) {
1041
+ captureHeuristics.recordRenderedSignature(
1042
+ scanResult?.layoutSignature,
1043
+ android.os.SystemClock.elapsedRealtime()
1044
+ )
1045
+ updateCachedFrames(cachedBitmap, hasBlockedSurface)
1046
+ }
1047
+
1048
+ lastCaptureTime = timestamp
1049
+ consecutiveCaptureCount++
1050
+ updateFrameRateTracking()
1051
+ if (consecutiveCaptureCount >= Constants.MAX_CONSECUTIVE_CAPTURES) {
1052
+ cooldownUntil = System.currentTimeMillis() +
1053
+ (Constants.CAPTURE_COOLDOWN_SECONDS * 1000).toLong()
1054
+ consecutiveCaptureCount = 0
1055
+ }
1056
+
1057
+ framesSinceSessionStart++
1058
+ framesSinceHierarchy++
1059
+ val layoutSignature = scanResult?.layoutSignature
1060
+ val layoutChanged = layoutSignature != null && layoutSignature != lastSerializedSignature
1061
+ val shouldSerialize = scanResult?.scrollActive != true
1062
+
1063
+ val shouldCaptureHierarchy = if (scanResult == null) {
1064
+ framesSinceHierarchy == 1 || framesSinceHierarchy >= hierarchyCaptureInterval
1065
+ } else {
1066
+ shouldSerialize && (
1067
+ framesSinceHierarchy == 1 ||
1068
+ (layoutChanged && framesSinceHierarchy >= hierarchyCaptureInterval) ||
1069
+ framesSinceHierarchy >= 30
1070
+ )
1071
+ }
1072
+
1073
+ if (shouldCaptureHierarchy) {
1074
+ captureHierarchySnapshot(timestamp, scanResult)
1075
+ framesSinceHierarchy = 0
1076
+ if (layoutSignature != null) {
1077
+ lastSerializedSignature = layoutSignature
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ private fun updateCachedFrames(bitmap: Bitmap, hasBlockedSurface: Boolean) {
1083
+ val toRelease = mutableListOf<Bitmap>()
1084
+ synchronized(cacheLock) {
1085
+ val previousCaptured = lastCapturedBitmap
1086
+ val previousSafe = lastSafeBitmap
1087
+
1088
+ lastCapturedBitmap = bitmap
1089
+ lastCapturedHadBlockedSurface = hasBlockedSurface
1090
+ if (!hasBlockedSurface) {
1091
+ lastSafeBitmap = bitmap
1092
+ }
1093
+
1094
+ if (previousCaptured != null && previousCaptured !== lastCapturedBitmap &&
1095
+ previousCaptured !== lastSafeBitmap) {
1096
+ toRelease.add(previousCaptured)
1097
+ }
1098
+ if (previousSafe != null && previousSafe !== lastCapturedBitmap &&
1099
+ previousSafe !== lastSafeBitmap) {
1100
+ toRelease.add(previousSafe)
1101
+ }
1102
+ }
1103
+ toRelease.forEach { returnBitmap(it) }
1104
+ }
1105
+
1106
+ private fun collectSensitiveRects(scanResult: ViewHierarchyScanResult): List<Rect> {
1107
+ val rects = mutableListOf<Rect>()
1108
+ if (privacyMaskTextInputs) {
1109
+ rects.addAll(scanResult.textInputFrames)
1110
+ }
1111
+ if (privacyMaskWebViews) {
1112
+ rects.addAll(scanResult.webViewFrames)
1113
+ }
1114
+ if (privacyMaskCameraViews) {
1115
+ rects.addAll(scanResult.cameraFrames)
1116
+ }
1117
+ if (privacyMaskVideoLayers) {
1118
+ rects.addAll(scanResult.videoFrames)
1119
+ }
1120
+ return rects
1121
+ }
1122
+
1123
+
1124
+ // Legacy bitmap hash - removed
1125
+
1126
+ /**
1127
+ * OPTIMIZATION 4: Render Server Pre-warming
1128
+ * Performs a dummy render to initialize the graphics subsystem/driver
1129
+ * before the actual recording starts.
1130
+ */
1131
+ private fun prewarmRenderServer() {
1132
+ try {
1133
+ val window = getCurrentWindow() ?: return
1134
+ val bitmap = getBitmap(1, 1)
1135
+ val canvas = Canvas(bitmap)
1136
+ // Draw a tiny part of the view hierarchy to warm up the rendering pipeline
1137
+ // This pays the "first draw" cost (~50-100ms) now instead of during first capture
1138
+ window.decorView.draw(canvas)
1139
+ returnBitmap(bitmap)
1140
+ Logger.debug("[CaptureEngine] Render server pre-warmed")
1141
+ } catch (e: Exception) {
1142
+ Logger.debug("[CaptureEngine] Render server pre-warm failed (non-critical): ${e.message}")
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Capture the current view hierarchy for click/hover maps.
1148
+ */
1149
+ private fun captureHierarchySnapshot(timestamp: Long, scanResult: ViewHierarchyScanResult?) {
1150
+ try {
1151
+ mainHandler.post {
1152
+ try {
1153
+ val window = getCurrentWindow() ?: return@post
1154
+ val serializer = viewSerializer ?: return@post
1155
+
1156
+ val hierarchy = PerfTiming.time(PerfMetric.VIEW_SERIALIZE) {
1157
+ serializer.serializeWindow(window, scanResult, timestamp)
1158
+ }
1159
+
1160
+ if (hierarchy.isNotEmpty()) {
1161
+ val snapshot = hierarchy.toMutableMap()
1162
+ currentScreenName?.let { snapshot["screenName"] = it }
1163
+ if (snapshot["root"] != null && snapshot["rootElement"] == null) {
1164
+ snapshot["rootElement"] = snapshot["root"]
1165
+ }
1166
+
1167
+ synchronized(hierarchySnapshots) {
1168
+ hierarchySnapshots.add(snapshot)
1169
+ }
1170
+
1171
+ Logger.debug("[CaptureEngine] Hierarchy snapshot captured (${hierarchySnapshots.size} accumulated)")
1172
+ } else {
1173
+ Logger.warning("[CaptureEngine] Hierarchy serialization returned empty")
1174
+ }
1175
+ } catch (e: Exception) {
1176
+ Logger.warning("[CaptureEngine] Hierarchy snapshot failed: ${e.message}")
1177
+ }
1178
+ }
1179
+ } catch (e: Exception) {
1180
+ Logger.error("[CaptureEngine] Failed to capture hierarchy snapshot", e)
1181
+ }
1182
+ }
1183
+
1184
+ /**
1185
+ * Upload accumulated hierarchy snapshots.
1186
+ */
1187
+ private fun uploadCurrentHierarchySnapshots() {
1188
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: START (sessionId=$sessionId)")
1189
+
1190
+ val snapshotsToUpload: List<Map<String, Any?>>
1191
+ synchronized(hierarchySnapshots) {
1192
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: hierarchySnapshots.size=${hierarchySnapshots.size}")
1193
+ if (hierarchySnapshots.isEmpty()) {
1194
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: No hierarchy snapshots to upload, returning")
1195
+ return
1196
+ }
1197
+ snapshotsToUpload = hierarchySnapshots.toList()
1198
+ hierarchySnapshots.clear()
1199
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: Copied ${snapshotsToUpload.size} snapshots, cleared buffer")
1200
+ }
1201
+
1202
+ try {
1203
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: Converting ${snapshotsToUpload.size} snapshots to JSON")
1204
+
1205
+ // Convert to JSON
1206
+ val jsonArray = JSONArray()
1207
+ for (snapshot in snapshotsToUpload) {
1208
+ try {
1209
+ jsonArray.put(mapToJson(snapshot))
1210
+ } catch (e: Exception) {
1211
+ Logger.warning("[CaptureEngine] Skipping snapshot JSON entry: ${e.message}")
1212
+ }
1213
+ }
1214
+
1215
+ val jsonData = jsonArray.toString().toByteArray(Charsets.UTF_8)
1216
+ val timestamp = System.currentTimeMillis()
1217
+
1218
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: JSON created, size=${jsonData.size} bytes, timestamp=$timestamp")
1219
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: Calling delegate.onHierarchySnapshotsReady (delegate=${delegate != null})")
1220
+
1221
+ delegate?.onHierarchySnapshotsReady(jsonData, timestamp) ?: run {
1222
+ Logger.error("[CaptureEngine] uploadCurrentHierarchySnapshots: ❌ Delegate is NULL, cannot upload hierarchy!")
1223
+ }
1224
+
1225
+ Logger.debug("[CaptureEngine] uploadCurrentHierarchySnapshots: ✅ Delegate callback completed")
1226
+
1227
+ } catch (e: Exception) {
1228
+ Logger.error("[CaptureEngine] uploadCurrentHierarchySnapshots: ❌ Exception: ${e.message}", e)
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * Convert a map to JSONObject recursively.
1234
+ */
1235
+ @Suppress("UNCHECKED_CAST")
1236
+ private fun mapToJson(map: Map<String, Any?>): JSONObject {
1237
+ val json = JSONObject()
1238
+ for ((key, value) in map) {
1239
+ when (value) {
1240
+ null -> json.put(key, JSONObject.NULL)
1241
+ is Map<*, *> -> json.put(key, mapToJson(value as Map<String, Any?>))
1242
+ is List<*> -> {
1243
+ val arr = JSONArray()
1244
+ for (item in value) {
1245
+ when (item) {
1246
+ is Map<*, *> -> arr.put(mapToJson(item as Map<String, Any?>))
1247
+ else -> arr.put(item)
1248
+ }
1249
+ }
1250
+ json.put(key, arr)
1251
+ }
1252
+ else -> json.put(key, value)
1253
+ }
1254
+ }
1255
+ return json
1256
+ }
1257
+
1258
+ private fun getCurrentWindow(): Window? {
1259
+ return try {
1260
+ (context as? ReactApplicationContext)?.currentActivity?.window
1261
+ } catch (e: Exception) {
1262
+ Logger.error("[CaptureEngine] Failed to get current window", e)
1263
+ null
1264
+ }
1265
+ }
1266
+
1267
+ private fun updatePerformanceLevel() {
1268
+ // Check thermal state first (matching iOS RJPerformanceManager behavior)
1269
+ if (thermalThrottleEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
1270
+ val thermalLevel = getThermalStatus()
1271
+ when (thermalLevel) {
1272
+ PowerManager.THERMAL_STATUS_CRITICAL,
1273
+ PowerManager.THERMAL_STATUS_EMERGENCY,
1274
+ PowerManager.THERMAL_STATUS_SHUTDOWN -> {
1275
+ currentPerformanceLevel = PerformanceLevel.PAUSED
1276
+ return
1277
+ }
1278
+ PowerManager.THERMAL_STATUS_SEVERE -> {
1279
+ currentPerformanceLevel = PerformanceLevel.MINIMAL
1280
+ return
1281
+ }
1282
+ PowerManager.THERMAL_STATUS_MODERATE -> {
1283
+ currentPerformanceLevel = PerformanceLevel.REDUCED
1284
+ return
1285
+ }
1286
+ // THERMAL_STATUS_NONE, THERMAL_STATUS_LIGHT - continue to battery check
1287
+ }
1288
+ }
1289
+
1290
+ // OPTIMIZATION: Battery-aware capture rate adjustment
1291
+ // Reduces capture rate when battery is low to extend battery life
1292
+ currentPerformanceLevel = when {
1293
+ isLowBattery() && batteryAwareEnabled -> {
1294
+ // Below 15% battery: reduce to minimal capture (0.25 FPS effective)
1295
+ if (cachedBatteryLevel < 0.15f) PerformanceLevel.MINIMAL
1296
+ // Below 30% battery: reduce capture rate (0.5 FPS effective)
1297
+ else if (cachedBatteryLevel < 0.30f) PerformanceLevel.REDUCED
1298
+ else PerformanceLevel.NORMAL
1299
+ }
1300
+ else -> PerformanceLevel.NORMAL
1301
+ }
1302
+ }
1303
+
1304
+ /**
1305
+ * Get current thermal status (Android Q+).
1306
+ * Returns THERMAL_STATUS_NONE for older devices.
1307
+ */
1308
+ private fun getThermalStatus(): Int {
1309
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
1310
+ return PowerManager.THERMAL_STATUS_NONE
1311
+ }
1312
+ return try {
1313
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
1314
+ powerManager?.currentThermalStatus ?: PowerManager.THERMAL_STATUS_NONE
1315
+ } catch (e: Exception) {
1316
+ Logger.debug("[CaptureEngine] Error getting thermal status: ${e.message}")
1317
+ PowerManager.THERMAL_STATUS_NONE
1318
+ }
1319
+ }
1320
+
1321
+ private fun isLowBattery(): Boolean {
1322
+ // Cache battery level for 15 seconds to avoid frequent calls
1323
+ val now = System.currentTimeMillis()
1324
+ if (now - lastBatteryCheckTime > 15000) {
1325
+ lastBatteryCheckTime = now
1326
+ try {
1327
+ val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
1328
+ cachedBatteryLevel = batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)?.let {
1329
+ it / 100f
1330
+ } ?: 1.0f
1331
+ } catch (e: Exception) {
1332
+ cachedBatteryLevel = 1.0f
1333
+ }
1334
+ }
1335
+ return cachedBatteryLevel < 0.15f
1336
+ }
1337
+
1338
+ private fun shouldCapture(importance: CaptureImportance): Boolean {
1339
+ val now = System.currentTimeMillis()
1340
+
1341
+ // Always allow critical captures
1342
+ if (importance == CaptureImportance.CRITICAL) {
1343
+ return true
1344
+ }
1345
+
1346
+ // Check minimum interval
1347
+ val elapsed = (now - lastCaptureTime) / 1000.0
1348
+ if (elapsed < minFrameInterval) {
1349
+ return false
1350
+ }
1351
+
1352
+ // Check frames per minute limit
1353
+ if (framesThisMinute >= maxFramesPerMinute) {
1354
+ return importance.value >= CaptureImportance.HIGH.value
1355
+ }
1356
+
1357
+ // Performance level checks
1358
+ return when (currentPerformanceLevel) {
1359
+ PerformanceLevel.PAUSED -> importance == CaptureImportance.CRITICAL
1360
+ PerformanceLevel.MINIMAL -> importance.value >= CaptureImportance.HIGH.value
1361
+ PerformanceLevel.REDUCED -> importance.value >= CaptureImportance.MEDIUM.value
1362
+ PerformanceLevel.NORMAL -> true
1363
+ }
1364
+ }
1365
+
1366
+ // Legacy methods removed: checkLayoutSignature, isAnyGestureActiveInView, hasSpecialViewsInView
1367
+
1368
+ private fun updateFrameRateTracking() {
1369
+ val now = System.currentTimeMillis()
1370
+ if (now - minuteStartTime >= 60_000) {
1371
+ minuteStartTime = now
1372
+ framesThisMinute = 0
1373
+ }
1374
+ framesThisMinute++
1375
+ }
1376
+
1377
+ /**
1378
+ * Detect whether we need PixelCopy to capture GPU-backed content.
1379
+ * SurfaceView/TextureView/GLSurfaceView (MapView, video, camera) require PixelCopy.
1380
+ * Falls back to direct draw otherwise for better CPU performance.
1381
+ */
1382
+ private fun requiresPixelCopyCapture(view: View, depth: Int = 0): Boolean {
1383
+ if (depth > 12) return false
1384
+
1385
+ if (view is SurfaceView || view is TextureView || view is GLSurfaceView) {
1386
+ return true
1387
+ }
1388
+
1389
+ val className = view.javaClass.name.lowercase()
1390
+ val simpleName = view.javaClass.simpleName.lowercase()
1391
+
1392
+ if (className.contains("mapview") || simpleName.contains("mapview") ||
1393
+ className.contains("airmap") || simpleName.contains("airmap") ||
1394
+ className.contains("googlemap") || simpleName.contains("googlemap") ||
1395
+ className.contains("cameraview") || simpleName.contains("cameraview") ||
1396
+ className.contains("previewview") || simpleName.contains("previewview") ||
1397
+ className.contains("playerview") || simpleName.contains("playerview") ||
1398
+ className.contains("exoplayer") || simpleName.contains("exoplayer") ||
1399
+ className.contains("videoview") || simpleName.contains("videoview")) {
1400
+ return true
1401
+ }
1402
+
1403
+ if (view is ViewGroup) {
1404
+ for (i in 0 until view.childCount) {
1405
+ if (requiresPixelCopyCapture(view.getChildAt(i), depth + 1)) {
1406
+ return true
1407
+ }
1408
+ }
1409
+ }
1410
+
1411
+ return false
1412
+ }
1413
+
1414
+ // Legacy Detection Logic (Gesture/Special Views) Removed
1415
+
1416
+ private fun startCaptureTimer() {
1417
+ stopCaptureTimer()
1418
+ captureRunnable = object : Runnable {
1419
+ override fun run() {
1420
+ if (_isRecording) {
1421
+ captureFrame(CaptureImportance.LOW, "timer")
1422
+ mainHandler.postDelayed(this, captureIntervalMs)
1423
+ }
1424
+ }
1425
+ }
1426
+ mainHandler.postDelayed(captureRunnable!!, captureIntervalMs)
1427
+ }
1428
+
1429
+ private fun stopCaptureTimer() {
1430
+ captureRunnable?.let { mainHandler.removeCallbacks(it) }
1431
+ captureRunnable = null
1432
+ }
1433
+
1434
+ private fun getBitmap(width: Int, height: Int): Bitmap {
1435
+ // OPTIMIZATION: Improved bitmap pool with size matching
1436
+ val pooled = bitmapPool.poll()
1437
+ if (pooled != null && !pooled.isRecycled) {
1438
+ // Check if pooled bitmap can be reused (same or larger size)
1439
+ if (pooled.width >= width && pooled.height >= height) {
1440
+ // Create a subset if the pooled bitmap is larger
1441
+ if (pooled.width == width && pooled.height == height) {
1442
+ return pooled
1443
+ } else {
1444
+ // Return oversized bitmap to pool and create exact size
1445
+ bitmapPool.offer(pooled)
1446
+ }
1447
+ } else {
1448
+ // Pooled bitmap too small, recycle it
1449
+ pooled.recycle()
1450
+ }
1451
+ }
1452
+
1453
+ return try {
1454
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
1455
+ } catch (e: OutOfMemoryError) {
1456
+ // Clear pool and try again
1457
+ clearBitmapPool()
1458
+ System.gc() // Suggest garbage collection
1459
+ Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) // Fallback to lower quality
1460
+ }
1461
+ }
1462
+
1463
+ /**
1464
+ * OPTIMIZATION: Aggressive bitmap pool management to reduce GC pressure.
1465
+ * Recycles bitmaps immediately when pool is full instead of keeping them in memory.
1466
+ */
1467
+ private fun returnBitmap(bitmap: Bitmap) {
1468
+ if (bitmap.isRecycled) return
1469
+
1470
+ // OPTIMIZATION: Stricter pool size limits based on memory pressure
1471
+ val maxPoolSize = when (currentPerformanceLevel) {
1472
+ PerformanceLevel.MINIMAL, PerformanceLevel.PAUSED -> 2 // Minimal pool under pressure
1473
+ PerformanceLevel.REDUCED -> 4 // Reduced pool
1474
+ PerformanceLevel.NORMAL -> MAX_POOL_SIZE // Full pool
1475
+ }
1476
+
1477
+ if (bitmapPool.size < maxPoolSize) {
1478
+ // Only pool bitmaps of reasonable size to avoid memory bloat
1479
+ val bitmapBytes = bitmap.byteCount
1480
+ if (bitmapBytes < 2 * 1024 * 1024) { // Max 2MB per bitmap in pool
1481
+ bitmapPool.offer(bitmap)
1482
+ return
1483
+ }
1484
+ }
1485
+
1486
+ // Pool full or bitmap too large - recycle immediately
1487
+ bitmap.recycle()
1488
+ }
1489
+
1490
+ /**
1491
+ * OPTIMIZATION: Clear bitmap pool and suggest GC.
1492
+ * Called during memory pressure or cleanup.
1493
+ */
1494
+ private fun clearBitmapPool() {
1495
+ var recycledCount = 0
1496
+ while (bitmapPool.isNotEmpty()) {
1497
+ bitmapPool.poll()?.recycle()
1498
+ recycledCount++
1499
+ }
1500
+ if (recycledCount > 0) {
1501
+ Logger.debug("[CaptureEngine] Recycled $recycledCount pooled bitmaps")
1502
+ // Suggest GC after clearing pool to reclaim memory quickly
1503
+ System.gc()
1504
+ }
1505
+ }
1506
+
1507
+ private fun resetCachedFrames() {
1508
+ val toRelease = mutableListOf<Bitmap>()
1509
+ synchronized(cacheLock) {
1510
+ lastCapturedBitmap?.let { toRelease.add(it) }
1511
+ lastSafeBitmap?.let { safe ->
1512
+ if (safe !== lastCapturedBitmap) {
1513
+ toRelease.add(safe)
1514
+ }
1515
+ }
1516
+ lastCapturedBitmap = null
1517
+ lastSafeBitmap = null
1518
+ lastCapturedHadBlockedSurface = false
1519
+ }
1520
+ toRelease.forEach { returnBitmap(it) }
1521
+ }
1522
+
1523
+ private fun cleanupOldSegments() {
1524
+ scope.launch {
1525
+ try {
1526
+ val cutoffTime = System.currentTimeMillis() - (24 * 60 * 60 * 1000) // 24 hours ago
1527
+ segmentDir.listFiles()?.forEach { file ->
1528
+ if (file.isFile && file.name.endsWith(".mp4") && file.lastModified() < cutoffTime) {
1529
+ file.delete()
1530
+ Logger.debug("[CaptureEngine] Cleaned up old segment: ${file.name}")
1531
+ }
1532
+ }
1533
+ } catch (e: Exception) {
1534
+ Logger.debug("[CaptureEngine] Error cleaning old segments: ${e.message}")
1535
+ }
1536
+ }
1537
+ }
1538
+
1539
+ fun shutdown() {
1540
+ isShuttingDown.set(true)
1541
+ stopSession()
1542
+ processingExecutor.shutdown()
1543
+ scope.cancel()
1544
+ }
1545
+
1546
+ private companion object {
1547
+ private const val DEFENSIVE_CAPTURE_DELAY_NAVIGATION_MS = 200L
1548
+ private const val DEFENSIVE_CAPTURE_DELAY_INTERACTION_MS = 150L
1549
+ private const val DEFENSIVE_CAPTURE_DELAY_SCROLL_MS = 200L
1550
+ private const val DEFENSIVE_CAPTURE_DELAY_MAP_MS = 550L
1551
+ private const val MAP_PRESENCE_WINDOW_MS = 2000L
1552
+ }
1553
+ }