@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,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
+ }