diff --git a/CHANGELOG.md b/CHANGELOG.md index 995fe2f7..91fa3a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/buildSrc/src/main/java/PosthogBuildConfig.kt b/buildSrc/src/main/java/PosthogBuildConfig.kt index cc66e66c..9a9d7172 100644 --- a/buildSrc/src/main/java/PosthogBuildConfig.kt +++ b/buildSrc/src/main/java/PosthogBuildConfig.kt @@ -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" diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 0e0d24e9..aab34421 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -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 (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;Lcom/posthog/android/internal/MainHandler;)V public fun install ()V public fun uninstall ()V diff --git a/posthog-android/build.gradle.kts b/posthog-android/build.gradle.kts index 1a6dda83..5b5d4bfd 100644 --- a/posthog-android/build.gradle.kts +++ b/posthog-android/build.gradle.kts @@ -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( diff --git a/posthog-android/consumer-rules.pro b/posthog-android/consumer-rules.pro index 1553107e..25de34ea 100644 --- a/posthog-android/consumer-rules.pro +++ b/posthog-android/consumer-rules.pro @@ -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 ---------- \ No newline at end of file diff --git a/posthog-android/lint-baseline.xml b/posthog-android/lint-baseline.xml index a9456408..d669a478 100644 --- a/posthog-android/lint-baseline.xml +++ b/posthog-android/lint-baseline.xml @@ -36,13 +36,13 @@ + message="A newer version of androidx.compose.ui:ui than 1.0.0 is available: 1.7.4" + errorLine1=" compileOnly("androidx.compose.ui:ui:${PosthogBuildConfig.Dependencies.ANDROIDX_COMPOSE}")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="94" + column="17"/> diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogMaskModifier.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogMaskModifier.kt new file mode 100644 index 00000000..c9800205 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogMaskModifier.kt @@ -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(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 + }, + ) + } +} diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index bee459c7..2d803c85 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -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 @@ -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 @@ -534,68 +538,131 @@ public class PostHogReplayIntegration( view: View, maskableWidgets: MutableList, ) { - 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, + ) { + 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 } } @@ -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" } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MainActivity.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MainActivity.kt index a882ebee..828ec3fb 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MainActivity.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MainActivity.kt @@ -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 @@ -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!" + }, ) }