From f83c4c1f93f6a3090e07ec6615870bbddef75ba1 Mon Sep 17 00:00:00 2001 From: luyi Date: Thu, 31 Oct 2024 12:00:36 +0100 Subject: [PATCH] RUM-6871: Add Material Chip mapper and improve CompoundButton telemetry --- .../material/MaterialExtensionSupport.kt | 11 +- .../material/internal/ChipWireframeMapper.kt | 66 ++++++ .../material/ChipWireframeMapperTest.kt | 208 ++++++++++++++++++ .../api/apiSurface | 1 + .../api/dd-sdk-android-session-replay.api | 1 + .../mapper/CheckableCompoundButtonMapper.kt | 32 ++- .../recorder/mapper/TextViewMapper.kt | 2 +- .../layout/fragment_text_view_components.xml | 24 ++ sample/kotlin/src/main/res/values/strings.xml | 1 + 9 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/ChipWireframeMapper.kt create mode 100644 features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/ChipWireframeMapperTest.kt diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt index 3ad195a3b7..dce978f774 100644 --- a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt @@ -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 @@ -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 @@ -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) ) } diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/ChipWireframeMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/ChipWireframeMapper.kt new file mode 100644 index 0000000000..f6e5c1efe6 --- /dev/null +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/ChipWireframeMapper.kt @@ -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( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper +) { + override fun map( + view: Chip, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + internalLogger: InternalLogger + ): List { + val wireframes = mutableListOf() + + 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() + } +} diff --git a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/ChipWireframeMapperTest.kt b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/ChipWireframeMapperTest.kt new file mode 100644 index 0000000000..490b55a100 --- /dev/null +++ b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/ChipWireframeMapperTest.kt @@ -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 "" + } + } +} diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 10a474f20a..d55d961ac2 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -78,6 +78,7 @@ open class com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper 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 : WireframeMapper interface com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 63a4ce0f47..8aa55e21f4 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -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 (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; diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableCompoundButtonMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableCompoundButtonMapper.kt index 28cd92dbe7..9efff700b5 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableCompoundButtonMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableCompoundButtonMapper.kt @@ -63,9 +63,24 @@ internal abstract class CheckableCompoundButtonMapper( } 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 { @@ -88,14 +103,7 @@ internal abstract class CheckableCompoundButtonMapper( 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? { @@ -115,6 +123,8 @@ internal abstract class CheckableCompoundButtonMapper( 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") diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt index f744796967..ffd15c056c 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt @@ -112,7 +112,7 @@ open class TextViewMapper( return (textView.layout?.text ?: textView.text)?.toString().orEmpty() } - private fun createTextWireframe( + protected fun createTextWireframe( textView: T, mappingContext: MappingContext, viewGlobalBounds: GlobalBounds diff --git a/sample/kotlin/src/main/res/layout/fragment_text_view_components.xml b/sample/kotlin/src/main/res/layout/fragment_text_view_components.xml index 2be9c73d20..6440bdb529 100644 --- a/sample/kotlin/src/main/res/layout/fragment_text_view_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_text_view_components.xml @@ -143,5 +143,29 @@ android:checkMark="?android:attr/listChoiceIndicatorMultiple" android:text="@string/app_compat_checked_text_view"/> + + + + + \ No newline at end of file diff --git a/sample/kotlin/src/main/res/values/strings.xml b/sample/kotlin/src/main/res/values/strings.xml index 34ceec3d88..07d53344a8 100644 --- a/sample/kotlin/src/main/res/values/strings.xml +++ b/sample/kotlin/src/main/res/values/strings.xml @@ -153,6 +153,7 @@ Default Radio App Compat Radio Material Radio + This is a chip Default spinner App Compat Spinner App Compat Switch