Skip to content

Commit

Permalink
feat: add replay masking to jetpack compose views (#198)
Browse files Browse the repository at this point in the history
  • Loading branch information
beradeep authored Oct 30, 2024
1 parent 351a86b commit 594f538
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 58 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Next

- recording: add replay masking to jetpack compose views ([#198](https://github.com/PostHog/posthog-android/pull/198))

## 3.8.3 - 2024-10-25

- recording: fix crash when calling view.isVisible ([#201](https://github.com/PostHog/posthog-android/pull/201))
Expand Down
1 change: 1 addition & 0 deletions buildSrc/src/main/java/PosthogBuildConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ object PosthogBuildConfig {
val OKHTTP = "4.11.0"
val CURTAINS = "1.2.5"
val ANDROIDX_CORE = "1.5.0"
val ANDROIDX_COMPOSE = "1.0.0"

// tests
val ANDROIDX_JUNIT = "1.1.5"
Expand Down
9 changes: 9 additions & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ public abstract interface class com/posthog/android/replay/PostHogDrawableConver
public abstract fun convert (Landroid/graphics/drawable/Drawable;)Landroid/graphics/Bitmap;
}

public final class com/posthog/android/replay/PostHogMaskModifier {
public static final field INSTANCE Lcom/posthog/android/replay/PostHogMaskModifier;
public final fun postHogMask (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier;
public static synthetic fun postHogMask$default (Lcom/posthog/android/replay/PostHogMaskModifier;Landroidx/compose/ui/Modifier;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier;
}

public final class com/posthog/android/replay/PostHogReplayIntegration : com/posthog/PostHogIntegration {
public static final field ANDROID_COMPOSE_VIEW Ljava/lang/String;
public static final field ANDROID_COMPOSE_VIEW_CLASS_NAME Ljava/lang/String;
public static final field PH_NO_CAPTURE_LABEL Ljava/lang/String;
public fun <init> (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;Lcom/posthog/android/internal/MainHandler;)V
public fun install ()V
public fun uninstall ()V
Expand Down
3 changes: 3 additions & 0 deletions posthog-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ dependencies {
implementation("androidx.core:core:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}")
implementation("com.squareup.curtains:curtains:${PosthogBuildConfig.Dependencies.CURTAINS}")

// compile only
compileOnly("androidx.compose.ui:ui:${PosthogBuildConfig.Dependencies.ANDROIDX_COMPOSE}")

// compatibility
signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature")
signature(
Expand Down
4 changes: 4 additions & 0 deletions posthog-android/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,8 @@
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**

# used in reflection to check if compose is available at runtime
-keepnames class androidx.compose.ui.platform.AndroidComposeView

##---------------End: proguard configuration for okhttp3 ----------
10 changes: 5 additions & 5 deletions posthog-android/lint-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@

<issue
id="GradleDependency"
message="A newer version of org.mockito:mockito-inline than 4.11.0 is available: 5.2.0"
errorLine1=" testImplementation(&quot;org.mockito:mockito-inline:${PosthogBuildConfig.Dependencies.MOCKITO_INLINE}&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
message="A newer version of androidx.compose.ui:ui than 1.0.0 is available: 1.7.4"
errorLine1=" compileOnly(&quot;androidx.compose.ui:ui:${PosthogBuildConfig.Dependencies.ANDROIDX_COMPOSE}&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="107"
column="24"/>
line="94"
column="17"/>
</issue>

</issues>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.posthog.android.replay

import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.semantics
import com.posthog.android.replay.PostHogReplayIntegration.Companion.PH_NO_CAPTURE_LABEL

public object PostHogMaskModifier {
internal val PostHogReplayMask = SemanticsPropertyKey<Boolean>(PH_NO_CAPTURE_LABEL)

/**
* Modifier to mask or unmask elements in the session replay.
* @param isEnabled If true, the element will be masked in the session replay.
* If false, the element will be unmasked in the session replay.
* This will override the defaults like maskAllTextInputs, maskAllImages etc. when used with the respective elements.
*/
public fun Modifier.postHogMask(isEnabled: Boolean = true): Modifier {
return semantics(
properties = {
this[PostHogReplayMask] = isEnabled
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import android.widget.RatingBar
import android.widget.Spinner
import android.widget.Switch
import android.widget.TextView
import androidx.compose.ui.node.RootForTest
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getAllSemanticsNodes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.posthog.PostHog
Expand All @@ -54,6 +57,7 @@ import com.posthog.android.internal.MainHandler
import com.posthog.android.internal.densityValue
import com.posthog.android.internal.displayMetrics
import com.posthog.android.internal.screenSize
import com.posthog.android.replay.PostHogMaskModifier.PostHogReplayMask
import com.posthog.android.replay.internal.NextDrawListener.Companion.onNextDraw
import com.posthog.android.replay.internal.ViewTreeSnapshotStatus
import com.posthog.android.replay.internal.isAliveAndAttachedToWindow
Expand Down Expand Up @@ -534,68 +538,131 @@ public class PostHogReplayIntegration(
view: View,
maskableWidgets: MutableList<Rect>,
) {
if (view is TextView) {
val viewText = view.text?.toString()
var maskIt = false
if (!viewText.isNullOrEmpty()) {
maskIt =
view.shouldMaskTextView()
}

val hint = view.hint?.toString()
if (!maskIt && !hint.isNullOrEmpty()) {
maskIt =
view.shouldMaskTextView()
when {
view.isComposeView() -> {
findMaskableComposeWidgets(view, maskableWidgets)
}

if (maskIt) {
view.isNoCapture() -> {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
return
}
}

if (view is Spinner) {
if (view.shouldMaskSpinner()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
return
view is TextView -> {
val viewText = view.text?.toString()
var maskIt = false
if (!viewText.isNullOrEmpty()) {
maskIt =
view.shouldMaskTextView()
}

val hint = view.hint?.toString()
if (!maskIt && !hint.isNullOrEmpty()) {
maskIt =
view.shouldMaskTextView()
}

if (maskIt) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
}
}
}

if (view is ImageView) {
if (view.shouldMaskImage()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
return
view is Spinner -> {
if (view.shouldMaskSpinner()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
}
}
}

if (view is WebView) {
if (view.isAnyInputSensitive()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
return
view is ImageView -> {
if (view.shouldMaskImage()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
}
}
}

// if a view parent of any type is tagged as non masking, mask it
if (view.isNoCapture()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
return
}
view is WebView -> {
if (view.isAnyInputSensitive()) {
val rect = view.globalVisibleRect()
maskableWidgets.add(rect)
}
}

if (view is ViewGroup && view.childCount > 0) {
for (i in 0 until view.childCount) {
val viewChild = view.getChildAt(i) ?: continue
view is ViewGroup && view.childCount > 0 -> {
for (i in 0 until view.childCount) {
val viewChild = view.getChildAt(i) ?: continue

if (!viewChild.isVisible()) {
continue
}

findMaskableWidgets(viewChild, maskableWidgets)
}
}
}
}

if (!viewChild.isVisible()) {
continue
private fun findMaskableComposeWidgets(
view: View,
maskableWidgets: MutableList<Rect>,
) {
try {
val semanticsOwner =
(view as? RootForTest)?.semanticsOwner ?: run {
config.logger.log("View is not a RootForTest: $view")
return
}
val semanticsNodes = semanticsOwner.getAllSemanticsNodes(true)

semanticsNodes.forEach { node ->
val hasText = node.config.contains(SemanticsProperties.Text)
val hasEditableText = node.config.contains(SemanticsProperties.EditableText)
val hasPassword = node.config.contains(SemanticsProperties.Password)
val hasImage = node.config.contains(SemanticsProperties.ContentDescription)

val hasMaskModifier = node.config.contains(PostHogReplayMask)
val isNoCapture = hasMaskModifier && node.config[PostHogReplayMask]

when {
isNoCapture -> {
maskableWidgets.add(node.boundsInWindow.toRect())
}

findMaskableWidgets(viewChild, maskableWidgets)
!hasMaskModifier -> {
when {
(hasText || hasEditableText) && (config.sessionReplayConfig.maskAllTextInputs || hasPassword) -> {
maskableWidgets.add(node.boundsInWindow.toRect())
}

hasImage && config.sessionReplayConfig.maskAllImages -> {
maskableWidgets.add(node.boundsInWindow.toRect())
}
}
}
}
}
} catch (e: Throwable) {
// swallow possible errors due to compose versioning, etc
config.logger.log("Session Replay findMaskableComposeWidgets failed: $e")
}
}

private fun androidx.compose.ui.geometry.Rect.toRect(): Rect {
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
}

private fun View.isComposeView(): Boolean {
return isComposeAvailable && this.javaClass.name.contains(ANDROID_COMPOSE_VIEW)
}

private val isComposeAvailable by lazy(LazyThreadSafetyMode.PUBLICATION) {
try {
Class.forName(ANDROID_COMPOSE_VIEW_CLASS_NAME)
true
} catch (e: Throwable) {
config.logger.log("Compose not available: $e.")
false
}
}

Expand Down Expand Up @@ -1199,7 +1266,9 @@ public class PostHogReplayIntegration(
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}

private companion object {
private const val PH_NO_CAPTURE_LABEL = "ph-no-capture"
internal companion object {
const val PH_NO_CAPTURE_LABEL: String = "ph-no-capture"
const val ANDROID_COMPOSE_VIEW_CLASS_NAME: String = "androidx.compose.ui.platform.AndroidComposeView"
const val ANDROID_COMPOSE_VIEW: String = "AndroidComposeView"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -44,12 +45,12 @@ fun greeting(
) {
var text by remember { mutableStateOf("Hello $name!") }

ClickableText(
Text(
text = AnnotatedString(text),
modifier = modifier,
onClick = {
text = "Clicked!"
},
modifier =
modifier.clickable {
text = "Clicked!"
},
)
}

Expand Down

0 comments on commit 594f538

Please sign in to comment.