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*: 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 a44508eac6..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 @@ -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,34 @@ internal val TextView.totalPaddingTopSafe: Int } catch (e: NullPointerException) { extendedPaddingTop } + +/** + * 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) + + // 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 +} 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..ec545ed109 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -0,0 +1,104 @@ +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.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]) +class TextViewDominantColorTest { + + @Test + fun `when no spans, returns currentTextColor`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + 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 controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + val text = "Hello, World!" + TextViewActivity.textView?.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + 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.RED, node.layout.dominantTextColor) + } + + @Test + fun `when has multiple foreground color spans, returns color of the longest span`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + val text = "Hello, World!" + 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) + } + 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) + + setContentView(linearLayout) + } +}