Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1134,54 +1134,19 @@ public class PostHogReplayIntegration(

var walkChildren = false

// Keep in sync with hasMaskableContent.
when {
view.isComposeView() -> {
when (view.maskRule()) {
MaskRule.COMPOSE -> {
findMaskableComposeWidgets(view, walk)
if (walk.shouldStop) {
return
}
// Also walk View children for interop scenarios (AndroidView, FragmentContainerView, etc.)
walkChildren = true
}

view.isUnmasked() -> {
// ph-no-mask has precedence, skip masking
}

view.isNoCapture() -> {
view.addGlobalVisibleRect(walk)
}

view is TextView -> {
// Only emptiness matters here; toString() would copy the text on every walk.
val hasContent = view.text?.isNotEmpty() == true || view.hint?.isNotEmpty() == true
if (hasContent && view.shouldMaskTextView()) {
view.addTextAreaGlobalVisibleRect(walk)
}
}

view is Spinner -> {
if (view.shouldMaskSpinner()) {
view.addGlobalVisibleRect(walk)
}
}

view is ImageView -> {
if (view.shouldMaskImage()) {
view.addGlobalVisibleRect(walk)
}
}

view is WebView -> {
if (view.isAnyInputSensitive()) {
view.addGlobalVisibleRect(walk)
}
}

view is ViewGroup && view.childCount > 0 -> {
walkChildren = true
}
MaskRule.MASK_VIEW -> view.addGlobalVisibleRect(walk)
MaskRule.MASK_TEXT -> (view as TextView).addTextAreaGlobalVisibleRect(walk)
MaskRule.CHILDREN -> walkChildren = true
MaskRule.NONE -> Unit
}

if (walkChildren && view is ViewGroup && view.childCount > 0) {
Expand All @@ -1208,27 +1173,43 @@ public class PostHogReplayIntegration(
}
}

internal enum class MaskRule { COMPOSE, MASK_VIEW, MASK_TEXT, CHILDREN, NONE }

// The one place that decides how a view is masked, shared by the mask walk and
// hasMaskableContent so the two can't disagree.
internal fun View.maskRule(): MaskRule =
when {
isComposeView() -> MaskRule.COMPOSE
// ph-no-mask has precedence, skip masking
isUnmasked() -> MaskRule.NONE
isNoCapture() -> MaskRule.MASK_VIEW
// Only emptiness matters here; toString() would copy the text on every walk.
this is TextView ->
if ((text?.isNotEmpty() == true || hint?.isNotEmpty() == true) && shouldMaskTextView()) {
MaskRule.MASK_TEXT
} else {
MaskRule.NONE
}
this is Spinner -> if (shouldMaskSpinner()) MaskRule.MASK_VIEW else MaskRule.NONE
this is ImageView -> if (shouldMaskImage()) MaskRule.MASK_VIEW else MaskRule.NONE
this is WebView -> if (isAnyInputSensitive()) MaskRule.MASK_VIEW else MaskRule.NONE
this is ViewGroup && childCount > 0 -> MaskRule.CHILDREN
else -> MaskRule.NONE
}

// Whether this subtree holds anything the walk would mask, judged without geometry, so a
// legacy walk can tell an unplaceable sensitive view from a harmless animation.
// Keep in sync with findMaskableWidgets.
private fun View.hasMaskableContent(walk: MaskWalk): Boolean {
internal fun View.hasMaskableContent(walk: MaskWalk): Boolean {
if (!walk.visitedViews.add(System.identityHashCode(this))) {
return false
}
return when {
isComposeView() -> true
isUnmasked() -> false
isNoCapture() -> true
this is TextView ->
(text?.isNotEmpty() == true || hint?.isNotEmpty() == true) && shouldMaskTextView()
this is Spinner -> shouldMaskSpinner()
this is ImageView -> shouldMaskImage()
this is WebView -> isAnyInputSensitive()
this is ViewGroup ->
(0 until childCount).any { i ->
return when (maskRule()) {
MaskRule.COMPOSE, MaskRule.MASK_VIEW, MaskRule.MASK_TEXT -> true
MaskRule.CHILDREN ->
(0 until (this as ViewGroup).childCount).any { i ->
getChildAt(i)?.let { it.visibility == View.VISIBLE && it.hasMaskableContent(walk) } == true
}
else -> false
MaskRule.NONE -> false
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import android.util.Base64
import android.view.MotionEvent
import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.view.animation.Animation
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
Expand Down Expand Up @@ -4330,6 +4332,59 @@ internal class PostHogReplayIntegrationTest {
}
}

@Test
fun `the discard check treats everything the mask walk masks as maskable`() {
val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false)
try {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
activity.setContentView(root)
val views =
listOf(
View(activity),
View(activity).apply { tag = "ph-no-capture" },
TextView(activity),
TextView(activity).apply { text = "secret" },
TextView(activity).apply {
text = "secret"
tag = "ph-no-mask"
},
ImageView(activity).apply { setImageBitmap(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) },
FrameLayout(activity).apply { addView(TextView(activity).apply { text = "secret" }) },
FrameLayout(activity).apply { addView(View(activity)) },
FrameLayout(activity).apply {
addView(
TextView(activity).apply {
text = "secret"
visibility = View.INVISIBLE
},
)
},
FakeAndroidComposeView(activity),
)
views.forEach { root.addView(it) }
shadowOf(Looper.getMainLooper()).idle()
makeWindowVisible(activity.window.decorView)
root.layout(0, 0, 100, 100)
views.forEach { view ->
view.layout(0, 0, 100, 20)
(view as? ViewGroup)?.getChildAt(0)?.layout(0, 0, 100, 20)
}

val maskedByWalk =
views.map { view ->
PostHogReplayIntegration.MaskWalk(failClosed = false).also { fx.sut.findMaskableWidgets(view, it) }.rects.isNotEmpty()
}
val maskableForDiscard = views.map { with(fx.sut) { it.hasMaskableContent(PostHogReplayIntegration.MaskWalk()) } }

assertEquals(listOf(false, true, false, true, false, true, true, false, false, false), maskedByWalk)
// Compose content can't be judged without geometry, so the discard check counts it as maskable.
assertEquals(listOf(false, true, false, true, false, true, true, false, false, true), maskableForDiscard)
} finally {
fx.sut.uninstall()
}
}

@Test
fun `legacy mask walk is not poisoned by an animating view with nothing to mask`() {
val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false)
Expand Down
Loading