@rejourneyco/react-native 1.0.2 → 1.0.4

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 (43) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +38 -363
  2. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +11 -113
  3. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +1 -15
  4. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +1 -61
  5. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +3 -1
  6. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +1 -22
  7. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +14 -27
  8. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +0 -2
  9. package/android/src/main/java/com/rejourney/network/UploadManager.kt +7 -93
  10. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +5 -41
  11. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +2 -58
  12. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +4 -4
  13. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +36 -7
  14. package/ios/Capture/RJCaptureEngine.m +9 -61
  15. package/ios/Capture/RJViewHierarchyScanner.m +68 -51
  16. package/ios/Core/RJLifecycleManager.m +0 -14
  17. package/ios/Core/Rejourney.mm +24 -37
  18. package/ios/Network/RJDeviceAuthManager.m +0 -2
  19. package/ios/Network/RJUploadManager.h +8 -0
  20. package/ios/Network/RJUploadManager.m +45 -0
  21. package/ios/Privacy/RJPrivacyMask.m +5 -31
  22. package/ios/Rejourney.h +0 -14
  23. package/ios/Touch/RJTouchInterceptor.m +21 -15
  24. package/ios/Utils/RJEventBuffer.m +57 -69
  25. package/ios/Utils/RJWindowUtils.m +87 -86
  26. package/lib/commonjs/index.js +44 -31
  27. package/lib/commonjs/sdk/autoTracking.js +0 -3
  28. package/lib/commonjs/sdk/constants.js +1 -1
  29. package/lib/commonjs/sdk/networkInterceptor.js +0 -11
  30. package/lib/commonjs/sdk/utils.js +73 -14
  31. package/lib/module/index.js +44 -31
  32. package/lib/module/sdk/autoTracking.js +0 -3
  33. package/lib/module/sdk/constants.js +1 -1
  34. package/lib/module/sdk/networkInterceptor.js +0 -11
  35. package/lib/module/sdk/utils.js +73 -14
  36. package/lib/typescript/sdk/constants.d.ts +1 -1
  37. package/lib/typescript/sdk/utils.d.ts +31 -1
  38. package/package.json +16 -4
  39. package/src/index.ts +42 -20
  40. package/src/sdk/autoTracking.ts +0 -2
  41. package/src/sdk/constants.ts +14 -14
  42. package/src/sdk/networkInterceptor.ts +0 -9
  43. package/src/sdk/utils.ts +76 -14
@@ -64,7 +64,6 @@ class UploadWorker(
64
64
  TimeUnit.SECONDS
65
65
  )
66
66
 
67
- // For urgent uploads (app going to background), use expedited work
68
67
  if (expedited) {
69
68
  workRequestBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
70
69
  }
@@ -107,13 +106,13 @@ class UploadWorker(
107
106
  30,
108
107
  TimeUnit.SECONDS
109
108
  )
110
- .setInitialDelay(2, TimeUnit.SECONDS) // Small delay to let app initialize
109
+ .setInitialDelay(2, TimeUnit.SECONDS)
111
110
  .build()
112
111
 
113
112
  WorkManager.getInstance(context)
114
113
  .enqueueUniqueWork(
115
114
  RECOVERY_WORK_NAME,
116
- ExistingWorkPolicy.KEEP, // Don't replace if already running
115
+ ExistingWorkPolicy.KEEP,
117
116
  workRequest
118
117
  )
119
118
 
