@multiplayer-app/session-recorder-react-native 1.3.5 → 1.3.6
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/android/src/main/java/com/sessionrecordernative/SessionRecorderNativeModule.kt +193 -65
- package/ios/SessionRecorderNative.swift +218 -130
- package/lib/module/config/defaults.js +1 -1
- package/lib/module/config/defaults.js.map +1 -1
- package/lib/module/patch/fetch.js +4 -15
- package/lib/module/patch/fetch.js.map +1 -1
- package/lib/module/recorder/screenRecorder.js +24 -35
- package/lib/module/recorder/screenRecorder.js.map +1 -1
- package/lib/module/services/{screenMaskingService.js → screenRecordingService.js} +12 -12
- package/lib/module/services/screenRecordingService.js.map +1 -0
- package/lib/typescript/src/recorder/screenRecorder.d.ts +2 -6
- package/lib/typescript/src/recorder/screenRecorder.d.ts.map +1 -1
- package/lib/typescript/src/services/{screenMaskingService.d.ts → screenRecordingService.d.ts} +8 -8
- package/lib/typescript/src/services/screenRecordingService.d.ts.map +1 -0
- package/package.json +4 -3
- package/src/config/defaults.ts +1 -1
- package/src/patch/fetch.ts +4 -17
- package/src/recorder/screenRecorder.ts +30 -47
- package/src/services/{screenMaskingService.ts → screenRecordingService.ts} +16 -16
- package/lib/module/services/screenMaskingService.js.map +0 -1
- package/lib/typescript/src/services/screenMaskingService.d.ts.map +0 -1
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
val
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
593
|
+
// Masking logic - only check if the relevant masking option is enabled
|
|
547
594
|
when {
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
707
|
+
return true
|
|
656
708
|
}
|
|
657
709
|
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
//
|
|
760
|
-
|
|
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
|
-
}
|
|
763
|
-
// Use zero coordinates as
|
|
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
|
}
|