diff --git a/CHANGELOG.md b/CHANGELOG.md index 71190dc668..fc4c2d1009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,20 @@ - Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) - Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) +- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code + - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions + - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well + - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified +- Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayRedact()` and `Modifier.sentryReplayIgnore()` modifiers ### Fixes - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) - Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) -- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) - - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags - - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code - - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions - - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) - Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) - Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778)) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index f74fcb4953..8e4b6832fb 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -147,8 +147,11 @@ object Config { val composeActivity = "androidx.activity:activity-compose:1.4.0" val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" val composeUi = "androidx.compose.ui:ui:$composeVersion" + + val composeUiReplay = "androidx.compose.ui:ui:1.5.0" // Note: don't change without testing forwards compatibility val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" + val composeCoil = "io.coil-kt:coil-compose:2.6.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 1c08379a49..4b4c59b9a2 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -7,11 +7,13 @@ public final class io/sentry/android/replay/BuildConfig { } public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public static final field $stable I public fun ()V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } public final class io/sentry/android/replay/GeneratedVideo { + public static final field $stable I public fun (Ljava/io/File;IJ)V public final fun component1 ()Ljava/io/File; public final fun component2 ()I @@ -26,6 +28,11 @@ public final class io/sentry/android/replay/GeneratedVideo { public fun toString ()Ljava/lang/String; } +public final class io/sentry/android/replay/ModifierExtensionsKt { + public static final fun sentryReplayIgnore (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayRedact (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; +} + public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { public abstract fun pause ()V public abstract fun resume ()V @@ -34,6 +41,7 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V @@ -50,6 +58,7 @@ public final class io/sentry/android/replay/ReplayCache$Companion { } public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, java/io/Closeable { + public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -78,6 +87,7 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb } public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; public fun (IIFFII)V public final fun component1 ()I @@ -103,6 +113,12 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } +public final class io/sentry/android/replay/SentryReplayModifiers { + public static final field $stable I + public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; + public final fun getSentryPrivacy ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; +} + public final class io/sentry/android/replay/SessionReplayOptionsKt { public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z @@ -116,12 +132,14 @@ public final class io/sentry/android/replay/ViewExtensionsKt { } public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { + public static final field $stable I public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V public fun onRootViewsChanged (Landroid/view/View;Z)V public final fun stop ()V } public final class io/sentry/android/replay/gestures/ReplayGestureConverter { + public static final field $stable I public fun (Lio/sentry/transport/ICurrentDateProvider;)V public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; } @@ -130,6 +148,19 @@ public abstract interface class io/sentry/android/replay/gestures/TouchRecorderC public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } +public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { + public static final field $stable I + public fun (Landroid/text/Layout;)V + public fun getDominantTextColor ()Ljava/lang/Integer; + public fun getEllipsisCount (I)I + public fun getLineBottom (I)I + public fun getLineCount ()I + public fun getLineStart (I)I + public fun getLineTop (I)I + public fun getLineVisibleEnd (I)I + public fun getPrimaryHorizontal (II)F +} + public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { public final field delegate Landroid/view/Window$Callback; public fun (Landroid/view/Window$Callback;)V @@ -160,6 +191,17 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } +public abstract interface class io/sentry/android/replay/util/TextLayout { + public abstract fun getDominantTextColor ()Ljava/lang/Integer; + public abstract fun getEllipsisCount (I)I + public abstract fun getLineBottom (I)I + public abstract fun getLineCount ()I + public abstract fun getLineStart (I)I + public abstract fun getLineTop (I)I + public abstract fun getLineVisibleEnd (I)I + public abstract fun getPrimaryHorizontal (II)F +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z @@ -169,6 +211,7 @@ public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer } public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public static final field $stable I public fun (Ljava/lang/String;F)V public fun getVideoTime ()J public fun isStarted ()Z @@ -178,6 +221,7 @@ public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentr } public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -195,6 +239,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z public final fun isVisible ()Z public final fun setChildren (Ljava/util/List;)V + public final fun setImportantForCaptureToAncestors (Z)V public final fun setImportantForContentCapture (Z)V public final fun traverse (Lkotlin/jvm/functions/Function1;)V } @@ -204,20 +249,23 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Comp } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final field $stable I + public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDominantColor ()Ljava/lang/Integer; - public final fun getLayout ()Landroid/text/Layout; + public final fun getLayout ()Lio/sentry/android/replay/util/TextLayout; public final fun getPaddingLeft ()I public final fun getPaddingTop ()I } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2e74641268..15713bb6f4 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") @@ -25,9 +26,20 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + useLiveLiterals = false + } + buildTypes { getByName("debug") - getByName("release") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } } kotlinOptions { @@ -65,6 +77,7 @@ kotlin { dependencies { api(projects.sentry) + compileOnly(Config.Libs.composeUiReplay) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests @@ -77,9 +90,19 @@ dependencies { testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) + testImplementation(Config.Libs.composeActivity) + testImplementation(Config.Libs.composeUi) + testImplementation(Config.Libs.composeCoil) + testImplementation(Config.Libs.composeFoundation) + testImplementation(Config.Libs.composeFoundationLayout) + testImplementation(Config.Libs.composeMaterial) } tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } + +tasks.withType>().configureEach { + compilerOptions.freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 738204b4c8..445c89b526 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -1,3 +1,20 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -keepattributes SourceFile,LineNumberTable + +# Rules to detect Images/Icons and redact them +-dontwarn androidx.compose.ui.graphics.painter.Painter +-keepnames class * extends androidx.compose.ui.graphics.painter.Painter +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.painter.Painter painter; +} +# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later redact them +-dontwarn androidx.compose.ui.graphics.ColorProducer +-dontwarn androidx.compose.foundation.layout.FillElement +-keepnames class androidx.compose.foundation.layout.FillElement +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.ColorProducer color; +} +# Rules to detect a compose view to parse its hierarchy +-dontwarn androidx.compose.ui.platform.AndroidComposeView +-keepnames class androidx.compose.ui.platform.AndroidComposeView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt new file mode 100644 index 0000000000..b1b119a89c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt @@ -0,0 +1,29 @@ +package io.sentry.android.replay + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics +import io.sentry.android.replay.SentryReplayModifiers.SentryPrivacy + +public object SentryReplayModifiers { + val SentryPrivacy = SemanticsPropertyKey( + name = "SentryPrivacy", + mergePolicy = { parentValue, _ -> parentValue } + ) +} + +public fun Modifier.sentryReplayRedact(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "redact" + } + ) +} + +public fun Modifier.sentryReplayIgnore(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "ignore" + } + ) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 0ea3fad6ab..5b779babe0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -13,10 +13,8 @@ import android.graphics.Rect import android.graphics.RectF import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import android.util.Log import android.view.PixelCopy import android.view.View -import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowManager import io.sentry.SentryLevel.DEBUG @@ -25,10 +23,10 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler -import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely +import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -102,7 +100,6 @@ internal class ScreenshotRecorder( Bitmap.Config.ARGB_8888 ) - val timeStart = System.nanoTime() // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible mainLooperHandler.post { try { @@ -117,6 +114,7 @@ internal class ScreenshotRecorder( return@request } + // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) if (contentChanged.get()) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() @@ -124,9 +122,7 @@ internal class ScreenshotRecorder( } val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy) - val timeEnd = System.nanoTime() - Log.e("TIME", String.format("%.2f", ((timeEnd - timeStart) / 1_000_000.0))) + root.traverse(viewHierarchy, options) recorder.submitSafely(options, "screenshot_recorder.redact") { val canvas = Canvas(bitmap) @@ -147,7 +143,7 @@ internal class ScreenshotRecorder( } is TextViewHierarchyNode -> { - val textColor = node.layout.dominantTextColor + val textColor = node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK node.layout.getVisibleRects( @@ -206,6 +202,8 @@ internal class ScreenshotRecorder( // next bind the new root rootView = WeakReference(root) root.viewTreeObserver?.addOnDrawListener(this) + // invalidate the flag to capture the first frame after new window is attached + contentChanged.set(true) } fun unbind(root: View?) { @@ -255,28 +253,6 @@ internal class ScreenshotRecorder( return singlePixelBitmap.getPixel(0, 0) } - private fun View.traverse(parentNode: ViewHierarchyNode) { - if (this !is ViewGroup) { - return - } - - if (this.childCount == 0) { - return - } - - val childNodes = ArrayList(this.childCount) - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child != null) { - val childNode = - ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) - childNodes.add(childNode) - child.traverse(childNode) - } - } - parentNode.children = childNodes - } - private class RecorderExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt new file mode 100644 index 0000000000..12152f50cb --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -0,0 +1,206 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes + +package io.sentry.android.replay.util + +import android.graphics.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.roundToInt + +internal class ComposeTextLayout(internal val layout: TextLayoutResult, private val hasFillModifier: Boolean) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() = null + override fun getPrimaryHorizontal(line: Int, offset: Int): Float { + val horizontalPos = layout.getHorizontalPosition(offset, usePrimaryDirection = true) + // when there's no `fill` modifier on a Text composable, compose still thinks that there's + // one and wrongly calculates horizontal position relative to node's start, not text's start + // for some reason. This is only the case for single-line text (multiline works fien). + // So we subtract line's left to get the correct position + return if (!hasFillModifier && lineCount == 1) { + horizontalPos - layout.getLineLeft(line) + } else { + horizontalPos + } + } + override fun getEllipsisCount(line: Int): Int = if (layout.isLineEllipsized(line)) 1 else 0 + override fun getLineVisibleEnd(line: Int): Int = layout.getLineEnd(line, visibleEnd = true) + override fun getLineTop(line: Int): Int = layout.getLineTop(line).roundToInt() + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line).roundToInt() + override fun getLineStart(line: Int): Int = layout.getLineStart(line) +} + +// TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime + +/** + * This method is necessary to redact images in Compose. + * + * We heuristically look up for classes that have a [Painter] modifier, usually they all have a + * `Painter` string in their name, e.g. PainterElement, PainterModifierNodeElement or + * ContentPainterModifier for Coil. + * + * That's not going to cover all cases, but probably 90%. + * + * We also add special proguard rules to keep the `Painter` class names and their `painter` member. + */ +internal fun LayoutNode.findPainter(): Painter? { + val modifierInfos = getModifierInfo() + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + if (modifier::class.java.name.contains("Painter")) { + return try { + modifier::class.java.getDeclaredField("painter") + .apply { isAccessible = true } + .get(modifier) as? Painter + } catch (e: Throwable) { + null + } + } + } + return null +} + +/** + * We heuristically check the known classes that are coming from local assets usually: + * [androidx.compose.ui.graphics.vector.VectorPainter] + * [androidx.compose.ui.graphics.painter.ColorPainter] + * [androidx.compose.ui.graphics.painter.BrushPainter] + * + * In theory, [androidx.compose.ui.graphics.painter.BitmapPainter] can also come from local assets, + * but it can as well come from a network resource, so we preemptively redact it. + */ +internal fun Painter.isRedactable(): Boolean { + val className = this::class.java.name + return !className.contains("Vector") && + !className.contains("Color") && + !className.contains("Brush") +} + +internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) + +/** + * This method is necessary to redact text in Compose. + * + * We heuristically look up for classes that have a [Text] modifier, usually they all have a + * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then + * get the color from the modifier, to be able to redact it with the correct color. + * + * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in + * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line + * text composable without a `fill` modifier still thinks that there's one and wrongly calculates + * horizontal position. + * + * We also add special proguard rules to keep the `Text` class names and their `color` member. + */ +internal fun LayoutNode.findTextAttributes(): TextAttributes { + val modifierInfos = getModifierInfo() + var color: Color? = null + var hasFillModifier = false + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + val modifierClassName = modifier::class.java.name + if (modifierClassName.contains("Text")) { + color = try { + ( + modifier::class.java.getDeclaredField("color") + .apply { isAccessible = true } + .get(modifier) as? ColorProducer + ) + ?.invoke() + } catch (e: Throwable) { + null + } + } else if (modifierClassName.contains("Fill")) { + hasFillModifier = true + } + } + return TextAttributes(color, hasFillModifier) +} + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private inline fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private inline fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} + +/** + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect { + root ?: return Rect() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect() + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt new file mode 100644 index 0000000000..cd07c6d170 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -0,0 +1,21 @@ +package io.sentry.android.replay.util + +/** + * An abstraction over [android.text.Layout] with different implementations for Views and Compose. + */ +interface TextLayout { + val lineCount: Int + + /** + * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if + * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it + * returns null. + */ + val dominantTextColor: Int? + fun getPrimaryHorizontal(line: Int, offset: Int): Float + fun getEllipsisCount(line: Int): Int + fun getLineVisibleEnd(line: Int): Int + fun getLineTop(line: Int): Int + fun getLineBottom(line: Int): Int + fun getLineStart(line: Int): Int +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 86c75f2e9d..1c6111c1b0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -16,9 +16,45 @@ import android.text.Layout import android.text.Spanned import android.text.style.ForegroundColorSpan import android.view.View +import android.view.ViewGroup import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.NullPointerException +/** + * Recursively traverses the view hierarchy and creates a [ViewHierarchyNode] for each view. + * Supports Compose view hierarchy as well. + */ +internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + if (this !is ViewGroup) { + return + } + + if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { + // if it's a compose view, we can skip the children as they are already traversed in + // the ComposeViewHierarchyNode.fromView method + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = + ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + childNodes.add(childNode) + child.traverse(childNode, options) + } + } + parentNode.children = childNodes +} + /** * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 */ @@ -65,19 +101,20 @@ internal fun Drawable?.isRedactable(): Boolean { } } -internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { +internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { if (this == null) { return listOf(globalRect) } val rects = mutableListOf() for (i in 0 until lineCount) { - val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt() + val lineStart = getPrimaryHorizontal(i, getLineStart(i)).toInt() val ellipsisCount = getEllipsisCount(i) - var lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() - if (lineEnd == 0) { + val lineVisibleEnd = getLineVisibleEnd(i) + var lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() + if (lineEnd == 0 && lineVisibleEnd > 0) { // looks like the case for when emojis are present in text - lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - 1).toInt() + 1 + lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - 1).toInt() + 1 } val lineTop = getLineTop(i) val lineBottom = getLineBottom(i) @@ -105,32 +142,39 @@ internal val TextView.totalPaddingTopSafe: Int } /** - * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if - * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it - * returns null. + * Converts an [Int] ARGB color to an opaque color by setting the alpha channel to 255. */ -internal val Layout?.dominantTextColor: Int? get() { - this ?: return null +internal fun Int.toOpaque() = this or 0xFF000000.toInt() - if (text !is Spanned) return null +class AndroidTextLayout(private val layout: Layout) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() { + if (layout.text !is Spanned) return null - val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) + val spans = (layout.text as Spanned).getSpans(0, layout.text.length, ForegroundColorSpan::class.java) - // determine the dominant color by the span with the longest range - var longestSpan = Int.MIN_VALUE - var dominantColor: Int? = null - for (span in spans) { - val spanStart = (text as Spanned).getSpanStart(span) - val spanEnd = (text as Spanned).getSpanEnd(span) - if (spanStart == -1 || spanEnd == -1) { - // the span is not attached - continue - } - val spanLength = spanEnd - spanStart - if (spanLength > longestSpan) { - longestSpan = spanLength - dominantColor = span.foregroundColor + // determine the dominant color by the span with the longest range + var longestSpan = Int.MIN_VALUE + var dominantColor: Int? = null + for (span in spans) { + val spanStart = (layout.text as Spanned).getSpanStart(span) + val spanEnd = (layout.text as Spanned).getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) { + // the span is not attached + continue + } + val spanLength = spanEnd - spanStart + if (spanLength > longestSpan) { + longestSpan = spanLength + dominantColor = span.foregroundColor + } } + return dominantColor?.toOpaque() } - return dominantColor + override fun getPrimaryHorizontal(line: Int, offset: Int): Float = layout.getPrimaryHorizontal(offset) + override fun getEllipsisCount(line: Int): Int = layout.getEllipsisCount(line) + override fun getLineVisibleEnd(line: Int): Int = layout.getLineVisibleEnd(line) + override fun getLineTop(line: Int): Int = layout.getLineTop(line) + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line) + override fun getLineStart(line: Int): Int = layout.getLineStart(line) } 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 new file mode 100644 index 0000000000..c611b91b47 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -0,0 +1,213 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.view.View +import androidx.compose.ui.graphics.isUnspecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.text.TextLayoutResult +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.SentryReplayModifiers +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.boundsInWindow +import io.sentry.android.replay.util.findPainter +import io.sentry.android.replay.util.findTextAttributes +import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode + +@TargetApi(26) +internal object ComposeViewHierarchyNode { + + /** + * 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 { + return when { + isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + collapsedSemantics?.contains(SemanticsProperties.Text) == true || + collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + else -> "android.view.View" + } + } + + private fun LayoutNode.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + if (sentryPrivacyModifier == "ignore") { + return false + } + + if (sentryPrivacyModifier == "redact") { + return true + } + + val className = getProxyClassName(isImage) + if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { + return false + } + + return options.experimental.sessionReplay.redactViewClasses.contains(className) + } + + private var _rootCoordinates: LayoutCoordinates? = null + + private fun fromComposeNode( + node: LayoutNode, + parent: ViewHierarchyNode?, + distance: Int, + isComposeRoot: Boolean, + options: SentryOptions + ): ViewHierarchyNode? { + val isInTree = node.isPlaced && node.isAttached + if (!isInTree) { + return null + } + + if (isComposeRoot) { + _rootCoordinates = node.coordinates.findRootCoordinates() + } + + val semantics = node.collapsedSemantics + val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates) + val isVisible = !node.outerCoordinator.isTransparent() && + (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && + visibleRect.height() > 0 && visibleRect.width() > 0 + val isEditable = semantics?.contains(SemanticsActions.SetText) == true + val positionInWindow = node.coordinates.positionInWindow() + return when { + semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { + val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + + parent?.setImportantForCaptureToAncestors(true) + val textLayoutResults = mutableListOf() + semantics?.getOrNull(SemanticsActions.GetTextLayoutResult) + ?.action + ?.invoke(textLayoutResults) + + val (color, hasFillModifier) = node.findTextAttributes() + var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + if (textColor?.isUnspecified == true) { + textColor = color + } + // TODO: support multiple text layouts + // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) + TextViewHierarchyNode( + layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + dominantColor = textColor?.toArgb()?.toOpaque(), + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + else -> { + val painter = node.findPainter() + if (painter != null) { + val shouldRedact = isVisible && node.shouldRedact(isImage = true, options) + + parent?.setImportantForCaptureToAncestors(true) + ImageViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldRedact = shouldRedact && painter.isRedactable(), + visibleRect = visibleRect + ) + } else { + val shouldRedact = isVisible && node.shouldRedact(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 + // TODO: using custom modifiers to obscure the entire node if it's sensitive + GenericViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } + } + } + + fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { + if (!view::class.java.name.contains("AndroidComposeView")) { + return false + } + + if (parent == null) { + return false + } + + try { + val rootNode = (view as? Owner)?.root ?: return false + rootNode.traverse(parent, isComposeRoot = true, options) + } catch (e: Throwable) { + options.logger.log( + SentryLevel.ERROR, + e, + """ + Error traversing Compose tree. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """.trimIndent() + ) + return false + } + + return true + } + + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, isComposeRoot: Boolean, options: SentryOptions) { + val children = this.children + if (children.isEmpty()) { + return + } + + val childNodes = ArrayList(children.size) + for (index in children.indices) { + val child = children[index] + val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options) + if (childNode != null) { + childNodes.add(childNode) + child.traverse(childNode, isComposeRoot = false, options) + } + } + parentNode.children = childNodes + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 90b96f134b..a231e4f3d2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -2,14 +2,16 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect -import android.text.Layout import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.R +import io.sentry.android.replay.util.AndroidTextLayout +import io.sentry.android.replay.util.TextLayout import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser +import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe @TargetApi(26) @@ -46,7 +48,7 @@ sealed class ViewHierarchyNode( ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) class TextViewHierarchyNode( - val layout: Layout? = null, + val layout: TextLayout? = null, val dominantColor: Int? = null, val paddingLeft: Int = 0, val paddingTop: Int = 0, @@ -77,6 +79,20 @@ sealed class ViewHierarchyNode( visibleRect: Rect? = null ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + fun setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } + /** * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first * manner. @@ -217,23 +233,6 @@ sealed class ViewHierarchyNode( ) companion object { - - private fun Int.toOpaque() = this or 0xFF000000.toInt() - - /** - * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() - * but for lower APIs and with less overhead. If we take a look at how it's set in Android: - * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain - * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. - */ - private fun ViewHierarchyNode?.setImportantForCaptureToAncestors(isImportant: Boolean) { - var parent = this?.parent - while (parent != null) { - parent.isImportantForContentCapture = isImportant - parent = parent.parent - } - } - private const val SENTRY_IGNORE_TAG = "sentry-ignore" private const val SENTRY_REDACT_TAG = "sentry-redact" @@ -274,9 +273,9 @@ sealed class ViewHierarchyNode( val shouldRedact = isVisible && view.shouldRedact(options) when (view) { is TextView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( - layout = view.layout, + layout = view.layout?.let { AndroidTextLayout(it) }, dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, @@ -295,7 +294,7 @@ sealed class ViewHierarchyNode( } is ImageView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, y = view.y, diff --git a/sentry-android-replay/src/test/AndroidManifest.xml b/sentry-android-replay/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..c8f45a53bb --- /dev/null +++ b/sentry-android-replay/src/test/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt index ec545ed109..9a5b805ad7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -36,7 +36,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertNull(node.layout.dominantTextColor) + assertNull(node.layout?.dominantTextColor) } @Test @@ -55,7 +55,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.RED, node.layout.dominantTextColor) + assertEquals(Color.RED, node.layout?.dominantTextColor) } @Test @@ -75,7 +75,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.BLACK, node.layout.dominantTextColor) + assertEquals(Color.BLACK, node.layout?.dominantTextColor) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt new file mode 100644 index 0000000000..981e351408 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt @@ -0,0 +1,240 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.compose.AsyncImage +import io.sentry.SentryOptions +import io.sentry.android.replay.redactAllImages +import io.sentry.android.replay.redactAllText +import io.sentry.android.replay.sentryReplayIgnore +import io.sentry.android.replay.sentryReplayRedact +import io.sentry.android.replay.util.ComposeTextLayout +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.Before +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeRedactionOptionsTest { + + @Before + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + ComposeRedactionOptionsActivity.textModifierApplier = null + ComposeRedactionOptionsActivity.containerModifierApplier = null + } + + @Test + fun `when redactAllText is set all Text nodes are redacted`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.all { it.shouldRedact }) + // just a sanity check for parsing the tree + assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + } + + @Test + fun `when redactAllText is set to false all Text nodes are ignored`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.none { it.shouldRedact }) + } + + @Test + fun `when redactAllImages is set all Image nodes are redacted`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = true + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.all { it.shouldRedact }) + } + + @Test + fun `when redactAllImages is set to false all Image nodes are ignored`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = false + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.none { it.shouldRedact }) + } + + @Test + fun `when sentry-redact modifier is set redacts the node`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayRedact() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertTrue(it.shouldRedact) + } else { + assertFalse(it.shouldRedact) + } + } + } + + @Test + fun `when sentry-ignore modifier is set ignores the node`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayIgnore() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldRedact) + } else { + assertTrue(it.shouldRedact) + } + } + } + + @Test + fun `when view is not visible, does not redact the view`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldRedact) + } else { + assertTrue(it.shouldRedact) + } + } + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + ComposeRedactionOptionsActivity.containerModifierApplier = { Modifier.sentryReplayIgnore() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions() + + val allNodes = activity.get().collectNodesOfType(options) + val imageNodes = allNodes.filterIsInstance() + val textNodes = allNodes.filterIsInstance() + val genericNodes = allNodes.filterIsInstance() + assertTrue(imageNodes.all { it.shouldRedact }) + assertTrue(textNodes.all { it.shouldRedact }) + assertTrue(genericNodes.none { it.shouldRedact }) + } + + private inline fun Activity.collectNodesOfType(options: SentryOptions): List { + val root = window.decorView + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + val nodes = mutableListOf() + viewHierarchy.traverse { + if (it is T) { + nodes += it + } + return@traverse true + } + return nodes + } +} + +private class ComposeRedactionOptionsActivity : ComponentActivity() { + + companion object { + var textModifierApplier: (() -> Modifier)? = null + var containerModifierApplier: (() -> Modifier)? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + + setContent { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .then(containerModifierApplier?.invoke() ?: Modifier) + ) { + AsyncImage( + model = Uri.fromFile(File(image.toURI())), + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) + TextField( + value = TextFieldValue("Placeholder"), + onValueChange = { _ -> } + ) + Text("Random repo") + Button( + onClick = {}, + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) + ) { + Text("Make Request", modifier = Modifier.then(textModifierApplier?.invoke() ?: Modifier)) + } + } + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt index 8ffffd046d..c1a50f7a62 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt @@ -20,10 +20,10 @@ import io.sentry.android.replay.sentryReplayIgnore import io.sentry.android.replay.sentryReplayRedact import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode -import org.junit.Before import org.junit.runner.RunWith import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -32,21 +32,21 @@ import kotlin.test.assertTrue @Config(sdk = [30]) class RedactionOptionsTest { - @Before + @BeforeTest fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") } @Test fun `when redactAllText is set all TextView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode is TextViewHierarchyNode) assertTrue(textNode.shouldRedact) @@ -57,14 +57,14 @@ class RedactionOptionsTest { @Test fun `when redactAllText is set to false all TextView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode is TextViewHierarchyNode) assertFalse(textNode.shouldRedact) @@ -75,13 +75,13 @@ class RedactionOptionsTest { @Test fun `when redactAllImages is set all ImageView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllImages = true } - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertTrue(imageNode is ImageViewHierarchyNode) assertTrue(imageNode.shouldRedact) @@ -89,13 +89,13 @@ class RedactionOptionsTest { @Test fun `when redactAllImages is set to false all ImageView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllImages = false } - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertTrue(imageNode is ImageViewHierarchyNode) assertFalse(imageNode.shouldRedact) @@ -103,98 +103,98 @@ class RedactionOptionsTest { @Test fun `when sentry-redact tag is set redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - ExampleActivity.textView!!.tag = "sentry-redact" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.tag = "sentry-redact" + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertTrue(textNode.shouldRedact) } @Test fun `when sentry-ignore tag is set ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.tag = "sentry-ignore" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.tag = "sentry-ignore" + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when sentry-privacy tag is set to redact redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - ExampleActivity.textView!!.sentryReplayRedact() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.sentryReplayRedact() + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertTrue(textNode.shouldRedact) } @Test fun `when sentry-privacy tag is set to ignore ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.sentryReplayIgnore() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.sentryReplayIgnore() + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when view is not visible, does not redact the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.visibility = View.GONE - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when added to redact list redacts custom view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) } - val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.customView!!, null, 0, options) + val customViewNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.customView!!, null, 0, options) assertTrue(customViewNode.shouldRedact) } @Test fun `when subclass is added to ignored classes ignores all instances of that class`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true // all TextView subclasses experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode.shouldRedact) assertFalse(radioButtonNode.shouldRedact) @@ -202,15 +202,15 @@ class RedactionOptionsTest { @Test fun `when a container view is ignored its children are not ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) } - val linearLayoutNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!.parent as LinearLayout, null, 0, options) - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val linearLayoutNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertFalse(linearLayoutNode.shouldRedact) assertTrue(textNode.shouldRedact) @@ -226,7 +226,7 @@ private class CustomView(context: Context) : View(context) { } } -private class ExampleActivity : Activity() { +private class RedactionOptionsActivity : Activity() { companion object { var textView: TextView? = null diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index a8d8897519..204ef83fc2 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -132,6 +132,7 @@ dependencies { implementation(Config.Libs.composeFoundationLayout) implementation(Config.Libs.composeNavigation) implementation(Config.Libs.composeMaterial) + implementation(Config.Libs.composeCoil) debugImplementation(Config.Libs.leakCanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 997fa5ff55..703685d6f0 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -166,6 +166,7 @@ - + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 1a4929b0b7..03d9e8d049 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -5,6 +5,7 @@ package io.sentry.samples.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -22,7 +23,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -31,10 +35,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import coil.compose.AsyncImage +import io.sentry.android.replay.sentryReplayIgnore import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI import kotlinx.coroutines.launch +import io.sentry.samples.android.R as IR class ComposeActivity : ComponentActivity() { @@ -109,6 +116,17 @@ fun Github( modifier = Modifier .fillMaxSize() ) { + Image( + painter = painterResource(IR.drawable.sentry_glyph), + contentDescription = "LOGO", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.padding(vertical = 16.dp) + ) + AsyncImage( + model = "https://i.imgur.com/tie6A3J.jpeg", + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) TextField( value = user, onValueChange = { newText -> @@ -127,7 +145,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request") + Text("Make Request", modifier = Modifier.sentryReplayIgnore()) } } } diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml new file mode 100644 index 0000000000..28a3442987 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml @@ -0,0 +1,9 @@ + + +