@@ -146,7 +145,6 @@ class UploadWorker(
146
145
 
147
146
  try {
148
147
  if (isRecovery) {
149
- // Recovery mode: process all pending sessions
150
148
  val success = performRecoveryUpload()
151
149
  return@withContext if (success) Result.success() else Result.retry()
152
150
  }
@@ -156,7 +154,6 @@ class UploadWorker(
156
154
  return@withContext Result.failure()
157
155
  }
158
156
 
159
- // Upload pending data for this specific session
160
157
  Logger.debug("[UploadWorker] ===== CALLING performSessionUpload =====")
161
158
  Logger.debug("[UploadWorker] About to upload session: $sessionId, isFinal=$isFinal")
162
159
  val success = performSessionUpload(sessionId, isFinal)
@@ -168,7 +165,6 @@ class UploadWorker(
168
165
  Result.success()
169
166
  } else {
170
167
  Logger.debug("[UploadWorker] ===== UPLOAD FAILED =====")
171
- // Retry up to 5 times with exponential backoff
172
168
  if (runAttemptCount < 5) {
173
169
  Logger.warning("[UploadWorker] ⚠️ Upload failed, will retry (attempt $runAttemptCount)")
174
170
  Result.retry()
@@ -178,13 +174,11 @@ class UploadWorker(
178
174
  }
179
175
  }
180
176
  } catch (e: CancellationException) {
181
- // WorkManager cancellation is expected, not an error
182
177
  Logger.debug("[UploadWorker] Work cancelled")
183
- throw e // Re-throw to let WorkManager handle cancellation
178
+ throw e
184
179
  } catch (e: Exception) {
185
180
  Logger.error("[UploadWorker] Upload error", e)
186
181
 
187
- // Retry on transient errors
188
182
  if (runAttemptCount < 5) {
189
183
  Result.retry()
190
184
  } else {
@@ -201,14 +195,11 @@ class UploadWorker(
201
195
  private suspend fun performSessionUpload(sessionId: String, isFinal: Boolean): Boolean {
202
196
  Logger.debug("[UploadWorker] performSessionUpload START (sessionId=$sessionId, isFinal=$isFinal)")
203
197
 
204
- // EventBuffer stores events in cache directory
205
198
  val eventBufferDir = File(appContext.cacheDir, "rj_pending/$sessionId")
206
- // UploadManager stores session metadata in files directory
207
199
  val uploadManagerDir = File(appContext.filesDir, "rejourney/pending_uploads/$sessionId")
208
200
 
209
201
  Logger.debug("[UploadWorker] Checking directories: eventBufferDir=${eventBufferDir.exists()}, uploadManagerDir=${uploadManagerDir.exists()}")
210
202
 
211
- // Read events from EventBuffer's disk storage
212
203
  val eventsFile = File(eventBufferDir, "events.jsonl")
213
204
  Logger.debug("[UploadWorker] ===== READING EVENTS FROM DISK =====")
214
205
  Logger.debug("[UploadWorker] Events file path: ${eventsFile.absolutePath}")
@@ -224,7 +215,7 @@ class UploadWorker(
224
215
  if (eventBufferDir.exists()) {
225
216
  Logger.warning("[UploadWorker] EventBuffer directory contents: ${eventBufferDir.listFiles()?.map { it.name }?.joinToString(", ") ?: "empty"}")
226
217
  }
227
- return true // Nothing to upload is success
218
+ return true
228
219
  }
229
220
 
230
221
  Logger.debug("[UploadWorker] Reading events from file...")
@@ -245,7 +236,6 @@ class UploadWorker(
245
236
 
246
237
  if (events.isEmpty()) {
247
238
  Logger.warning("[UploadWorker] ⚠️ No events to upload for session: $sessionId (file exists but is empty or unreadable)")
248
- // Clean up empty events file
249
239
  eventsFile.delete()
250
240
  File(eventBufferDir, "buffer_meta.json").delete()
251
241
  if (eventBufferDir.listFiles()?.isEmpty() == true) {
@@ -256,25 +246,19 @@ class UploadWorker(
256
246
 
257
247
  Logger.debug("[UploadWorker] ===== FOUND ${events.size} EVENTS TO UPLOAD =====")
258
248
 
259
- // CRITICAL FIX: Ensure valid auth token before attempting any upload!
260
- // This was the root cause of silent upload failures - the token would be
261
- // expired/missing and presign requests would fail silently.
262
249
  val authManager = DeviceAuthManager.getInstance(appContext)
263
250
  Logger.debug("[UploadWorker] Ensuring valid auth token before upload...")
264
251
 
265
252
  val tokenValid = authManager.ensureValidToken()
266
253
  if (!tokenValid) {
267
254
  Logger.error("[UploadWorker] FAILED to obtain valid auth token - upload will likely fail!")
268
- // Continue anyway, the upload will fail but at least we'll get detailed error logs
269
255
  } else {
270
256
  Logger.debug("[UploadWorker] Auth token is valid, proceeding with upload")
271
257
  }
272
258
 
273
- // Create upload manager for this upload (reads session metadata from uploadManagerDir)
274
259
  val uploadManager = createUploadManager(sessionId, uploadManagerDir)
275
260
  Logger.debug("[UploadWorker] UploadManager created (apiUrl=${uploadManager.apiUrl}, publicKey=${uploadManager.publicKey.take(8)}...)")
276
261
 
277
- // Perform upload
278
262
  Logger.debug("[UploadWorker] ===== STARTING EVENT UPLOAD =====")
279
263
  Logger.debug("[UploadWorker] Calling uploadBatch for ${events.size} events (isFinal=$isFinal, sessionId=$sessionId)")
280
264
  val uploadStartTime = System.currentTimeMillis()
@@ -284,28 +268,23 @@ class UploadWorker(
284
268
 
285
269
  if (uploadSuccess) {
286
270
  Logger.debug("[UploadWorker] Upload SUCCESS - cleaning up local files")
287
- // Clear the events file after successful upload
288
271
  eventsFile.delete()
289
272
  File(eventBufferDir, "buffer_meta.json").delete()
290
273
 
291
- // Clean up EventBuffer directory if empty
292
274
  if (eventBufferDir.listFiles()?.isEmpty() == true) {
293
275
  eventBufferDir.delete()
294
276
  }
295
277
 
296
- // If final upload, send session end
297
278
  if (isFinal) {
298
279
  Logger.debug("[UploadWorker] Sending session end signal...")
299
280
  val endSuccess = uploadManager.endSession()
300
281
  Logger.debug("[UploadWorker] Session end signal: ${if (endSuccess) "SUCCESS" else "FAILED"}")
301
- // Clean up UploadManager directory
302
282
  uploadManagerDir.deleteRecursively()
303
283
  }
304
284
  } else {
305
285
  Logger.error("[UploadWorker] Upload FAILED for session $sessionId - will retry")
306
286
  }
307
287
 
308
- // Video Recovery: Check for crash segment
309
288
  checkAndUploadCrashSegment(uploadManager, sessionId)
310
289
 
311
290
  return uploadSuccess
@@ -324,7 +303,6 @@ class UploadWorker(
324
303
  val metaJson = JSONObject(metaFile.readText())
325
304
  val metaSessionId = metaJson.optString("sessionId")
326
305
 
327
- // Only process if it matches our session
328
306
  if (metaSessionId != sessionId) return
329
307
 
330
308
  Logger.debug("[UploadWorker] Found pending crash segment for session $sessionId")
@@ -346,7 +324,6 @@ class UploadWorker(
346
324
 
347
325
  if (success) {
348
326
  Logger.debug("[UploadWorker] Crash segment recovered successfully")
349
- // Delete metadata and file
350
327
  metaFile.delete()
351
328
  segmentFile.delete()
352
329
  } else {
@@ -354,7 +331,7 @@ class UploadWorker(
354
331
  }
355
332
  } else {
356
333
  Logger.warning("[UploadWorker] Crash segment file missing or empty: $segmentPath")
357
- metaFile.delete() // Clean up invalid metadata
334
+ metaFile.delete()
358
335
  }
359
336
  } catch (e: Exception) {
360
337
  Logger.error("[UploadWorker] Error processing crash segment", e)
@@ -366,20 +343,15 @@ class UploadWorker(
366
343
  * Scans both the EventBuffer cache directory and UploadManager pending directory.
367
344
  */
368
345
  private suspend fun performRecoveryUpload(): Boolean {
369
- // EventBuffer stores events in cache directory
370
346
  val eventBufferRootDir = File(appContext.cacheDir, "rj_pending")
371
- // UploadManager stores session metadata in files directory
372
347
  val uploadManagerRootDir = File(appContext.filesDir, "rejourney/pending_uploads")
373
348
 
374
- // Collect all session IDs from both locations
375
349
  val sessionIds = mutableSetOf<String>()
376
350
 
377
- // Get sessions from EventBuffer directory (has events.jsonl)
378
351
  eventBufferRootDir.listFiles()?.filter { it.isDirectory }?.forEach {
379
352
  sessionIds.add(it.name)
380
353
  }
381
354
 
382
- // Get sessions from UploadManager directory (has session.json metadata)
383
355
  uploadManagerRootDir.listFiles()?.filter { it.isDirectory }?.forEach {
384
356
  sessionIds.add(it.name)
385
357
  }
@@ -398,7 +370,6 @@ class UploadWorker(
398
370
 
399
371
  Logger.debug("[UploadWorker] Checking session for recovery: $sessionId")
400
372
 
401
- // Skip current active session (marked by session.json with recent timestamp)
402
373
  val sessionMetaFile = File(uploadManagerRootDir, "$sessionId/session.json")
403
374
  if (sessionMetaFile.exists()) {
404
375
  try {
@@ -406,14 +377,11 @@ class UploadWorker(
406
377
  val updatedAt = meta.optLong("updatedAt", 0)
407
378
  val age = System.currentTimeMillis() - updatedAt
408
379
 
409
- // Skip if session was active less than 60 seconds ago
410
- // (likely current active session)
411
380
  if (age < 60_000) {
412
381
  Logger.debug("[UploadWorker] Skipping recent session: $sessionId (age=${age}ms)")
413
382
  continue
414
383
  }
415
384
  } catch (e: Exception) {
416
- // Continue with recovery
417
385
  }
418
386
  }
419
387
 
@@ -444,7 +412,6 @@ class UploadWorker(
444
412
  }
445
413
  events.add(map)
446
414
  } catch (e: Exception) {
447
- // Skip malformed lines
448
415
  }
449
416
  }
450
417
  }
@@ -460,7 +427,6 @@ class UploadWorker(
460
427
  * Create an UploadManager configured for the given session.
461
428
  */
462
429
  private fun createUploadManager(sessionId: String, sessionDir: File): UploadManager {
463
- // Read session metadata
464
430
  val sessionMetaFile = File(sessionDir, "session.json")
465
431
  var sessionStartTime = System.currentTimeMillis()
466
432
  var totalBackgroundTimeMs = 0L
@@ -471,11 +437,9 @@ class UploadWorker(
471
437
  sessionStartTime = meta.optLong("sessionStartTime", sessionStartTime)
472
438
  totalBackgroundTimeMs = meta.optLong("totalBackgroundTimeMs", 0)
473
439
  } catch (e: Exception) {
474
- // Use defaults
475
440
  }
476
441
  }
477
442
 
478
- // Get auth details from DeviceAuthManager
479
443
  val authManager = DeviceAuthManager.getInstance(appContext)
480
444
  val publicKey = authManager.getCurrentPublicKey() ?: ""
481
445
  val deviceHash = authManager.getCurrentDeviceHash() ?: ""
@@ -30,19 +30,18 @@ object PrivacyMask {
30
30
  var maskVideoLayers: Boolean = true
31
31
 
32
32
  private val maskPaint = Paint().apply {
33
- color = Color.BLACK // Solid black mask
33
+ color = Color.BLACK
34
34
  style = Paint.Style.FILL
35
35
  }
36
36
 
37
37
  private val textPaint = Paint().apply {
38
38
  color = Color.WHITE
39
- textSize = 32f // Visible size on standard density
39
+ textSize = 32f
40
40
  textAlign = Paint.Align.CENTER
41
41
  typeface = Typeface.DEFAULT_BOLD
42
42
  isAntiAlias = true
43
43
  }
44
44
 
45
- // Set of nativeIDs that should be manually masked
46
45
  private val maskedNativeIDs = mutableSetOf<String>()
47
46
 
48
47
  /**
@@ -90,16 +89,13 @@ object PrivacyMask {
90
89
  if (sensitiveRects.isEmpty()) return bitmap
91
90
 
92
91
  return try {
93
- // Create mutable copy if needed - use ARGB_8888 as fallback if config is null
94
92
  val config = bitmap.config ?: Bitmap.Config.ARGB_8888
95
93
  val mutableBitmap = if (bitmap.isMutable) bitmap else bitmap.copy(config, true)
96
94
  val canvas = Canvas(mutableBitmap)
97
95
 
98
- // Calculate scale factor
99
96
  val scaleX = mutableBitmap.width.toFloat() / rootWidth
100
97
  val scaleY = mutableBitmap.height.toFloat() / rootHeight
101
98
 
102
- // Draw masks over sensitive areas
103
99
  for (rect in sensitiveRects) {
104
100
  val scaledRect = Rect(
105
101
  (rect.left * scaleX).toInt(),
@@ -109,9 +105,7 @@ object PrivacyMask {
109
105
  )
110
106
  canvas.drawRect(scaledRect, maskPaint)
111
107
 
112
- // Draw stars centered
113
108
  val centerX = scaledRect.centerX().toFloat()
114
- // Approximated vertical centering
115
109
  val centerY = scaledRect.centerY().toFloat() + (textPaint.textSize / 3)
116
110
  canvas.drawText("********", centerX, centerY, textPaint)
117
111
  }
@@ -130,9 +124,6 @@ object PrivacyMask {
130
124
  fun findSensitiveRects(rootView: View): List<Rect> {
131
125
  val rects = mutableListOf<Rect>()
132
126
 
133
- // IMPORTANT: The capture bitmap is in the Activity window/decorView coordinate space,
134
- // but View.getLocationOnScreen() returns absolute screen coordinates.
135
- // Convert to coordinates relative to the provided rootView to ensure masks land correctly.
136
127
  val rootLocationOnScreen = IntArray(2)
137
128
  rootView.getLocationOnScreen(rootLocationOnScreen)
138
129
 
@@ -144,9 +135,6 @@ object PrivacyMask {
144
135
  rootHeight = rootView.height
145
136
  )
146
137
 
147
- // Fail-closed fallback: if the scan returns nothing but there is a focused sensitive view,
148
- // at least mask that focused view. This protects against edge cases where parts of the
149
- // hierarchy are not reachable (e.g., some dialog/modal roots) or transient layout states.
150
138
  if (rects.isEmpty()) {
151
139
  try {
152
140
  val focused = rootView.findFocus()
@@ -174,7 +162,6 @@ object PrivacyMask {
174
162
  }
175
163
  }
176
164
  } catch (e: Exception) {
177
- // Best-effort only
178
165
  }
179
166
  }
180
167
  return rects
@@ -184,12 +171,6 @@ object PrivacyMask {
184
171
  * Find all sensitive views across ALL visible windows.
185
172
  * This includes the main Activity window plus any dialogs, modals, or popup windows.
186
173
  *
187
- * CRITICAL: React Native modals/sheets may create views in separate windows
188
- * that are not children of the main decorView. This method scans all windows
189
- * to ensure text inputs in modals are properly masked.
190
- *
191
- * Must be run on Main Thread.
192
- *
193
174
  * @param activity The current Activity (to access window and system windows)
194
175
  * @param primaryRootView The primary decorView for coordinate reference
195
176
  * @return List of sensitive view rects in primaryRootView coordinate space
@@ -199,12 +180,10 @@ object PrivacyMask {
199
180
  val scannedViews = mutableSetOf<View>()
200
181
  var didBailOutEarly = false
201
182
 
202
- // Get primary window location for coordinate conversion
203
183
  val primaryLocationOnScreen = IntArray(2)
204
184
  primaryRootView.getLocationOnScreen(primaryLocationOnScreen)
205
185
 
206
186
  try {
207
- // 1. Scan the primary decorView (main window)
208
187
  findSensitiveViewsRecursive(
209
188
  view = primaryRootView,
210
189
  rects = rects,
@@ -214,8 +193,6 @@ object PrivacyMask {
214
193
  )
215
194
  scannedViews.add(primaryRootView)
216
195
 
217
- // 2. Access all visible windows using reflection on WindowManager
218
- // This catches Dialogs, PopupWindows, and React Native modals
219
196
  val additionalWindows = getAllVisibleWindowRoots(activity)
220
197
 
221
198
  for (windowRoot in additionalWindows) {
@@ -226,8 +203,6 @@ object PrivacyMask {
226
203
 
227
204
  scannedViews.add(windowRoot)
228
205
 
229
- // Scan this window for sensitive views
230
- // Convert coordinates relative to the primary window for proper masking
231
206
  val hitLimit = findSensitiveViewsInWindowRelativeTo(
232
207
  windowRoot = windowRoot,
233
208
  rects = rects,
@@ -245,14 +220,11 @@ object PrivacyMask {
245
220
 
246
221
  } catch (e: Exception) {
247
222
  Logger.warning("PrivacyMask: Multi-window scan failed: ${e.message}")
248
- // Fall back to single-window scan if multi-window fails
249
223
  if (rects.isEmpty()) {
250
224
  return findSensitiveRects(primaryRootView)
251
225
  }
252
226
  }
253
227
 
254
- // If we bailed out early and found nothing, do a targeted fallback
255
- // with a higher per-window budget to avoid missing inputs on complex screens.
256
228
  if (rects.isEmpty() && didBailOutEarly) {
257
229
  try {
258
230
  for (windowRoot in getAllVisibleWindowRoots(activity)) {
@@ -271,14 +243,11 @@ object PrivacyMask {
271
243
  Logger.warning("PrivacyMask: Fallback scan recovered ${rects.size} sensitive views after early bailout")
272
244
  }
273
245
  } catch (_: Exception) {
274
- // Best-effort only
275
246
  }
276
247
  }
277
248
 
278
- // Fail-closed fallback for focused view
279
249
  if (rects.isEmpty()) {
280
250
  try {
281
- // Check focused view across all windows
282
251
  val focusedView = activity.currentFocus
283
252
  if (focusedView != null && focusedView.isShown && isSensitiveView(focusedView) &&
284
253
  focusedView.width > 0 && focusedView.height > 0
@@ -304,7 +273,6 @@ object PrivacyMask {
304
273
  }
305
274
  }
306
275
  } catch (e: Exception) {
307
- // Best-effort only
308
276
  }
309
277
  }
310
278
 
@@ -320,12 +288,9 @@ object PrivacyMask {
320
288
  val roots = mutableListOf<View>()
321
289
 
322
290
  try {
323
- // Method 1: Use WindowManager's internal mViews array via reflection
324
- // This is how system tools and accessibility services access all windows
325
291
  val wmgClass = Class.forName("android.view.WindowManagerGlobal")
326
292
  val wmgInstance = wmgClass.getMethod("getInstance").invoke(null)
327
293
 
328
- // Try to get mViews field (array of DecorViews for all windows)
329
294
  val viewsField = wmgClass.getDeclaredField("mViews")
330
295
  viewsField.isAccessible = true
331
296
  val views = viewsField.get(wmgInstance)
@@ -349,8 +314,6 @@ object PrivacyMask {
349
314
  } catch (e: Exception) {
350
315
  Logger.debug("PrivacyMask: Reflection method failed (${e.message}), using fallback")
351
316
 
352
- // Method 2: Fallback - just scan the activity's own windows
353
- // This won't catch all dialogs but is safer
354
317
  try {
355
318
  activity.window?.decorView?.let { roots.add(it) }
356
319
  } catch (_: Exception) {}
@@ -385,13 +348,11 @@ object PrivacyMask {
385
348
  val location = IntArray(2)
386
349
  view.getLocationOnScreen(location)
387
350
 
388
- // Convert to primary window coordinates
389
351
  val left = location[0] - primaryLocationOnScreen[0]
390
352
  val top = location[1] - primaryLocationOnScreen[1]
391
353
  val right = left + view.width
392
354
  val bottom = top + view.height
393
355
 
394
- // Clip to primary window bounds
395
356
  val clipped = Rect(
396
357
  left.coerceAtLeast(0),
397
358
  top.coerceAtLeast(0),
@@ -427,19 +388,16 @@ object PrivacyMask {
427
388
  ) {
428
389
  if (!view.isShown) return
429
390
 
430
- // Check if this is a sensitive view
431
391
  if (isSensitiveView(view)) {
432
392
  if (view.width > 0 && view.height > 0) {
433
393
  val location = IntArray(2)
434
394
  view.getLocationOnScreen(location)
435
395
 
436
- // Convert absolute screen coords -> rootView-relative coords
437
396
  val left = location[0] - rootLocationOnScreen[0]
438
397
  val top = location[1] - rootLocationOnScreen[1]
439
398
  val right = left + view.width
440
399
  val bottom = top + view.height
441
400
 
442
- // Clip to root bounds to avoid huge/offscreen rects
443
401
  val clipped = Rect(
444
402
  left.coerceAtLeast(0),
445
403
  top.coerceAtLeast(0),
@@ -453,7 +411,6 @@ object PrivacyMask {
453
411
  }
454
412
  }
455
413
 
456
- // Recurse into children
457
414
  if (view is ViewGroup) {
458
415
  for (i in 0 until view.childCount) {
459
416
  findSensitiveViewsRecursive(
@@ -471,18 +428,14 @@ object PrivacyMask {
471
428
  * Determine if a view should be masked.
472
429
  */
473
430
  internal fun isSensitiveView(view: View): Boolean {
474
- // Check for manual masking tag first
475
431
  if (hasPrivacyTag(view)) return true
476
432
 
477
- // Check if view's nativeID is in the masked set
478
433
  val viewNativeID = view.getTag(com.facebook.react.R.id.view_tag_native_id)
479
434
  if (viewNativeID is String && maskedNativeIDs.contains(viewNativeID)) {
480
435
  Logger.debug("PrivacyMask: Found masked nativeID: $viewNativeID")
481
436
  return true
482
437
  }
483
438
 
484
- // Check immediate children for nativeID - React Native nests views so
485
- // the nativeID may be on a child wrapper rather than the parent container
486
439
  if (view is ViewGroup) {
487
440
  for (i in view.childCount - 1 downTo 0) {
488
441
  val child = view.getChildAt(i)
@@ -494,10 +447,8 @@ object PrivacyMask {
494
447
  }
495
448
  }
496
449
 
497
- // EditText and subclasses (password fields, etc.)
498
450
  if (maskTextInputs && view is EditText) return true
499
451
 
500
- // Check for WebView (both Android native and React Native)
501
452
  if (maskWebViews && isWebViewSurface(view)) return true
502
453
  if (maskCameraViews && isCameraPreview(view)) return true
503
454
  if (maskVideoLayers && isVideoLayerView(view)) return true
@@ -579,7 +530,6 @@ object PrivacyMask {
579
530
  }
580
531
  }
581
532
 
582
- // Class cache for prewarm (matching iOS prewarmClassCaches)
583
533
  @Volatile
584
534
  private var classesPrewarmed = false
585
535
 
@@ -595,14 +545,11 @@ object PrivacyMask {
595
545
  classesPrewarmed = true
596
546
 
597
547
  try {
598
- // Force class loading for common sensitive view types
599
- // These class lookups are cached by the JVM after first access
600
548
  EditText::class.java
601
549
  android.widget.TextView::class.java
602
550
  android.widget.Button::class.java
603
551
  ViewGroup::class.java
604
552
 
605
- // Force loading of WebView class name strings (used in isSensitiveView)
606
553
  val dummyClassNames = listOf(
607
554
  "android.webkit.webview",
608
555
  "webview",
@@ -613,14 +560,11 @@ object PrivacyMask {
613
560
  "password",
614
561
  "securetext"
615
562
  )
616
- // Touch each string to ensure interning
617
563
  dummyClassNames.forEach { it.lowercase() }
618
564
 
619
- // Pre-load React Native tag ID lookup (if available)
620
565
  try {
621
566
  com.facebook.react.R.id.view_tag_native_id
622
567
  } catch (_: Exception) {
623
- // May not be available in all configurations
624
568
  }
625
569
 
626
570
  Logger.debug("PrivacyMask: Class caches pre-warmed")
@@ -29,8 +29,8 @@ import kotlin.math.sqrt
29
29
  * Touch point data matching iOS RJTouchPoint.
30
30
  */
31
31
  data class TouchPoint(
32
- val x: Float, // Density-independent pixels (dp)
33
- val y: Float, // Density-independent pixels (dp)
32
+ val x: Float,
33
+ val y: Float,
34
34
  val timestamp: Long,
35
35
  val force: Float // Pressure (0.0-1.0)
36
36
  ) {
@@ -63,8 +63,8 @@ interface TouchInterceptorDelegate {
63
63
  type: String, // "scroll", "swipe", "pan"
64
64
  t0: Long, // Start timestamp
65
65
  t1: Long, // End timestamp
66
- dx: Float, // Delta X (dp)
67
- dy: Float, // Delta Y (dp)
66
+ dx: Float, // Delta X
67
+ dy: Float, // Delta Y
68
68
  v0: Float, // Initial velocity
69
69
  v1: Float, // Final velocity
70
70
  curve: String // "linear", "exponential_decay", "ease_out"
@@ -219,14 +219,43 @@ class EventBuffer(
219
219
  }
220
220
 
221
221
  fun readEventsAfterBatchNumber(afterBatchNumber: Int): List<Map<String, Any?>> {
222
- val allEvents = readAllEvents()
223
- val startIndex = lock.withLock {
224
- maxOf(uploadedEventCount, maxOf(0, afterBatchNumber))
225
- }
226
- if (startIndex >= allEvents.size) {
227
- return emptyList()
222
+ return lock.withLock {
223
+ try {
224
+ if (!eventsFile.exists()) {
225
+ return@withLock emptyList()
226
+ }
227
+
228
+ val events = mutableListOf<Map<String, Any?>>()
229
+ val targetIndex = maxOf(uploadedEventCount, maxOf(0, afterBatchNumber))
230
+ var currentIndex = 0
231
+
232
+ // Use BufferedReader to stream the file line by line
233
+ // This avoids loading the whole file into memory just to skip lines
234
+ BufferedReader(FileReader(eventsFile)).use { reader ->
235
+ reader.forEachLine { line ->
236
+ if (line.isNotBlank()) {
237
+ // Only parse JSON if we are past the skip threshold
238
+ if (currentIndex >= targetIndex) {
239
+ try {
240
+ val json = JSONObject(line)
241
+ val map = mutableMapOf<String, Any?>()
242
+ json.keys().forEach { key ->
243
+ map[key] = json.opt(key)
244
+ }
245
+ events.add(map)
246
+ } catch (_: Exception) {
247
+ }
248
+ }
249
+ currentIndex++
250
+ }
251
+ }
252
+ }
253
+ events
254
+ } catch (e: Exception) {
255
+ Logger.error("[EventBuffer] readEventsAfterBatchNumber: Failed to read events", e)
256
+ emptyList()
257
+ }
228
258
  }
229
- return allEvents.subList(startIndex, allEvents.size)
230
259
  }
231
260
 
232
261
  fun readPendingEvents(): List<Map<String, Any?>> {