Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SR] Detect dominant color for TextViews with Spans #3682

Merged
merged 8 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading