Skip to content

Commit

Permalink
RUM-6871: Add Material Chip mapper and improve CompoundButton telemetry
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Oct 31, 2024
1 parent a1b97be commit f83c4c1
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.cardview.widget.CardView
import com.datadog.android.sessionreplay.ExtensionSupport
import com.datadog.android.sessionreplay.MapperTypeWrapper
import com.datadog.android.sessionreplay.material.internal.CardWireframeMapper
import com.datadog.android.sessionreplay.material.internal.ChipWireframeMapper
import com.datadog.android.sessionreplay.material.internal.MaterialDrawableToColorMapper
import com.datadog.android.sessionreplay.material.internal.MaterialOptionSelectorDetector
import com.datadog.android.sessionreplay.material.internal.SliderWireframeMapper
Expand All @@ -23,6 +24,7 @@ import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver
import com.datadog.android.sessionreplay.utils.DrawableToColorMapper
import com.datadog.android.sessionreplay.utils.ViewBoundsResolver
import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver
import com.google.android.material.chip.Chip
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout

Expand Down Expand Up @@ -63,11 +65,18 @@ class MaterialExtensionSupport : ExtensionSupport {
viewBoundsResolver,
drawableToColorMapper
)
val chipWireframeMapper = ChipWireframeMapper(
viewIdentifierResolver,
colorStringFormatter,
viewBoundsResolver,
drawableToColorMapper
)

