From 2107b0c4bccea922026841d89fff70e510f6adfd Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 5 Sep 2024 13:54:01 +0200 Subject: [PATCH 1/7] Correctly infer text color for RN views --- .../replay/viewhierarchy/ViewHierarchyNode.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 145cefff3d..b2166bdbe7 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 @@ -3,6 +3,8 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect import android.text.Layout +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.view.View import android.widget.ImageView import android.widget.TextView @@ -219,6 +221,30 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() + private val TextView.dominantTextColor: Int get() { + if (text !is Spqanned) return currentTextColor + + val spans = (text as Spanned).getSpans(0, 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 + } + } + return dominantColor ?: currentTextColor + } + /** * 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: @@ -244,7 +270,7 @@ sealed class ViewHierarchyNode( parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, - dominantColor = view.currentTextColor.toOpaque(), + dominantColor = view.dominantTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, x = view.x, From bee9697fe31e8a2e35d31c4470d8f0100747fb94 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 5 Sep 2024 13:55:18 +0200 Subject: [PATCH 2/7] Fix --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b2166bdbe7..995176ff53 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 @@ -222,7 +222,7 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() private val TextView.dominantTextColor: Int get() { - if (text !is Spqanned) return currentTextColor + if (text !is Spanned) return currentTextColor val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) From a76e2239f8df8f165dad4ce368b420ffa6663a85 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 11:20:00 +0200 Subject: [PATCH 3/7] Add tests --- .../io/sentry/android/replay/util/Views.kt | 26 ++++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 25 +-------- .../replay/util/TextViewDominantColorTest.kt | 52 +++++++++++++++++++ 3 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt 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 a44508eac6..002b650c38 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 @@ -13,6 +13,8 @@ import android.graphics.drawable.VectorDrawable import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.text.Layout +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.view.View import android.widget.TextView import java.lang.NullPointerException @@ -101,3 +103,27 @@ internal val TextView.totalPaddingTopSafe: Int } catch (e: NullPointerException) { extendedPaddingTop } + +internal val TextView.dominantTextColor: Int get() { + if (text !is Spanned) return currentTextColor + + val spans = (text as Spanned).getSpans(0, 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 + } + } + return dominantColor ?: currentTextColor +} 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 995176ff53..cc9f0eff73 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 @@ -9,6 +9,7 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions +import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.totalPaddingTopSafe @@ -221,30 +222,6 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() - private val TextView.dominantTextColor: Int get() { - if (text !is Spanned) return currentTextColor - - val spans = (text as Spanned).getSpans(0, 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 - } - } - return dominantColor ?: currentTextColor - } - /** * 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: 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 new file mode 100644 index 0000000000..a73553baf7 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -0,0 +1,52 @@ +package io.sentry.android.replay.util + +import android.graphics.Color +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class TextViewDominantColorTest { + + @Test + fun `when no spans, returns currentTextColor`() { + val textView = TextView(ApplicationProvider.getApplicationContext()) + textView.text = "Hello, World!" + textView.setTextColor(Color.WHITE) + + assertEquals(Color.WHITE, textView.dominantTextColor) + } + + @Test + fun `when has a foreground color span, returns its color`() { + val textView = TextView(ApplicationProvider.getApplicationContext()) + val text = "Hello, World!" + textView.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + textView.setTextColor(Color.WHITE) + + assertEquals(Color.RED, textView.dominantTextColor) + } + + @Test + fun `when has multiple foreground color spans, returns color of the longest span`() { + val textView = TextView(ApplicationProvider.getApplicationContext()) + val text = "Hello, World!" + textView.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, 5, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + setSpan(ForegroundColorSpan(Color.BLACK), 6, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + textView.setTextColor(Color.WHITE) + + assertEquals(Color.BLACK, textView.dominantTextColor) + } +} From 91e86baf43ad578d5c221b1ecc428cc8dbd601b1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 11:20:44 +0200 Subject: [PATCH 4/7] Formatting --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 2 -- 1 file changed, 2 deletions(-) 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 cc9f0eff73..b5fc67f708 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 @@ -3,8 +3,6 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect import android.text.Layout -import android.text.Spanned -import android.text.style.ForegroundColorSpan import android.view.View import android.widget.ImageView import android.widget.TextView From c46fcc0972f92a32a007e5b3cf8aa4d6b7588a23 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 11:24:44 +0200 Subject: [PATCH 5/7] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418c20a938..cbedaf37f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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)) *Breaking changes*: From 27e834c61d21d48340ca820150c0d82b245b7298 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 10 Sep 2024 11:00:36 +0200 Subject: [PATCH 6/7] Move spans traversal to background thread --- .../android/replay/ScreenshotRecorder.kt | 8 +- .../io/sentry/android/replay/util/Views.kt | 13 +++- .../replay/viewhierarchy/ViewHierarchyNode.kt | 2 +- .../replay/util/TextViewDominantColorTest.kt | 78 +++++++++++++++---- 4 files changed, 81 insertions(+), 20 deletions(-) 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 5ec142b3e5..fdab9f442d 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 @@ -24,6 +24,7 @@ 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 @@ -142,13 +143,14 @@ internal class ScreenshotRecorder( } is TextViewHierarchyNode -> { - // TODO: find a way to get the correct text color for RN - // TODO: now it always returns black + val textColor = node.layout.dominantTextColor + ?: node.dominantColor + ?: Color.BLACK node.layout.getVisibleRects( node.visibleRect, node.paddingLeft, node.paddingTop - ) to (node.dominantColor ?: Color.BLACK) + ) to textColor } else -> { 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 002b650c38..86c75f2e9d 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 @@ -104,8 +104,15 @@ internal val TextView.totalPaddingTopSafe: Int extendedPaddingTop } -internal val TextView.dominantTextColor: Int get() { - if (text !is Spanned) return currentTextColor +/** + * 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. + */ +internal val Layout?.dominantTextColor: Int? get() { + this ?: return null + + if (text !is Spanned) return null val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) @@ -125,5 +132,5 @@ internal val TextView.dominantTextColor: Int get() { dominantColor = span.foregroundColor } } - return dominantColor ?: currentTextColor + return dominantColor } 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 b5fc67f708..0f5743ddd1 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 @@ -245,7 +245,7 @@ sealed class ViewHierarchyNode( parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, - dominantColor = view.dominantTextColor.toOpaque(), + dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, x = view.x, 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 a73553baf7..ec545ed109 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 @@ -1,16 +1,27 @@ package io.sentry.android.replay.util +import android.app.Activity import android.graphics.Color +import android.os.Bundle +import android.os.Looper import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams import android.widget.TextView -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) @@ -18,35 +29,76 @@ class TextViewDominantColorTest { @Test fun `when no spans, returns currentTextColor`() { - val textView = TextView(ApplicationProvider.getApplicationContext()) - textView.text = "Hello, World!" - textView.setTextColor(Color.WHITE) + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() - assertEquals(Color.WHITE, textView.dominantTextColor) + TextViewActivity.textView?.setTextColor(Color.WHITE) + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertNull(node.layout.dominantTextColor) } @Test fun `when has a foreground color span, returns its color`() { - val textView = TextView(ApplicationProvider.getApplicationContext()) + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + val text = "Hello, World!" - textView.text = SpannableString(text).apply { + TextViewActivity.textView?.text = SpannableString(text).apply { setSpan(ForegroundColorSpan(Color.RED), 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) } - textView.setTextColor(Color.WHITE) + TextViewActivity.textView?.setTextColor(Color.WHITE) + TextViewActivity.textView?.requestLayout() - assertEquals(Color.RED, textView.dominantTextColor) + shadowOf(Looper.getMainLooper()).idle() + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertEquals(Color.RED, node.layout.dominantTextColor) } @Test fun `when has multiple foreground color spans, returns color of the longest span`() { - val textView = TextView(ApplicationProvider.getApplicationContext()) + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + val text = "Hello, World!" - textView.text = SpannableString(text).apply { + TextViewActivity.textView?.text = SpannableString(text).apply { setSpan(ForegroundColorSpan(Color.RED), 0, 5, Spanned.SPAN_INCLUSIVE_INCLUSIVE) setSpan(ForegroundColorSpan(Color.BLACK), 6, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) } - textView.setTextColor(Color.WHITE) + TextViewActivity.textView?.setTextColor(Color.WHITE) + TextViewActivity.textView?.requestLayout() + + shadowOf(Looper.getMainLooper()).idle() + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertEquals(Color.BLACK, node.layout.dominantTextColor) + } +} + +private class TextViewActivity : Activity() { + + companion object { + var textView: TextView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) - assertEquals(Color.BLACK, textView.dominantTextColor) + setContentView(linearLayout) } } From e4db9aa355759113210bb9814927711e4e932187 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 10 Sep 2024 11:01:02 +0200 Subject: [PATCH 7/7] formatting --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 1 - 1 file changed, 1 deletion(-) 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 0f5743ddd1..145cefff3d 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 @@ -7,7 +7,6 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions -import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.totalPaddingTopSafe