Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add replay masking to jetpack compose views #198

Merged
merged 9 commits into from
Oct 30, 2024
Merged
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
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)
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
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
Loading