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