@multiplayer-app/session-recorder-react-native 1.3.5 → 1.3.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 CHANGED
@@ -851,9 +851,7 @@ SessionRecorder.init({
851
851
  isContentMaskingEnabled: true,
852
852
  maskHeadersList: ['authorization', 'cookie', 'x-api-key'],
853
853
  maskBodyFieldsList: ['password', 'token', 'secret', 'creditCard'],
854
- maskTextInputs: true,
855
- maskImages: false,
856
- maskButtons: false,
854
+ maskTextInputs: false,
857
855
  },
858
856
 
859
857
  // Session widget configuration
@@ -372,6 +372,16 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
372
372
  val rootView = activity.window.decorView.rootView
373
373
  val window = activity.window
374
374
 
375
+ // Skip screenshot if view is not visible or if animation transition is in progress
376
+ if (
377
+ !rootView.isVisible() ||
378
+ isAnimatingTransition(activity, rootView) ||
379
+ !rootView.isViewStateStableForMatrixOperations()
380
+ ) {
381
+ throw RuntimeException(
382
+ "Skipping screenshot - animation or transition in progress or view not visible")
383
+ }
384
+
375
385
  // Capture bitmap
376
386
  val bitmap =
377
387
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -382,21 +392,19 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
382
392
 
383
393
  bitmap ?: throw RuntimeException("Failed to capture screen")
384
394
 
385
- // Apply masking
395
+ // Apply masking and optional scaling in a single pass
386
396
  val maskedBitmap = applyMasking(bitmap, rootView, options)
387
397
 
388
- // Apply optional scaling (resolution downsample)
389
- val finalBitmap =
390
- if (config.scale < 1.0f) resizeBitmap(maskedBitmap, config.scale) else maskedBitmap
391
-
392
398
  // Convert to base64 with compression
393
399
  val output = ByteArrayOutputStream()
394
400
  val quality = (config.imageQuality * 100).toInt().coerceIn(1, 100)
395
- finalBitmap.compress(Bitmap.CompressFormat.JPEG, quality, output)
401
+ maskedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, output)
396
402
  val base64 = Base64.encodeToString(output.toByteArray(), Base64.DEFAULT)
397
403
 
398
404
  // Clean up memory
399
- if (finalBitmap != maskedBitmap) maskedBitmap.recycle()
405
+ if (maskedBitmap !== bitmap) {
406
+ bitmap.recycle()
407
+ }
400
408
 
401
409
  return base64
402
410
  }
@@ -493,35 +501,74 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
493
501
  }
494
502
 
