@rejourneyco/react-native 1.0.7

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 (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ package com.rejourney.recording
18
+
19
+ import android.app.Activity
20
+ import android.content.Context
21
+ import android.graphics.Rect
22
+ import android.os.SystemClock
23
+ import android.view.View
24
+ import android.view.ViewGroup
25
+ import android.widget.*
26
+ import java.lang.ref.WeakReference
27
+
28
+ /**
29
+ * View hierarchy scanning and serialization
30
+ * Android implementation aligned with iOS ViewHierarchyScanner.swift
31
+ */
32
+ class ViewHierarchyScanner private constructor() {
33
+
34
+ companion object {
35
+ @Volatile
36
+ private var instance: ViewHierarchyScanner? = null
37
+
38
+ val shared: ViewHierarchyScanner
39
+ get() = instance ?: synchronized(this) {
40
+ instance ?: ViewHierarchyScanner().also { instance = it }
41
+ }
42
+ }
43
+
44
+ var maxDepth: Int = 12
45
+ var includeTextContent: Boolean = true
46
+ var includeVisualProperties: Boolean = true
47
+
48
+ private val timeBudgetMs: Long = 16 // 16ms to stay under one frame
49
+
50
+ private var currentActivity: WeakReference<Activity>? = null
51
+
52
+ fun setCurrentActivity(activity: Activity?) {
53
+ currentActivity = if (activity != null) WeakReference(activity) else null
54
+ }
55
+
56
+ fun captureHierarchy(): Map<String, Any>? {
57
+ val activity = currentActivity?.get() ?: return null
58
+ val decorView = activity.window?.decorView ?: return null
59
+ return serializeWindow(decorView, activity)
60
+ }
61
+
62
+ fun serializeWindow(window: View, activity: Activity): Map<String, Any> {
63
+ val ts = System.currentTimeMillis()
64
+ val displayMetrics = activity.resources.displayMetrics
65
+ val bounds = Rect().also { window.getWindowVisibleDisplayFrame(it) }
66
+ val startTime = SystemClock.elapsedRealtime()
67
+
68
+ val root = serializeView(window, 0, startTime) ?: emptyMap()
69
+
70
+ val result = mutableMapOf<String, Any>(
71
+ "timestamp" to ts,
72
+ "screen" to mapOf(
73
+ "width" to bounds.width(),
74
+ "height" to bounds.height(),
75
+ "scale" to displayMetrics.density
76
+ ),
77
+ "root" to root
78
+ )
79
+
80
+ ReplayOrchestrator.shared?.currentScreenName?.let {
81
+ result["screenName"] = it
82
+ }
83
+
84
+ return result
85
+ }
86
+
87
+ private fun serializeView(view: View, depth: Int, startTime: Long): Map<String, Any>? {
88
+ if (depth > maxDepth) return null
89
+ if (SystemClock.elapsedRealtime() - startTime > timeBudgetMs) {
90
+ return mapOf("type" to view.javaClass.simpleName, "bailout" to true)
91
+ }
92
+ if (depth > 0 && (!view.isShown || view.alpha <= 0.01f || view.width <= 0 || view.height <= 0)) {
93
+ return null
94
+ }
95
+
96
+ val node = mutableMapOf<String, Any>()
97
+ node["type"] = view.javaClass.simpleName
98
+
99
+ val location = IntArray(2)
100
+ view.getLocationInWindow(location)
101
+ node["frame"] = mapOf(
102
+ "x" to location[0],
103
+ "y" to location[1],
104
+ "w" to view.width,
105
+ "h" to view.height
106
+ )
107
+
108
+ if (!view.isShown) node["hidden"] = true
109
+ if (view.alpha < 1.0f) node["alpha"] = view.alpha
110
+
111
+ // Get accessibility identifier / test ID
112
+ view.contentDescription?.toString()?.takeIf { it.isNotEmpty() }?.let {
113
+ node["testID"] = it
114
+ }
115
+
116
+ // Check for React Native nativeID
117
+ try {
118
+ val nativeId = view.getTag(com.facebook.react.R.id.view_tag_native_id) as? String
119
+ if (!nativeId.isNullOrEmpty()) {
120
+ node["testID"] = nativeId
121
+ }
122
+ } catch (_: Exception) { }
123
+
124
+ if (isSensitive(view)) node["masked"] = true
125
+
126
+ if (includeVisualProperties) {
127
+ view.background?.let { bg ->
128
+ // Try to get background color
129
+ try {
130
+ val colorDrawable = bg as? android.graphics.drawable.ColorDrawable
131
+ colorDrawable?.color?.let { color ->
132
+ node["bg"] = String.format("#%06X", 0xFFFFFF and color)
133
+ }
134
+ } catch (_: Exception) { }
135
+ }
136
+ }
137
+
138
+ if (includeTextContent) {
139
+ when (view) {
140
+ is TextView -> {
141
+ val text = view.text?.toString() ?: ""
142
+ node["text"] = maskText(text)
143
+ node["textLength"] = text.length
144
+
145
+ if (view is EditText) {
146
+ node["text"] = "***"
147
+ view.hint?.toString()?.let { node["placeholder"] = it }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ if (isInteractive(view)) {
154
+ node["interactive"] = true
155
+
156
+ when (view) {
157
+ is Button -> {
158
+ node["buttonTitle"] = view.text?.toString() ?: ""
159
+ node["enabled"] = view.isEnabled
160
+ }
161
+ is CompoundButton -> {
162
+ node["checked"] = view.isChecked
163
+ node["enabled"] = view.isEnabled
164
+ }
165
+ }
166
+
167
+ if (view.isEnabled) {
168
+ node["enabled"] = true
169
+ } else {
170
+ node["enabled"] = false
171
+ }
172
+ }
173
+
174
+ if (view is ScrollView || view is HorizontalScrollView) {
175
+ node["scrollEnabled"] = true
176
+ node["contentOffset"] = mapOf<String, Any>(
177
+ "x" to ((view as? HorizontalScrollView)?.scrollX ?: (view as? ScrollView)?.scrollX ?: 0),
178
+ "y" to ((view as? HorizontalScrollView)?.scrollY ?: (view as? ScrollView)?.scrollY ?: 0)
179
+ )
180
+ }
181
+
182
+ if (view is ImageView) {
183
+ node["hasImage"] = view.drawable != null
184
+ }
185
+
186
+ // Process children
187
+ if (view is ViewGroup) {
188
+ val children = mutableListOf<Map<String, Any>>()
189
+ for (i in 0 until view.childCount) {
190
+ val child = view.getChildAt(i)
191
+ if (child.isShown && child.alpha > 0.01f) {
192
+ serializeView(child, depth + 1, startTime)?.let {
193
+ children.add(it)
194
+ }
195
+ }
196
+ }
197
+ if (children.isNotEmpty()) {
198
+ node["children"] = children
199
+ }
200
+ }
201
+
202
+ return node
203
+ }
204
+
205
+ private fun isSensitive(view: View): Boolean {
206
+ if (view is EditText) {
207
+ val inputType = view.inputType
208
+ // Check for password input types
209
+ if (inputType and android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD != 0 ||
210
+ inputType and android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD != 0 ||
211
+ inputType and android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD != 0) {
212
+ return true
213
+ }
214
+ }
215
+ return false
216
+ }
217
+
218
+ private fun isInteractive(view: View): Boolean {
219
+ return view is Button ||
220
+ view is EditText ||
221
+ view is CheckBox ||
222
+ view is RadioButton ||
223
+ view is Switch ||
224
+ view is SeekBar ||
225
+ view is Spinner ||
226
+ view.isClickable
227
+ }
228
+
229
+ private fun maskText(text: String): String {
230
+ return if (text.length > 100) text.take(100) + "..." else text
231
+ }
232
+ }
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ package com.rejourney.recording
18
+
19
+ import android.app.Activity
20
+ import android.content.Context
21
+ import android.graphics.Bitmap
22
+ import android.graphics.Canvas
23
+ import android.graphics.Color
24
+ import android.graphics.Paint
25
+ import android.graphics.Rect
26
+ import android.os.Handler
27
+ import android.os.Looper
28
+ import android.os.SystemClock
29
+ import android.view.View
30
+ import android.view.WindowManager
31
+ import com.rejourney.engine.DiagnosticLog
32
+ import com.rejourney.utility.gzipCompress
33
+ import java.io.ByteArrayOutputStream
34
+ import java.io.File
35
+ import java.lang.ref.WeakReference
36
+ import java.util.concurrent.CopyOnWriteArrayList
37
+ import java.util.concurrent.Executors
38
+ import java.util.concurrent.atomic.AtomicBoolean
39
+ import java.util.concurrent.atomic.AtomicLong
40
+ import java.util.concurrent.locks.ReentrantLock
41
+ import kotlin.concurrent.withLock
42
+
43
+ /**
44
+ * Screen capture and frame packaging
45
+ * Android implementation aligned with iOS VisualCapture.swift
46
+ */
47
+ class VisualCapture private constructor(private val context: Context) {
48
+
49
+ companion object {
50
+ @Volatile
51
+ private var instance: VisualCapture? = null
52
+
53
+ fun getInstance(context: Context): VisualCapture {
54
+ return instance ?: synchronized(this) {
55
+ instance ?: VisualCapture(context.applicationContext).also { instance = it }
56
+ }
57
+ }
58
+
59
+ val shared: VisualCapture?
60
+ get() = instance
61
+ }
62
+
63
+ var snapshotInterval: Double = 0.5
64
+ var quality: Float = 0.5f
65
+
66
+ val isCapturing: Boolean
67
+ get() = stateMachine.currentState == CaptureState.CAPTURING
68
+
69
+ private val stateMachine = CaptureStateMachine()
70
+ private val screenshots = CopyOnWriteArrayList<Pair<ByteArray, Long>>()
71
+ private val stateLock = ReentrantLock()
72
+ private var captureRunnable: Runnable? = null
73
+ private val frameCounter = AtomicLong(0)
74
+ private var sessionEpoch: Long = 0
75
+ private val redactionMask = RedactionMask()
76
+ private var deferredUntilCommit = false
77
+ private var framesDiskPath: File? = null
78
+ private var currentSessionId: String? = null
79
+
80
+ private val mainHandler = Handler(Looper.getMainLooper())
81
+
82
+ // Use single thread executor for encoding (industry standard)
83
+ private val encodeExecutor = Executors.newSingleThreadExecutor()
84
+
85
+ // Backpressure limits to prevent stutter
86
+ private val maxPendingBatches = 50
87
+ private val maxBufferedScreenshots = 500
88
+
89
+ // Industry standard batch size (20 frames per batch)
90
+ private val batchSize = 20
91
+
92
+ // Current activity reference
93
+ private var currentActivity: WeakReference<Activity>? = null
94
+
95
+ fun setCurrentActivity(activity: Activity?) {
96
+ currentActivity = if (activity != null) WeakReference(activity) else null
97
+ DiagnosticLog.notice("[VisualCapture] setCurrentActivity: ${activity?.javaClass?.simpleName ?: "null"}")
98
+ }
99
+
100
+ fun beginCapture(sessionOrigin: Long) {
101
+ DiagnosticLog.notice("[VisualCapture] beginCapture called, currentActivity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}, state=${stateMachine.currentState}")
102
+ DiagnosticLog.trace("[VisualCapture] beginCapture called, currentActivity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}")
103
+ if (!stateMachine.transition(CaptureState.CAPTURING)) {
104
+ DiagnosticLog.notice("[VisualCapture] beginCapture REJECTED - state transition failed from ${stateMachine.currentState}")
105
+ DiagnosticLog.trace("[VisualCapture] beginCapture failed - state transition rejected")
106
+ return
107
+ }
108
+ sessionEpoch = sessionOrigin
109
+ frameCounter.set(0)
110
+
111
+ // Set up disk persistence for frames
112
+ currentSessionId = TelemetryPipeline.shared?.currentReplayId
113
+ currentSessionId?.let { sid ->
114
+ framesDiskPath = File(context.cacheDir, "rj_pending/$sid/frames").also {
115
+ it.mkdirs()
116
+ }
117
+ }
118
+
119
+ DiagnosticLog.notice("[VisualCapture] Starting capture timer with interval=${snapshotInterval}s")
120
+ DiagnosticLog.trace("[VisualCapture] Starting capture timer with interval=${snapshotInterval}s")
121
+ startCaptureTimer()
122
+ }
123
+
124
+ fun halt() {
125
+ if (!stateMachine.transition(CaptureState.HALTED)) return
126
+ stopCaptureTimer()
127
+
128
+ // Flush any remaining frames to disk before halting
129
+ flushBufferToDisk()
130
+ flushBuffer()
131
+
132
+ stateLock.withLock {
133
+ screenshots.clear()
134
+ }
135
+ }
136
+
137
+ fun flushToDisk() {
138
+ flushBufferToDisk()
139
+ }
140
+
141
+ fun activateDeferredMode() {
142
+ deferredUntilCommit = true
143
+ }
144
+
145
+ fun commitDeferredData() {
146
+ deferredUntilCommit = false
147
+ flushBuffer()
148
+ }
149
+
150
+ fun registerRedaction(view: View) {
151
+ redactionMask.add(view)
152
+ }
153
+
154
+ fun unregisterRedaction(view: View) {
155
+ redactionMask.remove(view)
156
+ }
157
+
158
+ fun configure(snapshotInterval: Double, jpegQuality: Double) {
159
+ this.snapshotInterval = snapshotInterval
160
+ this.quality = jpegQuality.toFloat()
161
+ if (stateMachine.currentState == CaptureState.CAPTURING) {
162
+ stopCaptureTimer()
163
+ startCaptureTimer()
164
+ }
165
+ }
166
+
167
+ fun snapshotNow() {
168
+ mainHandler.post { captureFrame() }
169
+ }
170
+
171
+ private fun startCaptureTimer() {
172
+ stopCaptureTimer()
173
+ captureRunnable = object : Runnable {
174
+ override fun run() {
175
+ captureFrame()
176
+ mainHandler.postDelayed(this, (snapshotInterval * 1000).toLong())
177
+ }
178
+ }
179
+ mainHandler.postDelayed(captureRunnable!!, (snapshotInterval * 1000).toLong())
180
+ }
181
+
182
+ private fun stopCaptureTimer() {
183
+ captureRunnable?.let { mainHandler.removeCallbacks(it) }
184
+ captureRunnable = null
185
+ }
186
+
187
+ private fun captureFrame() {
188
+ val currentFrameNum = frameCounter.get()
189
+ // Log first 3 frames at notice level
190
+ if (currentFrameNum < 3) {
191
+ DiagnosticLog.notice("[VisualCapture] captureFrame #$currentFrameNum, state=${stateMachine.currentState}, activity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}")
192
+ }
193
+
194
+ if (stateMachine.currentState != CaptureState.CAPTURING) {
195
+ DiagnosticLog.notice("[VisualCapture] captureFrame skipped - state=${stateMachine.currentState}")
196
+ DiagnosticLog.trace("[VisualCapture] captureFrame skipped - state=${stateMachine.currentState}")
197
+ return
198
+ }
199
+
200
+ val activity = currentActivity?.get()
201
+ if (activity == null) {
202
+ if (currentFrameNum < 3) {
203
+ DiagnosticLog.notice("[VisualCapture] captureFrame skipped - NO ACTIVITY")
204
+ }
205
+ DiagnosticLog.trace("[VisualCapture] captureFrame skipped - no activity")
206
+ return
207
+ }
208
+
209
+ val frameStart = SystemClock.elapsedRealtime()
210
+
211
+ try {
212
+ val decorView = activity.window?.decorView ?: return
213
+ val bounds = Rect()
214
+ decorView.getWindowVisibleDisplayFrame(bounds)
215
+
216
+ if (bounds.width() <= 0 || bounds.height() <= 0) return
217
+
218
+ val redactRects = redactionMask.computeRects()
219
+
220
+ // Use lower scale to reduce encoding time significantly
221
+ val screenScale = 1.25f
222
+ val scaledWidth = (bounds.width() / screenScale).toInt()
223
+ val scaledHeight = (bounds.height() / screenScale).toInt()
224
+
225
+ val bitmap = Bitmap.createBitmap(scaledWidth, scaledHeight, Bitmap.Config.ARGB_8888)
226
+ val canvas = Canvas(bitmap)
227
+ canvas.scale(1f / screenScale, 1f / screenScale)
228
+
229
+ decorView.draw(canvas)
230
+
231
+ // Apply redactions
232
+ if (redactRects.isNotEmpty()) {
233
+ val paint = Paint().apply {
234
+ color = Color.BLACK
235
+ style = Paint.Style.FILL
236
+ }
237
+ for (rect in redactRects) {
238
+ if (rect.width() > 0 && rect.height() > 0) {
239
+ canvas.drawRect(
240
+ rect.left / screenScale,
241
+ rect.top / screenScale,
242
+ rect.right / screenScale,
243
+ rect.bottom / screenScale,
244
+ paint
245
+ )
246
+ }
247
+ }
248
+ }
249
+
250
+ // Compress to JPEG
251
+ val stream = ByteArrayOutputStream()
252
+ bitmap.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt(), stream)
253
+ bitmap.recycle()
254
+
255
+ val data = stream.toByteArray()
256
+ val captureTs = System.currentTimeMillis()
257
+ val frameNum = frameCounter.incrementAndGet()
258
+
259
+ // Log first frame and every 30 frames
260
+ if (frameNum == 1L) {
261
+ DiagnosticLog.notice("[VisualCapture] First frame captured! size=${data.size} bytes")
262
+ }
263
+ if (frameNum % 30 == 0L) {
264
+ val frameDurationMs = (SystemClock.elapsedRealtime() - frameStart).toDouble()
265
+ val isMainThread = Looper.myLooper() == Looper.getMainLooper()
266
+ DiagnosticLog.perfFrame("screenshot", frameDurationMs, frameNum.toInt(), isMainThread)
267
+ }
268
+
269
+ // Store in buffer
270
+ stateLock.withLock {
271
+ screenshots.add(Pair(data, captureTs))
272
+ enforceScreenshotCaps()
273
+ val shouldSend = !deferredUntilCommit && screenshots.size >= batchSize
274
+
275
+ if (shouldSend) {
276
+ sendScreenshots()
277
+ }
278
+ }
279
+
280
+ } catch (e: Exception) {
281
+ DiagnosticLog.fault("Frame capture failed: ${e.message}")
282
+ }
283
+ }
284
+
285
+ private fun enforceScreenshotCaps() {
286
+ while (screenshots.size > maxBufferedScreenshots) {
287
+ screenshots.removeAt(0)
288
+ }
289
+ }
290
+
291
+ private fun sendScreenshots() {
292
+ // Check backpressure
293
+ // Copy and clear under lock
294
+ val images = stateLock.withLock {
295
+ val copy = screenshots.toList()
296
+ screenshots.clear()
297
+ copy
298
+ }
299
+
300
+ if (images.isEmpty()) {
301
+ DiagnosticLog.trace("[VisualCapture] sendScreenshots: no images to send")
302
+ return
303
+ }
304
+
305
+ DiagnosticLog.notice("[VisualCapture] sendScreenshots: sending ${images.size} frames")
306
+
307
+ // All heavy work happens in background
308
+ encodeExecutor.execute {
309
+ packageAndShip(images, sessionEpoch)
310
+ }
311
+ }
312
+
313
+ private fun packageAndShip(images: List<Pair<ByteArray, Long>>, sessionEpoch: Long) {
314
+ val batchStart = SystemClock.elapsedRealtime()
315
+
316
+ val bundle = packageFrameBundle(images, sessionEpoch) ?: return
317
+
318
+ val rid = TelemetryPipeline.shared?.currentReplayId ?: "unknown"
319
+ val endTs = images.lastOrNull()?.second ?: sessionEpoch
320
+ val fname = "$rid-$endTs.tar.gz"
321
+
322
+ val packDurationMs = (SystemClock.elapsedRealtime() - batchStart).toDouble()
323
+ val isMainThread = Looper.myLooper() == Looper.getMainLooper()
324
+ DiagnosticLog.perfBatch("package-frames", images.size, packDurationMs, isMainThread)
325
+
326
+ TelemetryPipeline.shared?.submitFrameBundle(
327
+ payload = bundle,
328
+ filename = fname,
329
+ startMs = images.firstOrNull()?.second ?: sessionEpoch,
330
+ endMs = endTs,
331
+ frameCount = images.size
332
+ )
333
+ }
334
+
335
+ private fun packageFrameBundle(images: List<Pair<ByteArray, Long>>, sessionEpoch: Long): ByteArray? {
336
+ // Create simple tar-like format and gzip it
337
+ val tarStream = ByteArrayOutputStream()
338
+
339
+ for ((jpeg, timestamp) in images) {
340
+ // Simple frame header: timestamp (8 bytes) + size (4 bytes) + data
341
+ val ts = timestamp - sessionEpoch
342
+ tarStream.write(longToBytes(ts))
343
+ tarStream.write(intToBytes(jpeg.size))
344
+ tarStream.write(jpeg)
345
+ }
346
+
347
+ return tarStream.toByteArray().gzipCompress()
348
+ }
349
+
350
+ private fun longToBytes(value: Long): ByteArray {
351
+ return ByteArray(8) { i -> (value shr (56 - 8 * i)).toByte() }
352
+ }
353
+
354
+ private fun intToBytes(value: Int): ByteArray {
355
+ return ByteArray(4) { i -> (value shr (24 - 8 * i)).toByte() }
356
+ }
357
+
358
+ private fun flushBufferToDisk() {
359
+ val frames = stateLock.withLock { screenshots.toList() }
360
+
361
+ val path = framesDiskPath ?: return
362
+
363
+ for ((jpeg, timestamp) in frames) {
364
+ val framePath = File(path, "$timestamp.jpeg")
365
+ if (!framePath.exists()) {
366
+ try {
367
+ framePath.writeBytes(jpeg)
368
+ } catch (_: Exception) { }
369
+ }
370
+ }
371
+ }
372
+
373
+ private fun flushBuffer() {
374
+ sendScreenshots()
375
+ }
376
+
377
+ fun uploadPendingFrames(sessionId: String) {
378
+ val framesPath = File(context.cacheDir, "rj_pending/$sessionId/frames")
379
+
380
+ if (!framesPath.exists()) return
381
+
382
+ val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?: return
383
+
384
+ val frames = mutableListOf<Pair<ByteArray, Long>>()
385
+ for (file in frameFiles) {
386
+ if (file.extension != "jpeg") continue
387
+ val data = try { file.readBytes() } catch (_: Exception) { continue }
388
+ val ts = file.nameWithoutExtension.toLongOrNull() ?: continue
389
+ frames.add(Pair(data, ts))
390
+ }
391
+
392
+ if (frames.isEmpty()) return
393
+
394
+ val bundle = packageFrameBundle(frames, frames.first().second) ?: return
395
+
396
+ SegmentDispatcher.shared.transmitFrameBundle(
397
+ payload = bundle,
398
+ startMs = frames.first().second,
399
+ endMs = frames.last().second,
400
+ frameCount = frames.size
401
+ ) { ok ->
402
+ if (ok) {
403
+ // Clean up files on success
404
+ frameFiles.forEach { it.delete() }
405
+ framesPath.delete()
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ private enum class CaptureState {
412
+ IDLE,
413
+ CAPTURING,
414
+ HALTED
415
+ }
416
+
417
+ private class CaptureStateMachine {
418
+ var currentState: CaptureState = CaptureState.IDLE
419
+ private set
420
+
421
+ private val lock = ReentrantLock()
422
+
423
+ fun transition(to: CaptureState): Boolean {
424
+ lock.withLock {
425
+ val allowed = when (currentState) {
426
+ CaptureState.IDLE -> to == CaptureState.CAPTURING
427
+ CaptureState.CAPTURING -> to == CaptureState.HALTED
428
+ CaptureState.HALTED -> to == CaptureState.IDLE || to == CaptureState.CAPTURING
429
+ }
430
+ if (allowed) {
431
+ currentState = to
432
+ }
433
+ return allowed
434
+ }
435
+ }
436
+ }
437
+
438
+ private class RedactionMask {
439
+ private val views = CopyOnWriteArrayList<WeakReference<View>>()
440
+
441
+ fun add(view: View) {
442
+ views.add(WeakReference(view))
443
+ }
444
+
445
+ fun remove(view: View) {
446
+ views.removeIf { it.get() === view || it.get() == null }
447
+ }
448
+
449
+ fun computeRects(): List<Rect> {
450
+ val rects = mutableListOf<Rect>()
451
+ views.removeIf { it.get() == null }
452
+
453
+ for (ref in views) {
454
+ val view = ref.get() ?: continue
455
+ if (!view.isShown) continue
456
+
457
+ val location = IntArray(2)
458
+ view.getLocationOnScreen(location)
459
+
460
+ val rect = Rect(
461
+ location[0],
462
+ location[1],
463
+ location[0] + view.width,
464
+ location[1] + view.height
465
+ )
466
+
467
+ if (rect.width() > 0 && rect.height() > 0) {
468
+ rects.add(rect)
469
+ }
470
+ }
471
+
472
+ return rects
473
+ }
474
+ }