diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dfe3d431f..6f46f0a89d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485)) + ### Features - Add New User Feedback form ([#4384](https://github.com/getsentry/sentry-java/pull/4384)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index ff4e79baeb..a2a366ffa1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.Owner import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsConfiguration import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.TextLayoutResult @@ -29,26 +30,51 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.lang.ref.WeakReference +import java.lang.reflect.Method @TargetApi(26) internal object ComposeViewHierarchyNode { + private val getSemanticsConfigurationMethod: Method? by lazy { + try { + return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply { + isAccessible = true + } + } catch (_: Throwable) { + // ignore, as this method may not be available + } + return@lazy null + } + + private var semanticsRetrievalErrorLogged: Boolean = false + + @JvmStatic + internal fun retrieveSemanticsConfiguration(node: LayoutNode): SemanticsConfiguration? { + // Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo + // See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt + // and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt + getSemanticsConfigurationMethod?.let { + return it.invoke(node) as SemanticsConfiguration? + } + + // for backwards compatibility + return node.collapsedSemantics + } + /** * Since Compose doesn't have a concept of a View class (they are all composable functions), * we need to map the semantics node to a corresponding old view system class. */ - private fun LayoutNode.getProxyClassName(isImage: Boolean): String { + private fun getProxyClassName(isImage: Boolean, config: SemanticsConfiguration?): String { return when { isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME - collapsedSemantics?.contains(SemanticsProperties.Text) == true || - collapsedSemantics?.contains(SemanticsActions.SetText) == true || - collapsedSemantics?.contains(SemanticsProperties.EditableText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + config != null && (config.contains(SemanticsProperties.Text) || config.contains(SemanticsActions.SetText) || config.contains(SemanticsProperties.EditableText)) -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME else -> "android.view.View" } } - private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { - val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + private fun SemanticsConfiguration?.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = this?.getOrNull(SentryReplayModifiers.SentryPrivacy) if (sentryPrivacyModifier == "unmask") { return false } @@ -57,7 +83,7 @@ internal object ComposeViewHierarchyNode { return true } - val className = getProxyClassName(isImage) + val className = getProxyClassName(isImage, this) if (options.sessionReplay.unmaskViewClasses.contains(className)) { return false } @@ -83,16 +109,53 @@ internal object ComposeViewHierarchyNode { _rootCoordinates = WeakReference(node.coordinates.findRootCoordinates()) } - val semantics = node.collapsedSemantics val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get()) + val semantics: SemanticsConfiguration? + + try { + semantics = retrieveSemanticsConfiguration(node) + } catch (t: Throwable) { + if (!semanticsRetrievalErrorLogged) { + semanticsRetrievalErrorLogged = true + options.logger.log( + SentryLevel.ERROR, + t, + """ + Error retrieving semantics information from Compose tree. Most likely you're using + an unsupported version of androidx.compose.ui:ui. The supported + version range is 1.5.0 - 1.8.0. + If you're using a newer version, please open a github issue with the version + you're using, so we can add support for it. + """.trimIndent() + ) + } + + // If we're unable to retrieve the semantics configuration + // we should play safe and mask the whole node. + return GenericViewHierarchyNode( + x = visibleRect.left.toFloat(), + y = visibleRect.top.toFloat(), + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldMask = true, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = !node.outerCoordinator.isTransparent() && visibleRect.height() > 0 && visibleRect.width() > 0, + visibleRect = visibleRect + ) + } + val isVisible = !node.outerCoordinator.isTransparent() && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 val isEditable = semantics?.contains(SemanticsActions.SetText) == true || semantics?.contains(SemanticsProperties.EditableText) == true + return when { semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { - val shouldMask = isVisible && node.shouldMask(isImage = false, options) + val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) parent?.setImportantForCaptureToAncestors(true) // TODO: if we get reports that it's slow, we can drop this, and just mask @@ -133,7 +196,7 @@ internal object ComposeViewHierarchyNode { else -> { val painter = node.findPainter() if (painter != null) { - val shouldMask = isVisible && node.shouldMask(isImage = true, options) + val shouldMask = isVisible && semantics.shouldMask(isImage = true, options) parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( @@ -150,7 +213,7 @@ internal object ComposeViewHierarchyNode { visibleRect = visibleRect ) } else { - val shouldMask = isVisible && node.shouldMask(isImage = false, options) + val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) // TODO: this currently does not support embedded AndroidViews, we'd have to // TODO: traverse the ViewHierarchyNode here again. For now we can recommend diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 6cdeb2b437..3a3b7b0242 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -1,9 +1,13 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package io.sentry.android.replay.viewhierarchy import android.app.Activity import android.net.Uri import android.os.Bundle import android.os.Looper +import android.view.View +import android.view.ViewGroup import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement @@ -15,6 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.editableText @@ -37,8 +42,14 @@ import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.Assert.assertThrows import org.junit.Before import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -46,6 +57,7 @@ import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -139,6 +151,44 @@ class ComposeMaskingOptionsTest { assertTrue(imageNodes.all { it.shouldMask }) } + @Test + fun `when retrieving the semantics fails, a node should be masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val options = SentryOptions() + + Mockito.mockStatic(ComposeViewHierarchyNode.javaClass) + .use { mock: MockedStatic -> + mock.`when` { + ComposeViewHierarchyNode.retrieveSemanticsConfiguration(any()) + }.thenThrow(RuntimeException()) + + val root = activity.get().window.decorView + val composeView = root.lookupComposeView() + assertNotNull(composeView) + + val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true) + ComposeViewHierarchyNode.fromView(composeView, rootNode, options) + + assertEquals(1, rootNode.children?.size) + + rootNode.traverse { node -> + assertTrue(node.shouldMask) + true + } + } + } + + @Test + fun `when retrieving the semantics fails, an error is thrown`() { + val node = mock() + whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error")) + + assertThrows(RuntimeException::class.java) { + ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node) + } + } + @Test fun `when maskAllImages is set to false all Image nodes are unmasked`() { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() @@ -246,6 +296,22 @@ class ComposeMaskingOptionsTest { } return nodes } + + private fun View.lookupComposeView(): View? { + if (this.javaClass.name.contains("AndroidComposeView")) { + return this + } + if (this is ViewGroup) { + for (i in 0 until childCount) { + val child = getChildAt(i) + val composeView = child.lookupComposeView() + if (composeView != null) { + return composeView + } + } + } + return null + } } private class ComposeMaskingOptionsActivity : ComponentActivity() { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy