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 2 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: 1 addition & 1 deletion buildSrc/src/main/java/PosthogBuildConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ object PosthogBuildConfig {
}

object Android {
val COMPILE_SDK = 33
val COMPILE_SDK = 34
marandaneto marked this conversation as resolved.
Show resolved Hide resolved

// when changing this, remember to check the ANIMAL_SNIFFER_SDK_VERSION
// Session Replay (addOnFrameMetricsAvailableListener requires API 26)
Expand Down
1 change: 1 addition & 0 deletions posthog-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-common-java8:${PosthogBuildConfig.Dependencies.LIFECYCLE}")
implementation("androidx.core:core:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}")
implementation("com.squareup.curtains:curtains:${PosthogBuildConfig.Dependencies.CURTAINS}")
implementation("androidx.compose.ui:ui-android:1.7.3")
marandaneto marked this conversation as resolved.
Show resolved Hide resolved

// compatibility
signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.posthog.android.replay

import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.semantics

public object PostHogMaskModifier {
internal val PostHogReplayMask = SemanticsPropertyKey<Boolean>("ph-no-capture")
marandaneto marked this conversation as resolved.
Show resolved Hide resolved

/**
* Marks the element as not to be captured by PostHog Session Replay.
*/
public fun Modifier.postHogMaskReplay(): Modifier {
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
return semantics(
properties = {
this[PostHogReplayMask] = true
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ 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.compose.ui.semantics.getOrNull
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.posthog.PostHog
Expand All @@ -55,6 +59,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 @@ -553,6 +558,10 @@ public class PostHogReplayIntegration(
}
}

if (view.isComposeView()) {
findMaskableComposeRects(view, maskableWidgets)
}
marandaneto marked this conversation as resolved.
Show resolved Hide resolved

// if a view parent of any type is tagged as non masking, mask it
if (view.isNoCapture()) {
val rect = view.globalVisibleRect()
Expand All @@ -573,6 +582,55 @@ public class PostHogReplayIntegration(
}
}

private fun findMaskableComposeRects(
view: View,
rectsToMask: MutableList<Rect>,
) {
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 isTextInput = node.config.contains(SemanticsProperties.EditableText)
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
val isPassword = node.config.contains(SemanticsProperties.Password)
val isImage = node.config.contains(SemanticsProperties.ContentDescription)
val shouldMaskAnyway = node.config.getOrNull(PostHogReplayMask) == true

if (!shouldMaskAnyway) {
if (isTextInput && (config.sessionReplayConfig.maskAllTextInputs || isPassword)) {
rectsToMask.add(node.boundsInWindow.toRect())
}

if (isImage && config.sessionReplayConfig.maskAllImages) {
rectsToMask.add(node.boundsInWindow.toRect())
}
} else {
rectsToMask.add(node.boundsInWindow.toRect())
}
}
}

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
}
}

// PixelCopy is only API >= 24 but this is already protected by the isSupported method
@SuppressLint("NewApi")
private fun View.toScreenshotWireframe(window: Window): RRWireframe? {
Expand Down Expand Up @@ -1175,5 +1233,7 @@ public class PostHogReplayIntegration(

private companion object {
private const val PH_NO_CAPTURE_LABEL = "ph-no-capture"
private const val ANDROID_COMPOSE_VIEW_CLASS_NAME = "androidx.compose.ui.platform.AndroidComposeView"
private const val ANDROID_COMPOSE_VIEW = "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