@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.
- package/README.md +29 -0
- 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 +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -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 +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -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 +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- 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
|
+
}
|