@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,633 @@
|
|
|
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.content.Context
|
|
20
|
+
import android.os.Build
|
|
21
|
+
import android.os.Handler
|
|
22
|
+
import android.os.Looper
|
|
23
|
+
import com.rejourney.engine.DiagnosticLog
|
|
24
|
+
import com.rejourney.engine.DeviceRegistrar
|
|
25
|
+
import com.rejourney.utility.gzipCompress
|
|
26
|
+
import org.json.JSONArray
|
|
27
|
+
import org.json.JSONObject
|
|
28
|
+
import java.util.*
|
|
29
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
30
|
+
import java.util.concurrent.Executors
|
|
31
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
32
|
+
import kotlin.concurrent.withLock
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Event pipeline for telemetry collection and upload
|
|
36
|
+
* Android implementation aligned with iOS TelemetryPipeline.swift
|
|
37
|
+
*/
|
|
38
|
+
class TelemetryPipeline private constructor(private val context: Context) {
|
|
39
|
+
|
|
40
|
+
companion object {
|
|
41
|
+
@Volatile
|
|
42
|
+
private var instance: TelemetryPipeline? = null
|
|
43
|
+
|
|
44
|
+
fun getInstance(context: Context): TelemetryPipeline {
|
|
45
|
+
return instance ?: synchronized(this) {
|
|
46
|
+
instance ?: TelemetryPipeline(context.applicationContext).also { instance = it }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
val shared: TelemetryPipeline?
|
|
51
|
+
get() = instance
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
var endpoint: String = "https://api.rejourney.co"
|
|
55
|
+
set(value) {
|
|
56
|
+
field = value
|
|
57
|
+
SegmentDispatcher.shared.endpoint = value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var currentReplayId: String? = null
|
|
61
|
+
set(value) {
|
|
62
|
+
field = value
|
|
63
|
+
SegmentDispatcher.shared.currentReplayId = value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var credential: String? = null
|
|
67
|
+
set(value) {
|
|
68
|
+
field = value
|
|
69
|
+
SegmentDispatcher.shared.credential = value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
var apiToken: String? = null
|
|
73
|
+
set(value) {
|
|
74
|
+
field = value
|
|
75
|
+
SegmentDispatcher.shared.apiToken = value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var projectId: String? = null
|
|
79
|
+
set(value) {
|
|
80
|
+
field = value
|
|
81
|
+
SegmentDispatcher.shared.projectId = value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// SDK's sampling decision for server-side enforcement
|
|
85
|
+
var isSampledIn: Boolean = true
|
|
86
|
+
set(value) {
|
|
87
|
+
field = value
|
|
88
|
+
SegmentDispatcher.shared.isSampledIn = value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Event ring buffer
|
|
92
|
+
private val eventRing = EventRingBuffer(5000)
|
|
93
|
+
private val frameQueue = FrameBundleQueue(200)
|
|
94
|
+
private var deferredMode = false
|
|
95
|
+
private var batchSeq = 0
|
|
96
|
+
private var draining = false
|
|
97
|
+
|
|
98
|
+
private val serialWorker = Executors.newSingleThreadExecutor()
|
|
99
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
100
|
+
private var heartbeatRunnable: Runnable? = null
|
|
101
|
+
|
|
102
|
+
private val batchSizeLimit = 500_000
|
|
103
|
+
|
|
104
|
+
// Dead tap detection — timestamp comparison.
|
|
105
|
+
// After a tap, a 400ms timer fires and checks whether any "response" event
|
|
106
|
+
// (navigation or input) occurred since the tap. If not → dead tap.
|
|
107
|
+
// We do NOT cancel the timer proactively because gesture-recognizer scroll
|
|
108
|
+
// events fire on nearly every tap due to micro-movement and would mask real dead taps.
|
|
109
|
+
private var deadTapRunnable: Runnable? = null
|
|
110
|
+
private var lastTapLabel: String = ""
|
|
111
|
+
private var lastTapX: Long = 0
|
|
112
|
+
private var lastTapY: Long = 0
|
|
113
|
+
private val deadTapTimeoutMs: Long = 400
|
|
114
|
+
private var lastTapTs: Long = 0
|
|
115
|
+
private var lastResponseTs: Long = 0
|
|
116
|
+
|
|
117
|
+
fun activate() {
|
|
118
|
+
// Upload any pending data from previous sessions first
|
|
119
|
+
uploadPendingSessions()
|
|
120
|
+
|
|
121
|
+
// Start heartbeat timer on main thread
|
|
122
|
+
mainHandler.post {
|
|
123
|
+
heartbeatRunnable = object : Runnable {
|
|
124
|
+
override fun run() {
|
|
125
|
+
dispatchNow()
|
|
126
|
+
mainHandler.postDelayed(this, 5000)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
mainHandler.postDelayed(heartbeatRunnable!!, 5000)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fun shutdown() {
|
|
134
|
+
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
135
|
+
heartbeatRunnable = null
|
|
136
|
+
|
|
137
|
+
SegmentDispatcher.shared.halt()
|
|
138
|
+
appSuspending()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fun finalizeAndShip() {
|
|
142
|
+
shutdown()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fun activateDeferredMode() {
|
|
146
|
+
serialWorker.execute { deferredMode = true }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun commitDeferredData() {
|
|
150
|
+
serialWorker.execute {
|
|
151
|
+
deferredMode = false
|
|
152
|
+
shipPendingEvents()
|
|
153
|
+
shipPendingFrames()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fun submitFrameBundle(payload: ByteArray, filename: String, startMs: Long, endMs: Long, frameCount: Int) {
|
|
158
|
+
DiagnosticLog.notice("[TelemetryPipeline] submitFrameBundle: $frameCount frames, ${payload.size} bytes, deferredMode=$deferredMode")
|
|
159
|
+
serialWorker.execute {
|
|
160
|
+
val bundle = PendingFrameBundle(filename, payload, startMs, endMs, frameCount)
|
|
161
|
+
frameQueue.enqueue(bundle)
|
|
162
|
+
if (!deferredMode) shipPendingFrames()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fun dispatchNow() {
|
|
167
|
+
serialWorker.execute {
|
|
168
|
+
shipPendingEvents()
|
|
169
|
+
shipPendingFrames()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fun getQueueDepth(): Int {
|
|
174
|
+
return eventRing.size() + frameQueue.size()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun appSuspending() {
|
|
178
|
+
if (draining) return
|
|
179
|
+
draining = true
|
|
180
|
+
|
|
181
|
+
// Flush visual frames immediately
|
|
182
|
+
VisualCapture.shared?.flushToDisk()
|
|
183
|
+
|
|
184
|
+
// Try to upload pending data
|
|
185
|
+
serialWorker.execute {
|
|
186
|
+
shipPendingEvents()
|
|
187
|
+
shipPendingFrames()
|
|
188
|
+
|
|
189
|
+
Thread.sleep(500)
|
|
190
|
+
draining = false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun uploadPendingSessions() {
|
|
195
|
+
// TODO: Implement pending session upload
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private fun shipPendingFrames() {
|
|
199
|
+
if (deferredMode) {
|
|
200
|
+
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: skipped (deferred mode)")
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
val next = frameQueue.dequeue()
|
|
204
|
+
if (next == null) {
|
|
205
|
+
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: no frames in queue")
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
if (currentReplayId == null) {
|
|
209
|
+
DiagnosticLog.caution("[TelemetryPipeline] shipPendingFrames: no currentReplayId, requeueing")
|
|
210
|
+
frameQueue.requeue(next)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
DiagnosticLog.notice("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
|
|
215
|
+
|
|
216
|
+
SegmentDispatcher.shared.transmitFrameBundle(
|
|
217
|
+
payload = next.payload,
|
|
218
|
+
startMs = next.rangeStart,
|
|
219
|
+
endMs = next.rangeEnd,
|
|
220
|
+
frameCount = next.count
|
|
221
|
+
) { ok ->
|
|
222
|
+
if (!ok) {
|
|
223
|
+
frameQueue.requeue(next)
|
|
224
|
+
} else {
|
|
225
|
+
serialWorker.execute { shipPendingFrames() }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private fun shipPendingEvents() {
|
|
231
|
+
if (deferredMode) return
|
|
232
|
+
val batch = eventRing.drain(batchSizeLimit)
|
|
233
|
+
if (batch.isEmpty()) return
|
|
234
|
+
|
|
235
|
+
val payload = serializeBatch(batch)
|
|
236
|
+
val compressed = payload.gzipCompress()
|
|
237
|
+
if (compressed == null) {
|
|
238
|
+
batch.forEach { eventRing.push(it) }
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
val seq = batchSeq++
|
|
243
|
+
|
|
244
|
+
SegmentDispatcher.shared.transmitEventBatch(compressed, seq, batch.size) { ok ->
|
|
245
|
+
if (!ok) {
|
|
246
|
+
batch.forEach { eventRing.push(it) }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun serializeBatch(events: List<EventEntry>): ByteArray {
|
|
252
|
+
val jsonEvents = JSONArray()
|
|
253
|
+
for (e in events) {
|
|
254
|
+
try {
|
|
255
|
+
var dataStr = String(e.data, Charsets.UTF_8)
|
|
256
|
+
if (dataStr.endsWith("\n")) {
|
|
257
|
+
dataStr = dataStr.dropLast(1)
|
|
258
|
+
}
|
|
259
|
+
val obj = JSONObject(dataStr)
|
|
260
|
+
jsonEvents.put(obj)
|
|
261
|
+
} catch (_: Exception) { }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
val displayMetrics = context.resources.displayMetrics
|
|
265
|
+
val orchestrator = ReplayOrchestrator.shared
|
|
266
|
+
|
|
267
|
+
val meta = JSONObject().apply {
|
|
268
|
+
put("platform", "android")
|
|
269
|
+
put("model", Build.MODEL)
|
|
270
|
+
put("osVersion", Build.VERSION.RELEASE)
|
|
271
|
+
put("vendorId", DeviceRegistrar.shared?.deviceFingerprint ?: "")
|
|
272
|
+
put("time", System.currentTimeMillis() / 1000.0)
|
|
273
|
+
put("networkType", orchestrator?.currentNetworkType ?: "unknown")
|
|
274
|
+
put("isConstrained", orchestrator?.networkIsConstrained ?: false)
|
|
275
|
+
put("isExpensive", orchestrator?.networkIsExpensive ?: false)
|
|
276
|
+
put("appVersion", getAppVersion())
|
|
277
|
+
put("appId", context.packageName)
|
|
278
|
+
put("screenWidth", displayMetrics.widthPixels)
|
|
279
|
+
put("screenHeight", displayMetrics.heightPixels)
|
|
280
|
+
put("screenScale", displayMetrics.density.toInt())
|
|
281
|
+
put("systemName", "Android")
|
|
282
|
+
put("name", Build.DEVICE)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
val wrapper = JSONObject().apply {
|
|
286
|
+
put("events", jsonEvents)
|
|
287
|
+
put("deviceInfo", meta)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return wrapper.toString().toByteArray(Charsets.UTF_8)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private fun getAppVersion(): String {
|
|
294
|
+
return try {
|
|
295
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
|
296
|
+
} catch (e: Exception) {
|
|
297
|
+
"unknown"
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Event Recording Methods
|
|
302
|
+
|
|
303
|
+
fun recordAttribute(key: String, value: String) {
|
|
304
|
+
enqueue(mapOf(
|
|
305
|
+
"type" to "custom",
|
|
306
|
+
"timestamp" to ts(),
|
|
307
|
+
"name" to "attribute",
|
|
308
|
+
"payload" to "{\"key\":\"$key\",\"value\":\"$value\"}"
|
|
309
|
+
))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fun recordCustomEvent(name: String, payload: String) {
|
|
313
|
+
enqueue(mapOf(
|
|
314
|
+
"type" to "custom",
|
|
315
|
+
"timestamp" to ts(),
|
|
316
|
+
"name" to name,
|
|
317
|
+
"payload" to payload
|
|
318
|
+
))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
fun recordJSErrorEvent(name: String, message: String, stack: String?) {
|
|
322
|
+
val event = mutableMapOf<String, Any>(
|
|
323
|
+
"type" to "error",
|
|
324
|
+
"timestamp" to ts(),
|
|
325
|
+
"name" to name,
|
|
326
|
+
"message" to message
|
|
327
|
+
)
|
|
328
|
+
if (stack != null) {
|
|
329
|
+
event["stack"] = stack
|
|
330
|
+
}
|
|
331
|
+
enqueue(event)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fun recordAnrEvent(durationMs: Long, stack: String?) {
|
|
335
|
+
val event = mutableMapOf<String, Any>(
|
|
336
|
+
"type" to "anr",
|
|
337
|
+
"timestamp" to ts(),
|
|
338
|
+
"durationMs" to durationMs,
|
|
339
|
+
"threadState" to "blocked"
|
|
340
|
+
)
|
|
341
|
+
if (stack != null) {
|
|
342
|
+
event["stack"] = stack
|
|
343
|
+
}
|
|
344
|
+
enqueue(event)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fun recordUserAssociation(userId: String) {
|
|
348
|
+
enqueue(mapOf(
|
|
349
|
+
"type" to "user_identity_changed",
|
|
350
|
+
"timestamp" to ts(),
|
|
351
|
+
"userId" to userId
|
|
352
|
+
))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fun recordTapEvent(label: String, x: Long, y: Long, isInteractive: Boolean = false) {
|
|
356
|
+
// Cancel any existing dead tap timer (new tap supersedes previous)
|
|
357
|
+
cancelDeadTapTimer()
|
|
358
|
+
|
|
359
|
+
val tapTs = ts()
|
|
360
|
+
enqueue(mapOf(
|
|
361
|
+
"type" to "touch",
|
|
362
|
+
"gestureType" to "tap",
|
|
363
|
+
"timestamp" to tapTs,
|
|
364
|
+
"label" to label,
|
|
365
|
+
"x" to x,
|
|
366
|
+
"y" to y,
|
|
367
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to tapTs))
|
|
368
|
+
))
|
|
369
|
+
|
|
370
|
+
// Skip dead tap detection for interactive elements (buttons, touchables, etc.)
|
|
371
|
+
// These are expected to respond, so we don't need to track "no response" as dead.
|
|
372
|
+
if (isInteractive) return
|
|
373
|
+
|
|
374
|
+
// Start dead tap timer — when it fires, check if any response event
|
|
375
|
+
// occurred after this tap. If not → dead tap.
|
|
376
|
+
lastTapLabel = label
|
|
377
|
+
lastTapX = x
|
|
378
|
+
lastTapY = y
|
|
379
|
+
lastTapTs = tapTs
|
|
380
|
+
val runnable = Runnable {
|
|
381
|
+
deadTapRunnable = null
|
|
382
|
+
// Only fire dead tap if no response event occurred since this tap
|
|
383
|
+
if (lastResponseTs <= lastTapTs) {
|
|
384
|
+
recordDeadTapEvent(lastTapLabel, lastTapX, lastTapY)
|
|
385
|
+
ReplayOrchestrator.shared?.incrementDeadTapTally()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
deadTapRunnable = runnable
|
|
389
|
+
mainHandler.postDelayed(runnable, deadTapTimeoutMs)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
fun recordRageTapEvent(label: String, x: Long, y: Long, count: Int) {
|
|
393
|
+
enqueue(mapOf(
|
|
394
|
+
"type" to "gesture",
|
|
395
|
+
"gestureType" to "rage_tap",
|
|
396
|
+
"timestamp" to ts(),
|
|
397
|
+
"label" to label,
|
|
398
|
+
"x" to x,
|
|
399
|
+
"y" to y,
|
|
400
|
+
"count" to count,
|
|
401
|
+
"frustrationKind" to "rage_tap",
|
|
402
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
403
|
+
))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
fun recordDeadTapEvent(label: String, x: Long, y: Long) {
|
|
407
|
+
enqueue(mapOf(
|
|
408
|
+
"type" to "gesture",
|
|
409
|
+
"gestureType" to "dead_tap",
|
|
410
|
+
"timestamp" to ts(),
|
|
411
|
+
"label" to label,
|
|
412
|
+
"x" to x,
|
|
413
|
+
"y" to y,
|
|
414
|
+
"frustrationKind" to "dead_tap",
|
|
415
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
416
|
+
))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fun recordSwipeEvent(label: String, x: Long, y: Long, direction: String) {
|
|
420
|
+
enqueue(mapOf(
|
|
421
|
+
"type" to "gesture",
|
|
422
|
+
"gestureType" to "swipe",
|
|
423
|
+
"timestamp" to ts(),
|
|
424
|
+
"label" to label,
|
|
425
|
+
"x" to x,
|
|
426
|
+
"y" to y,
|
|
427
|
+
"direction" to direction,
|
|
428
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
429
|
+
))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fun recordScrollEvent(label: String, x: Long, y: Long, direction: String) {
|
|
433
|
+
// NOTE: Do NOT mark scroll as a "response" for dead tap detection.
|
|
434
|
+
// Gesture recognisers classify micro-movement during a tap as a scroll,
|
|
435
|
+
// which would mask nearly every dead tap. Only navigation and input
|
|
436
|
+
// count as definitive responses.
|
|
437
|
+
enqueue(mapOf(
|
|
438
|
+
"type" to "gesture",
|
|
439
|
+
"gestureType" to "scroll",
|
|
440
|
+
"timestamp" to ts(),
|
|
441
|
+
"label" to label,
|
|
442
|
+
"x" to x,
|
|
443
|
+
"y" to y,
|
|
444
|
+
"direction" to direction,
|
|
445
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
446
|
+
))
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
fun recordPanEvent(label: String, x: Long, y: Long) {
|
|
450
|
+
enqueue(mapOf(
|
|
451
|
+
"type" to "gesture",
|
|
452
|
+
"gestureType" to "pan",
|
|
453
|
+
"timestamp" to ts(),
|
|
454
|
+
"label" to label,
|
|
455
|
+
"x" to x,
|
|
456
|
+
"y" to y,
|
|
457
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
458
|
+
))
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
fun recordLongPressEvent(label: String, x: Long, y: Long) {
|
|
462
|
+
enqueue(mapOf(
|
|
463
|
+
"type" to "gesture",
|
|
464
|
+
"gestureType" to "long_press",
|
|
465
|
+
"timestamp" to ts(),
|
|
466
|
+
"label" to label,
|
|
467
|
+
"x" to x,
|
|
468
|
+
"y" to y,
|
|
469
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
470
|
+
))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fun recordPinchEvent(label: String, x: Long, y: Long, scale: Double) {
|
|
474
|
+
enqueue(mapOf(
|
|
475
|
+
"type" to "gesture",
|
|
476
|
+
"gestureType" to "pinch",
|
|
477
|
+
"timestamp" to ts(),
|
|
478
|
+
"label" to label,
|
|
479
|
+
"x" to x,
|
|
480
|
+
"y" to y,
|
|
481
|
+
"scale" to scale,
|
|
482
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
483
|
+
))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
fun recordRotationEvent(label: String, x: Long, y: Long, angle: Double) {
|
|
487
|
+
enqueue(mapOf(
|
|
488
|
+
"type" to "gesture",
|
|
489
|
+
"gestureType" to "rotation",
|
|
490
|
+
"timestamp" to ts(),
|
|
491
|
+
"label" to label,
|
|
492
|
+
"x" to x,
|
|
493
|
+
"y" to y,
|
|
494
|
+
"angle" to angle,
|
|
495
|
+
"touches" to listOf(mapOf("x" to x, "y" to y, "timestamp" to ts()))
|
|
496
|
+
))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fun recordInputEvent(value: String, redacted: Boolean, label: String) {
|
|
500
|
+
lastResponseTs = ts() // keyboard input = definitive response
|
|
501
|
+
enqueue(mapOf(
|
|
502
|
+
"type" to "input",
|
|
503
|
+
"timestamp" to ts(),
|
|
504
|
+
"value" to if (redacted) "***" else value,
|
|
505
|
+
"redacted" to redacted,
|
|
506
|
+
"label" to label
|
|
507
|
+
))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
fun recordViewTransition(viewId: String, viewLabel: String, entering: Boolean) {
|
|
511
|
+
lastResponseTs = ts() // navigation = definitive response
|
|
512
|
+
enqueue(mapOf(
|
|
513
|
+
"type" to "navigation",
|
|
514
|
+
"timestamp" to ts(),
|
|
515
|
+
"screen" to viewLabel,
|
|
516
|
+
"screenName" to viewLabel,
|
|
517
|
+
"viewId" to viewId,
|
|
518
|
+
"entering" to entering
|
|
519
|
+
))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fun recordNetworkEvent(details: Map<String, Any>) {
|
|
523
|
+
val event = details.toMutableMap()
|
|
524
|
+
event["type"] = "network_request"
|
|
525
|
+
event["timestamp"] = ts()
|
|
526
|
+
enqueue(event)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
fun recordAppStartup(durationMs: Long) {
|
|
530
|
+
enqueue(mapOf(
|
|
531
|
+
"type" to "app_startup",
|
|
532
|
+
"timestamp" to ts(),
|
|
533
|
+
"durationMs" to durationMs,
|
|
534
|
+
"platform" to "android"
|
|
535
|
+
))
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
fun recordAppForeground(totalBackgroundTimeMs: Long) {
|
|
539
|
+
enqueue(mapOf(
|
|
540
|
+
"type" to "app_foreground",
|
|
541
|
+
"timestamp" to ts(),
|
|
542
|
+
"totalBackgroundTime" to totalBackgroundTimeMs
|
|
543
|
+
))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private fun cancelDeadTapTimer() {
|
|
547
|
+
deadTapRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
548
|
+
deadTapRunnable = null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private fun enqueue(dict: Map<String, Any>) {
|
|
552
|
+
try {
|
|
553
|
+
val json = JSONObject(dict)
|
|
554
|
+
val data = (json.toString() + "\n").toByteArray(Charsets.UTF_8)
|
|
555
|
+
eventRing.push(EventEntry(data, data.size))
|
|
556
|
+
} catch (_: Exception) { }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private fun ts(): Long = System.currentTimeMillis()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private data class EventEntry(
|
|
563
|
+
val data: ByteArray,
|
|
564
|
+
val size: Int
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
private class EventRingBuffer(private val capacity: Int) {
|
|
568
|
+
private val storage = CopyOnWriteArrayList<EventEntry>()
|
|
569
|
+
private val lock = ReentrantLock()
|
|
570
|
+
|
|
571
|
+
fun push(entry: EventEntry) {
|
|
572
|
+
lock.withLock {
|
|
573
|
+
if (storage.size >= capacity) {
|
|
574
|
+
storage.removeAt(0)
|
|
575
|
+
}
|
|
576
|
+
storage.add(entry)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
fun drain(maxBytes: Int): List<EventEntry> {
|
|
581
|
+
lock.withLock {
|
|
582
|
+
val result = mutableListOf<EventEntry>()
|
|
583
|
+
var total = 0
|
|
584
|
+
while (storage.isNotEmpty()) {
|
|
585
|
+
val next = storage.first()
|
|
586
|
+
if (total + next.size > maxBytes) break
|
|
587
|
+
result.add(next)
|
|
588
|
+
total += next.size
|
|
589
|
+
storage.removeAt(0)
|
|
590
|
+
}
|
|
591
|
+
return result
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
fun size(): Int = storage.size
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private data class PendingFrameBundle(
|
|
599
|
+
val tag: String,
|
|
600
|
+
val payload: ByteArray,
|
|
601
|
+
val rangeStart: Long,
|
|
602
|
+
val rangeEnd: Long,
|
|
603
|
+
val count: Int
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
private class FrameBundleQueue(private val maxPending: Int) {
|
|
607
|
+
private val queue = mutableListOf<PendingFrameBundle>()
|
|
608
|
+
private val lock = ReentrantLock()
|
|
609
|
+
|
|
610
|
+
fun enqueue(bundle: PendingFrameBundle) {
|
|
611
|
+
lock.withLock {
|
|
612
|
+
if (queue.size >= maxPending) {
|
|
613
|
+
queue.removeAt(0)
|
|
614
|
+
}
|
|
615
|
+
queue.add(bundle)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
fun dequeue(): PendingFrameBundle? {
|
|
620
|
+
lock.withLock {
|
|
621
|
+
if (queue.isEmpty()) return null
|
|
622
|
+
return queue.removeAt(0)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
fun requeue(bundle: PendingFrameBundle) {
|
|
627
|
+
lock.withLock {
|
|
628
|
+
queue.add(0, bundle)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
fun size(): Int = queue.size
|
|
633
|
+
}
|