495
503
  private fun applyMasking(bitmap: Bitmap, rootView: View, options: ReadableMap?): Bitmap {
496
- val maskedBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
497
- val canvas = Canvas(maskedBitmap)
498
-
499
- // Find maskable widgets
500
- val maskableWidgets = mutableListOf<Rect>()
501
- findMaskableWidgets(rootView, rootView, maskableWidgets)
502
-
503
- for (frame in maskableWidgets) {
504
- // Skip zero rects (which indicate invalid coordinates)
505
- if (frame.isEmpty) continue
506
-
507
- // Validate frame dimensions before processing
508
- if (!frame.isValid()) continue
509
-
510
- // Clip the frame to the image bounds to avoid drawing outside context
511
- val clippedFrame =
512
- Rect(
513
- frame.left.coerceAtLeast(0),
514
- frame.top.coerceAtLeast(0),
515
- frame.right.coerceAtMost(bitmap.width),
516
- frame.bottom.coerceAtMost(bitmap.height)
517
- )
504
+ val hasMasks = hasAnyMaskingEnabled()
505
+
506
+ // Integrate optional scaling directly into the masking pass to avoid extra resize work
507
+ val clampedScale = config.scale.coerceIn(0.1f, 1.0f)
508
+ val scaleFactor = if (clampedScale < 1.0f) clampedScale else 1.0f
509
+ val shouldDownsample = scaleFactor < 1.0f
518
510
 
519
- if (clippedFrame.isEmpty) continue
511
+ // If there is no masking and no downsampling requested, return the original bitmap
512
+ if (!hasMasks && !shouldDownsample) {
513
+ return bitmap
514
+ }
520
515
 
521
- applyCleanMask(canvas, clippedFrame)
516
+ val sourceWidth = bitmap.width
517
+ val sourceHeight = bitmap.height
518
+ val targetWidth =
519
+ if (shouldDownsample) (sourceWidth * scaleFactor).toInt().coerceAtLeast(1)
520
+ else sourceWidth
521
+ val targetHeight =
522
+ if (shouldDownsample) (sourceHeight * scaleFactor).toInt().coerceAtLeast(1)
523
+ else sourceHeight
524
+
525
+ val resultBitmap =
526
+ Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
527
+ val canvas = Canvas(resultBitmap)
528
+
529
+ canvas.save()
530
+ if (shouldDownsample) {
531
+ canvas.scale(scaleFactor, scaleFactor)
522
532
  }
523
533
 
524
- return maskedBitmap
534
+ // Draw the original screenshot into the (possibly downsampled) canvas
535
+ canvas.drawBitmap(bitmap, 0f, 0f, null)
536
+
537
+ if (hasMasks) {
538
+ // Find maskable widgets
539
+ val maskableWidgets = mutableListOf<Rect>()
540
+ findMaskableWidgets(rootView, rootView, maskableWidgets)
541
+
542
+ for (frame in maskableWidgets) {
543
+ // Skip zero rects (which indicate invalid coordinates)
544
+ if (frame.isEmpty) continue
545
+
546
+ // Validate frame dimensions before processing
547
+ if (!frame.isValid()) continue
548
+
549
+ // Clip the frame to the image bounds to avoid drawing outside context
550
+ val clippedFrame =
551
+ Rect(
552
+ frame.left.coerceAtLeast(0),
553
+ frame.top.coerceAtLeast(0),
554
+ frame.right.coerceAtMost(sourceWidth),
555
+ frame.bottom.coerceAtMost(sourceHeight)
556
+ )
557
+
558
+ if (clippedFrame.isEmpty) continue
559
+
560
+ applyCleanMask(canvas, clippedFrame)
561
+ }
562
+ }
563
+
564
+ canvas.restore()
565
+
566
+ return resultBitmap
567
+ }
568
+
569
+ // Check if any masking option is enabled (optimization)
570
+ private fun hasAnyMaskingEnabled(): Boolean {
571
+ return config.maskTextInputs || config.maskImages || config.maskButtons || config.maskWebViews
525
572
  }
526
573
 
527
574
  private fun findMaskableWidgets(
@@ -543,27 +590,31 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
543
590
  return
544
591
  }
545
592
 
546
- // Masking logic - clean and organized
593
+ // Masking logic - only check if the relevant masking option is enabled
547
594
  when {
548
- view is EditText && view.shouldMaskEditText() -> {
595
+ // EditText - only if maskTextInputs is enabled
596
+ view is EditText && config.maskTextInputs && view.shouldMaskEditText() -> {
549
597
  maskableWidgets.add(view.toAbsoluteRect(rootView))
550
598
  return
551
599
  }
552
- view is Button && view.shouldMaskButton() -> {
600
+ // Button - only if maskButtons is enabled
601
+ view is Button && config.maskButtons && view.shouldMaskButton() -> {
553
602
  maskableWidgets.add(view.toAbsoluteRect(rootView))
554
603
  return
555
604
  }
556
- view is ImageView && view.shouldMaskImage() -> {
605
+ // ImageView - only if maskImages is enabled
606
+ view is ImageView && config.maskImages && view.shouldMaskImage() -> {
557
607
  maskableWidgets.add(view.toAbsoluteRect(rootView))
558
608
  return
559
609
  }
560
- view is WebView && view.shouldMaskWebView() -> {
610
+ // WebView - only if maskWebViews is enabled
611
+ view is WebView && config.maskWebViews && view.shouldMaskWebView() -> {
561
612
  maskableWidgets.add(view.toAbsoluteRect(rootView))
562
613
  return
563
614
  }
564
615
  }
565
616
 
566
- // Detect React Native views
617
+ // Detect React Native views - only check if relevant masking is enabled
567
618
  if (isReactNativeView(view)) {
568
619
  if (shouldMaskReactNativeView(view)) {
569
620
  maskableWidgets.add(view.toAbsoluteRect(rootView))
@@ -598,29 +649,29 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
598
649
 
599
650
  // MARK: - Sensitive Content Detection Methods
600
651
 
601
- private fun View.isAnyInputSensitive(): Boolean {
602
- return this.isTextInputSensitive() || config.maskImages
603
- }
604
-
605
- private fun View.isTextInputSensitive(): Boolean {
606
- return config.maskTextInputs && this is EditText
607
- }
608
-
609
652
  // Masking methods for different view types
653
+ // Note: These methods are only called when the relevant config option is already checked
610
654
  private fun Button.shouldMaskButton(): Boolean {
611
- return config.maskButtons || this.isExplicitlyMasked(config.noCaptureLabel)
655
+ // Only mask if explicitly marked or if maskButtons is enabled (already checked in caller)
656
+ return this.isExplicitlyMasked(config.noCaptureLabel)
612
657
  }
613
658
 
614
659
  private fun ImageView.shouldMaskImage(): Boolean {
615
- return config.maskImages || this.isExplicitlyMasked(config.noCaptureLabel)
660
+ // Only mask if explicitly marked or if maskImages is enabled (already checked in caller)
661
+ return this.isExplicitlyMasked(config.noCaptureLabel)
616
662
  }
617
663
 
618
664
  private fun WebView.shouldMaskWebView(): Boolean {
619
- return config.maskWebViews || this.isExplicitlyMasked(config.noCaptureLabel)
665
+ // Only mask if explicitly marked or if maskWebViews is enabled (already checked in caller)
666
+ return this.isExplicitlyMasked(config.noCaptureLabel)
620
667
  }
621
668
 
622
669
  private fun EditText.shouldMaskEditText(): Boolean {
623
- return this.isTextInputSensitive() || this.isExplicitlyMasked(config.noCaptureLabel)
670
+ // Mask if explicitly marked, or if it has content (text or hint), or if it's secure
671
+ return this.isExplicitlyMasked(config.noCaptureLabel) ||
672
+ hasText(this.text?.toString()) ||
673
+ hasText(this.hint?.toString()) ||
674
+ this.isSecureTextEntry()
624
675
  }
625
676
 
626
677
  // Check if view is explicitly marked as sensitive
@@ -645,20 +696,22 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
645
696
 
646
697
  // React Native input views: only treat EditText-like classes as inputs.
647
698
  // Examples: ReactEditText, EditText, TextInputEditText
648
- if (className.contains("EditText") ||
649
- className.contains("ReactEditText") ||
650
- className.contains("TextInputEditText") ||
651
- // Some RN implementations may expose TextInput class names without TextView
652
- // suffix
653
- (className.contains("TextInput") && !className.contains("TextView"))
699
+ // Only check if maskTextInputs is enabled
700
+ if (config.maskTextInputs &&
701
+ (className.contains("EditText") ||
702
+ className.contains("ReactEditText") ||
703
+ className.contains("TextInputEditText") ||
704
+ // Some RN implementations may expose TextInput class names without TextView suffix
705
+ (className.contains("TextInput") && !className.contains("TextView")))
654
706
  ) {
655
- return config.maskTextInputs
707
+ return true
656
708
  }
657
709
 
658
- // Do NOT mask generic ReactTextView labels when maskTextInputs is enabled
659
- // Only mask images when explicitly configured
660
- if (className.contains("ImageView") || className.contains("Image")) {
661
- return config.maskImages
710
+ // Only mask images when maskImages is explicitly enabled
711
+ if (config.maskImages &&
712
+ (className.contains("ImageView") || className.contains("Image"))
713
+ ) {
714
+ return true
662
715
  }
663
716
 
664
717
  return false
@@ -691,6 +744,69 @@ class SessionRecorderNativeModule(reactContext: ReactApplicationContext) :
691
744
  PIXELATE,
692
745
  NONE
693
746
  }
747
+
748
+ // MARK: - Animation Transition Detection
749
+ /// Check if any view controller or fragment is animating a transition
750
+ private fun isAnimatingTransition(activity: Activity, rootView: View): Boolean {
751
+ // Check for activity transitions
752
+ if (isActivityTransitionInProgress(activity)) {
753
+ return true
754
+ }
755
+
756
+ // Check for view animations (most reliable indicator)
757
+ if (isViewAnimationInProgress(rootView)) {
758
+ return true
759
+ }
760
+
761
+ return false
762
+ }
763
+
764
+ private fun isActivityTransitionInProgress(activity: Activity): Boolean {
765
+ return try {
766
+ // Check if activity is finishing or in transition
767
+ activity.isFinishing || activity.isChangingConfigurations
768
+ } catch (e: Exception) {
769
+ false
770
+ }
771
+ }
772
+
773
+ private fun isViewAnimationInProgress(view: View): Boolean {
774
+ return try {
775
+ // Check if view has any running animations
776
+ if (view.animation != null && view.animation?.hasStarted() == true && view.animation?.hasEnded() != true) {
777
+ return true
778
+ }
779
+
780
+ // Check if view has transient state (indicating animation)
781
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
782
+ if (view.hasTransientState()) {
783
+ return true
784
+ }
785
+ }
786
+
787
+ // Check if view is in layout transition
788
+ if (view.parent is ViewGroup) {
789
+ val parent = view.parent as ViewGroup
790
+ if (parent.isInLayout) {
791
+ return true
792
+ }
793
+ }
794
+
795
+ // Recursively check child views
796
+ if (view is ViewGroup) {
797
+ for (i in 0 until view.childCount) {
798
+ val child = view.getChildAt(i)
799
+ if (isViewAnimationInProgress(child)) {
800
+ return true
801
+ }
802
+ }
803
+ }
804
+
805
+ false
806
+ } catch (e: Exception) {
807
+ false
808
+ }
809
+ }
694
810
  }
695
811
 
696
812
  // Extension functions for View
@@ -756,11 +872,23 @@ private fun View.toAbsoluteRect(rootView: View): Rect {
756
872
  try {
757
873
  val location = IntArray(2)
758
874
 
759
- // Use stable view state check before accessing location
760
- if (isViewStateStableForMatrixOperations()) {
875
+ // Try to resolve the absolute screen location for this view.
876
+ // On some Android layouts (especially with React Native views), the
877
+ // view may report transient or "unstable" state while still having
878
+ // valid coordinates. Calling getLocationOnScreen() directly is safe
879
+ // and matches the iOS behavior where we always convert from the
880
+ // window's coordinate space.
881
+ //
882
+ // Previously we gated this call behind isViewStateStableForMatrixOperations()
883
+ // and fell back to (0, 0) when the view was considered unstable. In
884
+ // practice this caused many valid views to be mapped to the top‑left
885
+ // corner of the screenshot, which produced masking rectangles that
886
+ // did not align with the underlying elements.
887
+ try {
761
888
  getLocationOnScreen(location)
762
- } else {
763
- // Use zero coordinates as fallback when view state is unstable
889
+ } catch (e: Exception) {
890
+ // Use zero coordinates as a last‑resort fallback if location
891
+ // lookup truly fails.
764
892
  location[0] = 0
765
893
  location[1] = 0
766
894
  }