return listOf(
MapperTypeWrapper(Slider::class.java, sliderWireframeMapper),
MapperTypeWrapper(TabLayout.TabView::class.java, tabWireframeMapper),
MapperTypeWrapper(CardView::class.java, cardWireframeMapper)
MapperTypeWrapper(CardView::class.java, cardWireframeMapper),
MapperTypeWrapper(Chip::class.java, chipWireframeMapper)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.material.internal

import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.recorder.MappingContext
import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.DrawableToColorMapper
import com.datadog.android.sessionreplay.utils.ViewBoundsResolver
import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver
import com.google.android.material.chip.Chip

internal class ChipWireframeMapper(
viewIdentifierResolver: ViewIdentifierResolver,
colorStringFormatter: ColorStringFormatter,
viewBoundsResolver: ViewBoundsResolver,
drawableToColorMapper: DrawableToColorMapper
) : TextViewMapper<Chip>(
viewIdentifierResolver,
colorStringFormatter,
viewBoundsResolver,
drawableToColorMapper
) {
override fun map(
view: Chip,
mappingContext: MappingContext,
asyncJobStatusCallback: AsyncJobStatusCallback,
internalLogger: InternalLogger
): List<MobileSegment.Wireframe> {
val wireframes = mutableListOf<MobileSegment.Wireframe>()

val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds(
view,
mappingContext.systemInformation.screenDensity
)
val density = mappingContext.systemInformation.screenDensity
val drawableBounds = view.chipDrawable.bounds
val backgroundWireframe = mappingContext.imageWireframeHelper.createImageWireframe(
view = view,
// Background drawable doesn't need to be masked.
imagePrivacy = ImagePrivacy.MASK_NONE,
currentWireframeIndex = 0,
x = viewGlobalBounds.x + drawableBounds.left.toLong().densityNormalized(density),
y = viewGlobalBounds.y + drawableBounds.top.toLong().densityNormalized(density),
width = view.chipDrawable.intrinsicWidth,
height = view.chipDrawable.intrinsicHeight,
usePIIPlaceholder = false,
drawable = view.chipDrawable,
asyncJobStatusCallback = asyncJobStatusCallback
)
backgroundWireframe?.let {
wireframes.add(it)
}
// Text wireframe
wireframes.add(super.createTextWireframe(view, mappingContext, viewGlobalBounds))
return wireframes.toList()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.material

import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.Layout
import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.material.forge.ForgeConfigurator
import com.datadog.android.sessionreplay.material.internal.ChipWireframeMapper
import com.datadog.android.sessionreplay.material.internal.densityNormalized
import com.datadog.android.sessionreplay.recorder.MappingContext
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.DrawableToColorMapper
import com.datadog.android.sessionreplay.utils.GlobalBounds
import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE
import com.datadog.android.sessionreplay.utils.ViewBoundsResolver
import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver
import com.google.android.material.chip.Chip
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.annotation.FloatForgery
import fr.xgouchet.elmyr.annotation.Forgery
import fr.xgouchet.elmyr.annotation.IntForgery
import fr.xgouchet.elmyr.annotation.LongForgery
import fr.xgouchet.elmyr.annotation.StringForgery
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.mockito.quality.Strictness

@Extensions(
ExtendWith(MockitoExtension::class),
ExtendWith(ForgeExtension::class)
)
@MockitoSettings(strictness = Strictness.LENIENT)
@ForgeConfiguration(value = ForgeConfigurator::class)
class ChipWireframeMapperTest {

private lateinit var testedChipWireframeMapper: ChipWireframeMapper

@Mock
lateinit var mockChipView: Chip

@Mock
lateinit var mockViewBoundsResolver: ViewBoundsResolver

@Mock
lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback

@Mock
lateinit var mockInternalLogger: InternalLogger

@Mock
lateinit var mockViewIdentifierResolver: ViewIdentifierResolver

@Mock
lateinit var mockColorStringFormatter: ColorStringFormatter

@Mock
lateinit var mockDrawableToColorMapper: DrawableToColorMapper

@Mock
lateinit var mockChipDrawable: Drawable

@LongForgery
var fakeViewId: Long = 0L

@StringForgery
var fakeText: String = ""

@Forgery
lateinit var fakeMappingContext: MappingContext

@Forgery
lateinit var fakeGlobalBounds: GlobalBounds

lateinit var fakeDrawableBounds: Rect

@IntForgery
private var fakeDrawableHeight: Int = 0

@IntForgery
private var fakeDrawableWidth: Int = 0

@Mock
lateinit var mockLayout: Layout

@FloatForgery(0f, 255f)
var fakeFontSize: Float = 0f

@IntForgery(min = 0, max = 0xffffff)
var fakeTextColor: Int = 0

@BeforeEach
fun `set up`(forge: Forge) {
fakeDrawableBounds = Rect(
forge.aSmallInt(),
forge.aSmallInt(),
forge.aSmallInt(),
forge.aSmallInt()
)
mockChipView = mockChip()
whenever(
mockViewBoundsResolver.resolveViewGlobalBounds(
mockChipView,
fakeMappingContext.systemInformation.screenDensity
)
).thenReturn(fakeGlobalBounds)
whenever(
mockViewIdentifierResolver.resolveViewId(
mockChipView
)
).thenReturn(fakeViewId)
testedChipWireframeMapper = ChipWireframeMapper(
viewIdentifierResolver = mockViewIdentifierResolver,
colorStringFormatter = mockColorStringFormatter,
viewBoundsResolver = mockViewBoundsResolver,
drawableToColorMapper = mockDrawableToColorMapper
)
}

@Test
fun `M resolves card view wireframe W map`() {
// When
testedChipWireframeMapper.map(
mockChipView,
fakeMappingContext,
mockAsyncJobStatusCallback,
mockInternalLogger
)

// Then
val density = fakeMappingContext.systemInformation.screenDensity

verify(
fakeMappingContext.imageWireframeHelper,
times(1)
).createImageWireframe(
view = eq(mockChipView),
// Background drawable doesn't need to be masked.
imagePrivacy = eq(ImagePrivacy.MASK_NONE),
currentWireframeIndex = anyInt(),
x = eq(
fakeGlobalBounds.x + fakeDrawableBounds.left.toLong()
.densityNormalized(density)
),
y = eq(
fakeGlobalBounds.y + fakeDrawableBounds.top.toLong()
.densityNormalized(density)
),
width = eq(fakeDrawableWidth),
height = eq(fakeDrawableHeight),
usePIIPlaceholder = eq(false),
drawable = eq(mockChipDrawable),
drawableCopier = any(),
asyncJobStatusCallback = eq(mockAsyncJobStatusCallback),
clipping = isNull(),
shapeStyle = isNull(),
border = isNull(),
prefix = any()
)
}

private fun mockChip(): Chip {
return mock {
whenever(it.text).thenReturn(fakeText)
whenever(it.chipDrawable).thenReturn(mockChipDrawable)
whenever(mockChipDrawable.bounds).thenReturn(fakeDrawableBounds)
whenever(mockChipDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth)
whenever(mockChipDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight)

whenever(it.layout) doReturn mockLayout
whenever(it.typeface) doReturn Typeface.SERIF
whenever(it.textSize) doReturn fakeFontSize
whenever(it.currentTextColor) doReturn fakeTextColor
whenever(it.textAlignment) doReturn 0
whenever(it.gravity) doReturn 0
whenever(
mockColorStringFormatter.formatColorAndAlphaAsHexString(
fakeTextColor,
OPAQUE_ALPHA_VALUE
)
) doReturn ""
}
}
}
1 change: 1 addition & 0 deletions features/dd-sdk-android-session-replay/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ open class com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper<T: a
constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper)
override fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List<com.datadog.android.sessionreplay.model.MobileSegment.Wireframe>
protected open fun resolveCapturedText(T, com.datadog.android.sessionreplay.TextAndInputPrivacy, Boolean): String
protected fun createTextWireframe(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.GlobalBounds): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.TextWireframe
interface com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapper<T: android.view.ViewGroup> : WireframeMapper<T>
interface com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper<T: android.view.View>
fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List<com.datadog.android.sessionreplay.model.MobileSegment.Wireframe>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,7 @@ public final class com/datadog/android/sessionreplay/recorder/mapper/EditTextMap

public class com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper : com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper {
public fun <init> (Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver;Lcom/datadog/android/sessionreplay/utils/ColorStringFormatter;Lcom/datadog/android/sessionreplay/utils/ViewBoundsResolver;Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper;)V
protected final fun createTextWireframe (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe;
public synthetic fun map (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List;
public fun map (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List;
protected fun resolveCapturedText (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Z)Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,24 @@ internal abstract class CheckableCompoundButtonMapper<T : CompoundButton>(
} else {
CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX
}
(view.buttonDrawable?.constantState as? DrawableContainer.DrawableContainerState)?.getChild(
checkableDrawableIndex
)
view.buttonDrawable?.let {
(it.constantState as? DrawableContainer.DrawableContainerState)?.getChild(
checkableDrawableIndex
)
} ?: kotlin.run {
internalLogger.log(
level = InternalLogger.Level.ERROR,
targets = listOf(
InternalLogger.Target.MAINTAINER,
InternalLogger.Target.TELEMETRY
),
messageBuilder = { NULL_BUTTON_DRAWABLE_MSG },
additionalProperties = mapOf(
"replay.compound.view" to view.javaClass.canonicalName
)
)
null
}
} else {
// view.buttonDrawable is not available below API 23, so reflection is used to retrieve it.
try {
Expand All @@ -88,14 +103,7 @@ internal abstract class CheckableCompoundButtonMapper<T : CompoundButton>(
null
}
}
return originCheckableDrawable?.let { cloneCheckableDrawable(view, it) } ?: run {
internalLogger.log(
level = InternalLogger.Level.ERROR,
targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
messageBuilder = { GET_DRAWABLE_FAIL_MESSAGE }
)
null
}
return originCheckableDrawable?.let { cloneCheckableDrawable(view, it) }
}

private fun cloneCheckableDrawable(view: T, drawable: Drawable): Drawable? {
Expand All @@ -115,6 +123,8 @@ internal abstract class CheckableCompoundButtonMapper<T : CompoundButton>(
internal const val DEFAULT_CHECKABLE_HEIGHT_IN_DP = 32L
internal const val GET_DRAWABLE_FAIL_MESSAGE =
"Failed to get buttonDrawable from the checkable compound button."
internal const val NULL_BUTTON_DRAWABLE_MSG =
"ButtonDrawable of the compound button is null"

// Reflects the field at the initialization of the class instead of reflecting it for every wireframe generation
@Suppress("PrivateApi", "SwallowedException", "TooGenericExceptionCaught")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ open class TextViewMapper<in T : TextView>(
return (textView.layout?.text ?: textView.text)?.toString().orEmpty()
}

private fun createTextWireframe(
protected fun createTextWireframe(
textView: T,
mappingContext: MappingContext,
viewGlobalBounds: GlobalBounds
Expand Down
Loading

0 comments on commit f83c4c1

Please sign in to comment.