From 79dce7ffe00d35502394bd58a385dbb7f7eb8659 Mon Sep 17 00:00:00 2001 From: luyi Date: Tue, 2 Jul 2024 11:07:05 +0200 Subject: [PATCH 1/3] RUM:4717: Improve CheckableTextViewMapper --- detekt_custom.yml | 4 + .../internal/DefaultRecorderProvider.kt | 6 +- .../recorder/mapper/CheckBoxMapper.kt | 7 +- .../mapper/CheckableCompoundButtonMapper.kt | 95 ++++- .../mapper/CheckableTextViewMapper.kt | 89 ++--- .../mapper/CheckableWireframeMapper.kt | 16 +- .../recorder/mapper/CheckedTextViewMapper.kt | 22 ++ .../recorder/mapper/RadioButtonMapper.kt | 16 +- .../recorder/mapper/SwitchCompatMapper.kt | 9 +- .../internal/utils/DrawableUtils.kt | 5 +- .../recorder/mapper/BaseCheckBoxMapperTest.kt | 296 -------------- .../mapper/BaseCheckableTextViewMapperTest.kt | 289 ++++++++++++++ .../mapper/BaseCheckedTextViewMapperTest.kt | 372 ------------------ .../mapper/BaseRadioButtonMapperTest.kt | 312 --------------- .../mapper/BaseSwitchCompatMapperTest.kt | 2 + .../recorder/mapper/CheckBoxMapperTest.kt | 19 +- .../mapper/CheckedTextViewMapperTest.kt | 16 +- .../recorder/mapper/RadioButtonMapperTest.kt | 19 +- .../recorder/mapper/SwitchCompatMapperTest.kt | 2 + .../internal/utils/DrawableUtilsTest.kt | 5 + .../sessionreplay/RadioCheckBoxesFragment.kt | 37 +- .../TextViewComponentsFragment.kt | 11 +- .../main/res/color/checkbox_state_tint.xml | 12 + .../fragment_radio_checkbox_components.xml | 87 +++- .../layout/fragment_text_view_components.xml | 1 + sample/kotlin/src/main/res/values/strings.xml | 12 +- 26 files changed, 680 insertions(+), 1081 deletions(-) delete mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckBoxMapperTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt delete mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckedTextViewMapperTest.kt delete mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseRadioButtonMapperTest.kt create mode 100644 sample/kotlin/src/main/res/color/checkbox_state_tint.xml diff --git a/detekt_custom.yml b/detekt_custom.yml index aa560d3d04..4345803a51 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -459,7 +459,10 @@ datadog: - "android.graphics.drawable.Drawable.getPadding(android.graphics.Rect)" - "android.graphics.drawable.Drawable.resolveShapeStyleAndBorder(kotlin.Float)" - "android.graphics.drawable.Drawable.setBounds(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)" + - "android.graphics.drawable.Drawable.setState(kotlin.IntArray)" + - "android.graphics.drawable.Drawable.setTintList(android.content.res.ColorStateList?)" - "android.graphics.drawable.RippleDrawable.findIndexByLayerId(kotlin.Int)" + - "android.graphics.drawable.DrawableContainer.DrawableContainerState.getChild(kotlin.Int)" - "android.graphics.Point.constructor()" - "android.graphics.Rect.centerX()" - "android.graphics.Rect.centerY()" @@ -980,6 +983,7 @@ datadog: - "kotlin.collections.listOf(kotlin.Array)" - "kotlin.collections.listOf(kotlin.String)" - "kotlin.collections.listOf(okhttp3.ConnectionSpec)" + - "kotlin.collections.listOfNotNull(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe?)" - "kotlin.collections.mapOf(kotlin.Array)" - "kotlin.collections.mapOf(kotlin.Pair)" - "kotlin.collections.mutableListOf()" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt index e32eeb078f..c71a90c780 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt @@ -116,7 +116,8 @@ internal class DefaultRecorderProvider( viewIdentifierResolver, colorStringFormatter, viewBoundsResolver, - drawableToColorMapper + drawableToColorMapper, + internalLogger = sdkCore.internalLogger ) ), MapperTypeWrapper( @@ -126,7 +127,8 @@ internal class DefaultRecorderProvider( viewIdentifierResolver, colorStringFormatter, viewBoundsResolver, - drawableToColorMapper + drawableToColorMapper, + internalLogger = sdkCore.internalLogger ) ), MapperTypeWrapper( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapper.kt index fd23e3a13b..e83baa4386 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapper.kt @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.widget.CheckBox +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.DrawableToColorMapper @@ -18,11 +19,13 @@ internal open class CheckBoxMapper( viewIdentifierResolver: ViewIdentifierResolver, colorStringFormatter: ColorStringFormatter, viewBoundsResolver: ViewBoundsResolver, - drawableToColorMapper: DrawableToColorMapper + drawableToColorMapper: DrawableToColorMapper, + internalLogger: InternalLogger ) : CheckableCompoundButtonMapper( textWireframeMapper, viewIdentifierResolver, colorStringFormatter, viewBoundsResolver, - drawableToColorMapper + drawableToColorMapper, + internalLogger ) 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 7d9cd10e2c..28cd92dbe7 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 @@ -6,9 +6,12 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.graphics.drawable.Drawable +import android.graphics.drawable.DrawableContainer import android.os.Build import android.widget.CompoundButton import androidx.annotation.UiThread +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.utils.ColorStringFormatter @@ -22,7 +25,8 @@ internal abstract class CheckableCompoundButtonMapper( viewIdentifierResolver: ViewIdentifierResolver, colorStringFormatter: ColorStringFormatter, viewBoundsResolver: ViewBoundsResolver, - drawableToColorMapper: DrawableToColorMapper + drawableToColorMapper: DrawableToColorMapper, + private val internalLogger: InternalLogger ) : CheckableTextViewMapper( textWireframeMapper, viewIdentifierResolver, @@ -36,27 +40,94 @@ internal abstract class CheckableCompoundButtonMapper( @UiThread override fun resolveCheckableBounds(view: T, pixelsDensity: Float): GlobalBounds { val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds(view, pixelsDensity) - var checkBoxHeight = DEFAULT_CHECKABLE_HEIGHT_IN_PX - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - view.buttonDrawable?.let { - checkBoxHeight = it.intrinsicHeight.toLong() - } + val checkBoxHeight = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + view.buttonDrawable?.intrinsicHeight?.toLong()?.densityNormalized(pixelsDensity) + ?: DEFAULT_CHECKABLE_HEIGHT_IN_DP + } else { + DEFAULT_CHECKABLE_HEIGHT_IN_DP } - // minus the padding - checkBoxHeight -= MIN_PADDING_IN_PX * 2 - checkBoxHeight = checkBoxHeight.densityNormalized(pixelsDensity) return GlobalBounds( - x = viewGlobalBounds.x + MIN_PADDING_IN_PX.densityNormalized(pixelsDensity), + x = viewGlobalBounds.x, y = viewGlobalBounds.y + (viewGlobalBounds.height - checkBoxHeight) / 2, width = checkBoxHeight, height = checkBoxHeight ) } + override fun getCheckableDrawable(view: T): Drawable? { + val originCheckableDrawable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // drawable from [CompoundButton] can not be retrieved according to the state, + // so here two hardcoded indexes are used to retrieve "checked" and "not checked" drawables. + val checkableDrawableIndex = if (view.isChecked) { + CHECK_BOX_CHECKED_DRAWABLE_INDEX + } else { + CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX + } + (view.buttonDrawable?.constantState as? DrawableContainer.DrawableContainerState)?.getChild( + checkableDrawableIndex + ) + } else { + // view.buttonDrawable is not available below API 23, so reflection is used to retrieve it. + try { + mButtonDrawableField?.get(view) as? Drawable + } catch (e: IllegalAccessException) { + internalLogger.log( + level = InternalLogger.Level.ERROR, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { GET_DRAWABLE_FAIL_MESSAGE }, + throwable = e + ) + null + } catch (e: IllegalArgumentException) { + internalLogger.log( + level = InternalLogger.Level.ERROR, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { GET_DRAWABLE_FAIL_MESSAGE }, + throwable = e + ) + 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 + } + } + + private fun cloneCheckableDrawable(view: T, drawable: Drawable): Drawable? { + return drawable.constantState?.newDrawable(view.resources)?.apply { + // Set state to make the drawable have correct tint. + setState(view.drawableState) + // Set tint list to drawable if the button has declared `buttonTint` attribute. + view.buttonTintList?.let { + setTintList(it) + } + } + } + // endregion companion object { - internal const val MIN_PADDING_IN_PX = 20L - internal const val DEFAULT_CHECKABLE_HEIGHT_IN_PX = 84L + 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." + + // Reflects the field at the initialization of the class instead of reflecting it for every wireframe generation + @Suppress("PrivateApi", "SwallowedException", "TooGenericExceptionCaught") + internal val mButtonDrawableField = try { + CompoundButton::class.java.getDeclaredField("mButtonDrawable").apply { + isAccessible = true + } + } catch (e: NoSuchFieldException) { + null + } catch (e: SecurityException) { + null + } catch (e: NullPointerException) { + null + } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt index bfeb83317e..b44f7cffc1 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.graphics.drawable.Drawable import android.widget.Checkable import android.widget.TextView import androidx.annotation.UiThread @@ -47,39 +48,23 @@ internal abstract class CheckableTextViewMapper( } @UiThread - override fun resolveCheckedCheckable( + override fun resolveCheckable( view: T, - mappingContext: MappingContext + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback ): List? { - val checkableId = viewIdentifierResolver.resolveChildUniqueIdentifier( - view, - CHECKABLE_KEY_NAME - ) ?: return null - val checkBoxColor = resolveCheckableColor(view) - val checkBoxBounds = resolveCheckableBounds( - view, - mappingContext.systemInformation.screenDensity - ) - val shapeStyle = resolveCheckedShapeStyle(view, checkBoxColor) - val shapeBorder = resolveCheckedShapeBorder(view, checkBoxColor) - return listOf( - MobileSegment.Wireframe.ShapeWireframe( - id = checkableId, - x = checkBoxBounds.x, - y = checkBoxBounds.y, - width = checkBoxBounds.width, - height = checkBoxBounds.height, - border = shapeBorder, - shapeStyle = shapeStyle + return listOfNotNull( + createCheckableDrawableWireFrames( + view, + mappingContext, + asyncJobStatusCallback ) ) } @UiThread - override fun resolveNotCheckedCheckable( - view: T, - mappingContext: MappingContext - ): List? { + override fun resolveMaskedCheckable(view: T, mappingContext: MappingContext): List? { + // TODO RUM-5118: Decide how to display masked checkbox, Currently use old unchecked shape wireframe, val checkableId = viewIdentifierResolver.resolveChildUniqueIdentifier( view, CHECKABLE_KEY_NAME @@ -104,11 +89,6 @@ internal abstract class CheckableTextViewMapper( ) } - @UiThread - override fun resolveMaskedCheckable(view: T, mappingContext: MappingContext): List? { - return resolveNotCheckedCheckable(view, mappingContext) - } - // endregion // region CheckableTextViewMapper @@ -116,22 +96,41 @@ internal abstract class CheckableTextViewMapper( @UiThread abstract fun resolveCheckableBounds(view: T, pixelsDensity: Float): GlobalBounds - protected open fun resolveCheckableColor(view: T): String { - return colorStringFormatter.formatColorAndAlphaAsHexString(view.currentTextColor, OPAQUE_ALPHA_VALUE) + @UiThread + private fun createCheckableDrawableWireFrames( + view: T, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): MobileSegment.Wireframe? { + // Checkbox drawable has animation transition which produces intermediate wireframe, to avoid that, the final + // state drawable "checked" or "unchecked" needs to be extracted to generate the correct wireframes. + return getCheckableDrawable(view)?.let { + val checkBoxBounds = resolveCheckableBounds( + view, + mappingContext.systemInformation.screenDensity + ) + mappingContext.imageWireframeHelper.createImageWireframe( + view = view, + currentWireframeIndex = 0, + x = checkBoxBounds.x, + y = checkBoxBounds.y, + width = it.intrinsicWidth, + height = it.intrinsicHeight, + drawable = it, + shapeStyle = null, + border = null, + usePIIPlaceholder = true, + clipping = MobileSegment.WireframeClip(), + asyncJobStatusCallback = asyncJobStatusCallback + ) + } } - protected open fun resolveCheckedShapeStyle(view: T, checkBoxColor: String): MobileSegment.ShapeStyle? { - return MobileSegment.ShapeStyle( - backgroundColor = checkBoxColor, - view.alpha - ) - } + @UiThread + abstract fun getCheckableDrawable(view: T): Drawable? - protected open fun resolveCheckedShapeBorder(view: T, checkBoxColor: String): MobileSegment.ShapeBorder? { - return MobileSegment.ShapeBorder( - color = checkBoxColor, - width = CHECKABLE_BORDER_WIDTH - ) + protected open fun resolveCheckableColor(view: T): String { + return colorStringFormatter.formatColorAndAlphaAsHexString(view.currentTextColor, OPAQUE_ALPHA_VALUE) } protected open fun resolveNotCheckedShapeStyle(view: T, checkBoxColor: String): MobileSegment.ShapeStyle? { @@ -150,5 +149,7 @@ internal abstract class CheckableTextViewMapper( companion object { internal const val CHECKABLE_KEY_NAME = "checkable" internal const val CHECKABLE_BORDER_WIDTH = 1L + internal const val CHECK_BOX_CHECKED_DRAWABLE_INDEX = 0 + internal const val CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX = 1 } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt index 60a7c55d74..9994c7feac 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt @@ -42,10 +42,9 @@ internal abstract class CheckableWireframeMapper( val mainWireframes = resolveMainWireframes(view, mappingContext, asyncJobStatusCallback, internalLogger) val checkableWireframes = if (mappingContext.privacy != SessionReplayPrivacy.ALLOW) { resolveMaskedCheckable(view, mappingContext) - } else if (view.isChecked) { - resolveCheckedCheckable(view, mappingContext) } else { - resolveNotCheckedCheckable(view, mappingContext) + // Resolves checkable view regardless the state + resolveCheckable(view, mappingContext, asyncJobStatusCallback) } checkableWireframes?.let { wireframes -> return mainWireframes + wireframes @@ -68,14 +67,9 @@ internal abstract class CheckableWireframeMapper( ): List? @UiThread - abstract fun resolveNotCheckedCheckable( + abstract fun resolveCheckable( view: T, - mappingContext: MappingContext - ): List? - - @UiThread - abstract fun resolveCheckedCheckable( - view: T, - mappingContext: MappingContext + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback ): List? } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapper.kt index 9929b32e89..97a5faf820 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapper.kt @@ -6,6 +6,8 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.graphics.drawable.Drawable +import android.graphics.drawable.DrawableContainer import android.widget.CheckedTextView import androidx.annotation.UiThread import com.datadog.android.sessionreplay.internal.recorder.densityNormalized @@ -62,5 +64,25 @@ internal open class CheckedTextViewMapper( ) } + override fun getCheckableDrawable(view: CheckedTextView): Drawable? { + // drawable from [CheckedTextView] can not be retrieved according to the state, + // so here two hardcoded indexes are used to retrieve "checked" and "not checked" drawables. + val checkableDrawableIndex = if (view.isChecked) { + CHECK_BOX_CHECKED_DRAWABLE_INDEX + } else { + CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX + } + + return (view.checkMarkDrawable?.constantState as? DrawableContainer.DrawableContainerState)?.getChild( + checkableDrawableIndex + )?.constantState?.newDrawable(view.resources)?.apply { + // Set state to make the drawable have correct tint according to the state. + setState(view.drawableState) + // Set tint list to drawable if the button has declared `checkMarkTint` attribute. + view.checkMarkTintList?.let { + setTintList(view.checkMarkTintList) + } + } + } // endregion } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt index 160b95275e..685b8da430 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.widget.RadioButton import androidx.annotation.UiThread +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.utils.ColorStringFormatter @@ -20,26 +21,19 @@ internal open class RadioButtonMapper( viewIdentifierResolver: ViewIdentifierResolver, colorStringFormatter: ColorStringFormatter, viewBoundsResolver: ViewBoundsResolver, - drawableToColorMapper: DrawableToColorMapper + drawableToColorMapper: DrawableToColorMapper, + internalLogger: InternalLogger ) : CheckableCompoundButtonMapper( textWireframeMapper, viewIdentifierResolver, colorStringFormatter, viewBoundsResolver, - drawableToColorMapper + drawableToColorMapper, + internalLogger ) { // region CheckableTextViewMapper - @UiThread - override fun resolveCheckedShapeStyle(view: RadioButton, checkBoxColor: String): MobileSegment.ShapeStyle? { - return MobileSegment.ShapeStyle( - backgroundColor = checkBoxColor, - view.alpha, - cornerRadius = CORNER_RADIUS - ) - } - @UiThread override fun resolveNotCheckedShapeStyle(view: RadioButton, checkBoxColor: String): MobileSegment.ShapeStyle? { return MobileSegment.ShapeStyle( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt index f7906b56fa..acb3a0e3d4 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt @@ -49,9 +49,10 @@ internal open class SwitchCompatMapper( @Suppress("ReturnCount") @UiThread - override fun resolveCheckedCheckable( + override fun resolveCheckable( view: SwitchCompat, - mappingContext: MappingContext + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback ): List? { val trackThumbDimensions = resolveThumbAndTrackDimensions(view, mappingContext.systemInformation) ?: return null @@ -99,8 +100,10 @@ internal open class SwitchCompatMapper( return wireframes } + @Suppress("UnusedPrivateMember") + // TODO RUM-4715: Improve SwitchCompatMapper @UiThread - override fun resolveNotCheckedCheckable( + private fun resolveNotCheckedCheckable( view: SwitchCompat, mappingContext: MappingContext ): List? { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt index 33f665998e..49eebf59ea 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt @@ -109,7 +109,10 @@ internal class DrawableUtils( bitmapCreationCallback: ResourceResolver.BitmapCreationCallback ) { // don't use the original drawable - it will affect the view hierarchy - val newDrawable = drawable.constantState?.newDrawable(resources) + val newDrawable = drawable.constantState?.newDrawable(resources)?.apply { + // `constantState` contains only immutable properties of drawable,the state needs to be set manually + setState(drawable.current.state) + } val canvas = canvasWrapper.createCanvas(bitmap) if (canvas == null || newDrawable == null) { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckBoxMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckBoxMapperTest.kt deleted file mode 100644 index 68070df7e8..0000000000 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckBoxMapperTest.kt +++ /dev/null @@ -1,296 +0,0 @@ -/* - * 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.internal.recorder.mapper - -import android.graphics.drawable.Drawable -import android.os.Build -import android.widget.CheckBox -import com.datadog.android.sessionreplay.SessionReplayPrivacy -import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.android.sessionreplay.internal.recorder.densityNormalized -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper -import com.datadog.android.sessionreplay.utils.GlobalBounds -import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE -import com.datadog.tools.unit.annotations.TestTargetApi -import com.datadog.tools.unit.extensions.ApiLevelExtension -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.assertj.core.api.Assertions.assertThat -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.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.mock -import org.mockito.kotlin.whenever -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(ApiLevelExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(ForgeConfigurator::class) -internal abstract class BaseCheckBoxMapperTest : LegacyBaseWireframeMapperTest() { - - lateinit var testedCheckBoxMapper: CheckBoxMapper - - @Mock - lateinit var mockTextWireframeMapper: TextViewMapper - - @Forgery - lateinit var fakeTextWireframes: List - - @LongForgery - var fakeGeneratedIdentifier: Long = 0L - - @Forgery - lateinit var fakeViewGlobalBounds: GlobalBounds - - private lateinit var mockCheckBox: CheckBox - - @IntForgery(min = 0, max = 0xffffff) - var fakeCurrentTextColor: Int = 0 - - @StringForgery(regex = "#[0-9A-F]{8}") - lateinit var fakeCurrentTextColorString: String - - @FloatForgery(min = 1f, max = 100f) - var fakeTextSize: Float = 1f - - @IntForgery(min = CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_PX.toInt(), max = 100) - var fakeIntrinsicDrawableHeight = 1 - - @BeforeEach - fun `set up`() { - mockCheckBox = mock { - whenever(it.textSize).thenReturn(fakeTextSize) - whenever(it.currentTextColor).thenReturn(fakeCurrentTextColor) - whenever(it.alpha) doReturn 1f - } - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockCheckBox, - CheckableTextViewMapper.CHECKABLE_KEY_NAME - ) - ).thenReturn(fakeGeneratedIdentifier) - - whenever(mockTextWireframeMapper.map(eq(mockCheckBox), eq(fakeMappingContext), any(), eq(mockInternalLogger))) - .thenReturn(fakeTextWireframes) - - whenever( - mockViewBoundsResolver.resolveViewGlobalBounds( - mockCheckBox, - fakeMappingContext.systemInformation.screenDensity - ) - ).thenReturn(fakeViewGlobalBounds) - - whenever( - mockColorStringFormatter.formatColorAndAlphaAsHexString(fakeCurrentTextColor, OPAQUE_ALPHA_VALUE) - ) doReturn fakeCurrentTextColorString - - testedCheckBoxMapper = setupTestedMapper() - } - - internal abstract fun setupTestedMapper(): CheckBoxMapper - - internal open fun expectedCheckedShapeStyle(checkBoxColor: String): MobileSegment.ShapeStyle? { - return if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { - MobileSegment.ShapeStyle( - backgroundColor = checkBoxColor, - opacity = mockCheckBox.alpha - ) - } else { - null - } - } - - // region Unit Tests - - @TestTargetApi(Build.VERSION_CODES.M) - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checked, above M }`() { - // Given - val mockDrawable = mock { - whenever(it.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) - } - whenever(mockCheckBox.buttonDrawable).thenReturn(mockDrawable) - whenever(mockCheckBox.isChecked).thenReturn(true) - val checkBoxSize = resolveCheckBoxSize(fakeIntrinsicDrawableHeight.toLong()) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedCheckBoxMapper.map( - mockCheckBox, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @TestTargetApi(Build.VERSION_CODES.M) - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { not checked, above M }`() { - // Given - val mockDrawable = mock { - whenever(it.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) - } - whenever(mockCheckBox.buttonDrawable).thenReturn(mockDrawable) - whenever(mockCheckBox.isChecked).thenReturn(false) - val checkBoxSize = - resolveCheckBoxSize(fakeIntrinsicDrawableHeight.toLong()) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = null - ) - - // When - val resolvedWireframes = testedCheckBoxMapper.map( - mockCheckBox, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checked }`() { - // Given - whenever(mockCheckBox.isChecked).thenReturn(true) - val checkBoxSize = - resolveCheckBoxSize(CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_PX) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedCheckBoxMapper.map( - mockCheckBox, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { not checked }`() { - // Given - whenever(mockCheckBox.isChecked).thenReturn(false) - val checkBoxSize = - resolveCheckBoxSize(CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_PX) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = null - ) - - // When - val resolvedWireframes = testedCheckBoxMapper.map( - mockCheckBox, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M ignore the checkbox W map() { unique id could not be generated }`() { - // Given - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockCheckBox, - CheckableTextViewMapper.CHECKABLE_KEY_NAME - ) - ).thenReturn(null) - - // When - val resolvedWireframes = testedCheckBoxMapper.map( - mockCheckBox, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) - } - - // endregion - - // region Internal - - private fun resolveCheckBoxSize(checkBoxSize: Long): Long { - val density = fakeMappingContext.systemInformation.screenDensity - val size = checkBoxSize - 2 * CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - return size.densityNormalized(density) - } - - // endregion -} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt new file mode 100644 index 0000000000..f128b17ca2 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt @@ -0,0 +1,289 @@ +/* + * 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.internal.recorder.mapper + +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.graphics.drawable.DrawableContainer +import android.os.Build +import android.widget.Checkable +import android.widget.TextView +import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckableTextViewMapper.Companion.CHECK_BOX_CHECKED_DRAWABLE_INDEX +import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckableTextViewMapper.Companion.CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE +import com.datadog.tools.unit.annotations.TestTargetApi +import com.datadog.tools.unit.extensions.ApiLevelExtension +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.assertj.core.api.Assertions.assertThat +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.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +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.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class), + ExtendWith(ApiLevelExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal abstract class BaseCheckableTextViewMapperTest : + LegacyBaseWireframeMapperTest() where T : TextView, T : Checkable { + + private lateinit var testedCheckableTextViewMapper: CheckableTextViewMapper + + @Mock + lateinit var mockTextWireframeMapper: TextViewMapper + + @Forgery + lateinit var fakeTextWireframes: List + + @LongForgery + var fakeGeneratedIdentifier: Long = 0L + + @Forgery + lateinit var fakeViewGlobalBounds: GlobalBounds + + @Mock + lateinit var mockCheckableTextView: T + + @Mock + lateinit var mockResources: Resources + + @Mock + lateinit var mockButtonDrawable: Drawable + + @Mock + lateinit var mockConstantState: DrawableContainer.DrawableContainerState + + @Mock + lateinit var mockCheckedConstantState: DrawableContainer.DrawableContainerState + + @IntForgery(min = 0, max = 0xffffff) + var fakeCurrentTextColor: Int = 0 + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeCurrentTextColorString: String + + @FloatForgery(min = 1f, max = 100f) + var fakeTextSize: Float = 1f + + @IntForgery(min = CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_DP.toInt(), max = 100) + var fakeIntrinsicDrawableHeight = 1 + + @Mock + lateinit var mockCheckedDrawable: Drawable + + @Mock + lateinit var mockNotCheckedDrawable: Drawable + + @Mock + lateinit var mockClonedDrawable: Drawable + + @IntForgery + var mockCloneDrawableIntrinsicHeight: Int = 0 + + @IntForgery + var mockCloneDrawableIntrinsicWidth: Int = 0 + + @BeforeEach + fun `set up`() { + mockClonedDrawable = mock { + whenever(it.intrinsicHeight) doReturn mockCloneDrawableIntrinsicHeight + whenever(it.intrinsicWidth) doReturn mockCloneDrawableIntrinsicWidth + } + mockCheckedConstantState = mock { + whenever(it.newDrawable(mockResources)) doReturn mockClonedDrawable + } + mockCheckedDrawable = mock { + whenever(it.constantState) doReturn mockCheckedConstantState + } + mockNotCheckedDrawable = mock { + whenever(it.constantState) doReturn mockCheckedConstantState + } + mockConstantState = mock { + whenever(it.getChild(CHECK_BOX_CHECKED_DRAWABLE_INDEX)) doReturn mockCheckedDrawable + whenever(it.getChild(CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX)) doReturn mockNotCheckedDrawable + } + mockButtonDrawable = mock { + whenever(it.constantState) doReturn mockConstantState + } + + mockCheckableTextView = mockCheckableTextView() + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier( + mockCheckableTextView, + CheckableTextViewMapper.CHECKABLE_KEY_NAME + ) + ).thenReturn(fakeGeneratedIdentifier) + + whenever( + mockTextWireframeMapper.map( + eq(mockCheckableTextView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) + .thenReturn(fakeTextWireframes) + + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + mockCheckableTextView, + fakeMappingContext.systemInformation.screenDensity + ) + ).thenReturn(fakeViewGlobalBounds) + + whenever( + mockColorStringFormatter.formatColorAndAlphaAsHexString(fakeCurrentTextColor, OPAQUE_ALPHA_VALUE) + ) doReturn fakeCurrentTextColorString + + testedCheckableTextViewMapper = setupTestedMapper() + } + + internal abstract fun setupTestedMapper(): CheckableTextViewMapper + + internal abstract fun mockCheckableTextView(): T + + internal open fun expectedCheckedShapeStyle(checkBoxColor: String): MobileSegment.ShapeStyle? { + return if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { + MobileSegment.ShapeStyle( + backgroundColor = checkBoxColor, + opacity = mockCheckableTextView.alpha + ) + } else { + null + } + } + + // region Unit Tests + + @TestTargetApi(Build.VERSION_CODES.M) + @Test + fun `M create ImageWireFrame W map() { checked, above M }`() { + // Given + val allowedMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) + whenever(mockButtonDrawable.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) + whenever(mockCheckableTextView.isChecked).thenReturn(true) + + // When + testedCheckableTextViewMapper.map( + mockCheckableTextView, + allowedMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + val expectedX = testedCheckableTextViewMapper + .resolveCheckableBounds(mockCheckableTextView, fakeMappingContext.systemInformation.screenDensity).x + val expectedY = testedCheckableTextViewMapper + .resolveCheckableBounds(mockCheckableTextView, fakeMappingContext.systemInformation.screenDensity).y + // Then + verify(fakeMappingContext.imageWireframeHelper).createImageWireframe( + view = eq(mockCheckableTextView), + currentWireframeIndex = anyInt(), + x = eq(expectedX), + y = eq(expectedY), + width = eq(mockCloneDrawableIntrinsicWidth), + height = eq(mockCloneDrawableIntrinsicHeight), + usePIIPlaceholder = anyBoolean(), + drawable = eq(mockClonedDrawable), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), + clipping = eq(MobileSegment.WireframeClip()), + shapeStyle = eq(null), + border = eq(null), + prefix = anyString() + ) + } + + @TestTargetApi(Build.VERSION_CODES.M) + @Test + fun `M create ImageWireFrame W map() { not checked, above M }`() { + // Given + val allowedMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) + whenever(mockButtonDrawable.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) + whenever(mockCheckableTextView.isChecked).thenReturn(false) + + // When + testedCheckableTextViewMapper.map( + mockCheckableTextView, + allowedMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // Then + val expectedX = testedCheckableTextViewMapper + .resolveCheckableBounds(mockCheckableTextView, fakeMappingContext.systemInformation.screenDensity).x + val expectedY = testedCheckableTextViewMapper + .resolveCheckableBounds(mockCheckableTextView, fakeMappingContext.systemInformation.screenDensity).y + // Then + verify(fakeMappingContext.imageWireframeHelper).createImageWireframe( + view = eq(mockCheckableTextView), + currentWireframeIndex = anyInt(), + x = eq(expectedX), + y = eq(expectedY), + width = eq(mockCloneDrawableIntrinsicWidth), + height = eq(mockCloneDrawableIntrinsicHeight), + usePIIPlaceholder = anyBoolean(), + drawable = eq(mockClonedDrawable), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), + clipping = eq(MobileSegment.WireframeClip()), + shapeStyle = eq(null), + border = eq(null), + prefix = anyString() + ) + } + + @Test + fun `M ignore the checkbox W map() { unique id could not be generated }`() { + // Given + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier( + mockCheckableTextView, + CheckableTextViewMapper.CHECKABLE_KEY_NAME + ) + ).thenReturn(null) + + // When + val resolvedWireframes = testedCheckableTextViewMapper.map( + mockCheckableTextView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // Then + assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) + } + + // endregion +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckedTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckedTextViewMapperTest.kt deleted file mode 100644 index 9173d2877e..0000000000 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckedTextViewMapperTest.kt +++ /dev/null @@ -1,372 +0,0 @@ -/* - * 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.internal.recorder.mapper - -import android.content.res.ColorStateList -import android.graphics.drawable.Drawable -import android.widget.CheckedTextView -import com.datadog.android.sessionreplay.SessionReplayPrivacy -import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.android.sessionreplay.internal.recorder.densityNormalized -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper -import com.datadog.android.sessionreplay.utils.GlobalBounds -import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE -import com.datadog.tools.unit.extensions.ApiLevelExtension -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.assertj.core.api.Assertions.assertThat -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.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(ApiLevelExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(ForgeConfigurator::class) -internal abstract class BaseCheckedTextViewMapperTest : LegacyBaseWireframeMapperTest() { - - lateinit var testedCheckedTextWireframeMapper: CheckedTextViewMapper - - @Mock - lateinit var mockTextWireframeMapper: TextViewMapper - - @Forgery - lateinit var fakeTextWireframes: List - - @LongForgery - var fakeGeneratedIdentifier: Long = 0L - - @Forgery - lateinit var fakeViewGlobalBounds: GlobalBounds - - lateinit var mockCheckedTextView: CheckedTextView - - @Mock - lateinit var mockCheckMarkTintList: ColorStateList - - @Mock - lateinit var mockCheckMarkDrawable: Drawable - - @IntForgery(min = 0) - var fakeCheckMarkHeight: Int = 0 - - @IntForgery(min = 0) - var fakePaddingTop: Int = 0 - - @IntForgery(min = 0) - var fakePaddingBottom: Int = 0 - - @IntForgery(min = 0) - var fakePaddingRight: Int = 0 - - @IntForgery(min = 0, max = 0xffffff) - var fakeCheckMarkTintColor: Int = 0 - - @IntForgery(min = 0, max = 0xffffff) - var fakeCurrentTextColor: Int = 0 - - @StringForgery(regex = "#[0-9A-F]{8}") - lateinit var fakeCurrentTextColorString: String - - @FloatForgery(min = 1f) - var fakeTextSize: Float = 1f - - @BeforeEach - fun `set up`() { - whenever(mockCheckMarkDrawable.intrinsicHeight).thenReturn(fakeCheckMarkHeight) - mockCheckedTextView = mock { - whenever(it.checkMarkDrawable).thenReturn(mockCheckMarkDrawable) - whenever(it.totalPaddingTop).thenReturn(fakePaddingTop) - whenever(it.totalPaddingBottom).thenReturn(fakePaddingBottom) - whenever(it.totalPaddingRight).thenReturn(fakePaddingRight) - whenever(it.textSize).thenReturn(fakeTextSize) - } - whenever(mockCheckedTextView.currentTextColor).thenReturn(fakeCurrentTextColor) - whenever(mockCheckMarkTintList.defaultColor).thenReturn(fakeCheckMarkTintColor) - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockCheckedTextView, - CheckableTextViewMapper.CHECKABLE_KEY_NAME - ) - ).thenReturn(fakeGeneratedIdentifier) - - whenever( - mockTextWireframeMapper.map( - eq(mockCheckedTextView), - eq(fakeMappingContext), - any(), - eq(mockInternalLogger) - ) - ).thenReturn(fakeTextWireframes) - - whenever( - mockViewBoundsResolver.resolveViewGlobalBounds( - mockCheckedTextView, - fakeMappingContext.systemInformation.screenDensity - ) - ).thenReturn(fakeViewGlobalBounds) - - whenever( - mockColorStringFormatter.formatColorAndAlphaAsHexString(fakeCurrentTextColor, OPAQUE_ALPHA_VALUE) - ) doReturn fakeCurrentTextColorString - testedCheckedTextWireframeMapper = setupTestedMapper() - } - - internal abstract fun setupTestedMapper(): CheckedTextViewMapper - - internal open fun expectedCheckedShapeStyle(checkBoxColor: String): MobileSegment.ShapeStyle? { - return if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { - MobileSegment.ShapeStyle( - backgroundColor = checkBoxColor, - opacity = mockCheckedTextView.alpha - ) - } else { - null - } - } - - // region Unit Tests - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { text checked }`() { - // Given - whenever(mockCheckedTextView.isChecked).thenReturn(true) - val checkBoxSize = resolveCheckBoxSize() - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - - fakePaddingRight.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { text not checked }`() { - // Given - whenever(mockCheckedTextView.isChecked).thenReturn(false) - val checkBoxSize = resolveCheckBoxSize() - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - - fakePaddingRight.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = null - ) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checkMarkDrawable not available }`() { - // Given - whenever(mockCheckedTextView.checkMarkDrawable).thenReturn(null) - whenever(mockCheckedTextView.isChecked).thenReturn(false) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - - fakePaddingRight.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y, - width = 0, - height = 0, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = null - ) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checkMarkTintList available }`() { - // Given - whenever(mockCheckedTextView.checkMarkTintList).thenReturn(mockCheckMarkTintList) - whenever( - mockColorStringFormatter.formatColorAndAlphaAsHexString(eq(mockCheckMarkTintList.defaultColor), anyOrNull()) - ).thenReturn(fakeCurrentTextColorString) - - val checkBoxSize = resolveCheckBoxSize() - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - - fakePaddingRight.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ) - ) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checkMarkTintList is null }`() { - // Given - whenever(mockCheckedTextView.checkMarkTintList).thenReturn(null) - val checkBoxSize = resolveCheckBoxSize() - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - - fakePaddingRight.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ) - ) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checkMarkTintList not available }`() { - // Given - val checkBoxSize = resolveCheckBoxSize() - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - - fakePaddingRight.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ) - ) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M ignore the checkbox W map() { unique id could not be generated }`() { - // Given - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockCheckedTextView, - CheckableTextViewMapper.CHECKABLE_KEY_NAME - ) - ).thenReturn(null) - - // When - val resolvedWireframes = testedCheckedTextWireframeMapper.map( - mockCheckedTextView, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) - } - - // endregion - - // region Internal - - private fun resolveCheckBoxSize(): Long { - val size = fakeCheckMarkHeight - fakePaddingBottom - fakePaddingTop - return size.toLong().densityNormalized(fakeMappingContext.systemInformation.screenDensity) - } - - // endregion -} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseRadioButtonMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseRadioButtonMapperTest.kt deleted file mode 100644 index b9ff183cde..0000000000 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseRadioButtonMapperTest.kt +++ /dev/null @@ -1,312 +0,0 @@ -/* - * 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.internal.recorder.mapper - -import android.graphics.drawable.Drawable -import android.os.Build -import android.widget.RadioButton -import com.datadog.android.sessionreplay.SessionReplayPrivacy -import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.android.sessionreplay.internal.recorder.densityNormalized -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper -import com.datadog.android.sessionreplay.utils.GlobalBounds -import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE -import com.datadog.tools.unit.annotations.TestTargetApi -import com.datadog.tools.unit.extensions.ApiLevelExtension -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.assertj.core.api.Assertions.assertThat -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.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.mock -import org.mockito.kotlin.whenever -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(ApiLevelExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(ForgeConfigurator::class) -internal abstract class BaseRadioButtonMapperTest : LegacyBaseWireframeMapperTest() { - - lateinit var testedRadioButtonMapper: RadioButtonMapper - - @Mock - lateinit var mockTextWireframeMapper: TextViewMapper - - @Forgery - lateinit var fakeTextWireframes: List - - @LongForgery - var fakeGeneratedIdentifier: Long = 0L - - @Forgery - lateinit var fakeViewGlobalBounds: GlobalBounds - - lateinit var mockRadioButton: RadioButton - - @IntForgery(min = 0, max = 0xffffff) - var fakeCurrentTextColor: Int = 0 - - @StringForgery(regex = "#[0-9A-F]{8}") - lateinit var fakeCurrentTextColorString: String - - @FloatForgery(min = 1f, max = 100f) - var fakeTextSize: Float = 1f - - @IntForgery(min = CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_PX.toInt(), max = 100) - var fakeIntrinsicDrawableHeight = 1 - - @BeforeEach - fun `set up`() { - mockRadioButton = mock { - whenever(it.textSize).thenReturn(fakeTextSize) - whenever(it.currentTextColor).thenReturn(fakeCurrentTextColor) - } - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockRadioButton, - CheckableTextViewMapper.CHECKABLE_KEY_NAME - ) - ).thenReturn(fakeGeneratedIdentifier) - - whenever( - mockTextWireframeMapper.map( - eq(mockRadioButton), - eq(fakeMappingContext), - any(), - eq(mockInternalLogger) - ) - ) - .thenReturn(fakeTextWireframes) - - whenever( - mockViewBoundsResolver.resolveViewGlobalBounds( - mockRadioButton, - fakeMappingContext.systemInformation.screenDensity - ) - ).thenReturn(fakeViewGlobalBounds) - - whenever( - mockColorStringFormatter.formatColorAndAlphaAsHexString(fakeCurrentTextColor, OPAQUE_ALPHA_VALUE) - ) doReturn fakeCurrentTextColorString - testedRadioButtonMapper = setupTestedMapper() - } - - internal abstract fun setupTestedMapper(): RadioButtonMapper - - internal open fun expectedCheckedShapeStyle(checkBoxColor: String): MobileSegment.ShapeStyle? { - val backgroundColor = if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { - checkBoxColor - } else { - null - } - return MobileSegment.ShapeStyle( - backgroundColor = backgroundColor, - opacity = mockRadioButton.alpha, - cornerRadius = RadioButtonMapper.CORNER_RADIUS - ) - } - - internal open fun expectedNotCheckedShapeStyle(checkBoxColor: String): MobileSegment.ShapeStyle? { - return MobileSegment.ShapeStyle( - opacity = mockRadioButton.alpha, - cornerRadius = RadioButtonMapper.CORNER_RADIUS - - ) - } - - // region Unit Tests - - @TestTargetApi(Build.VERSION_CODES.M) - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checked, above M }`() { - // Given - val mockDrawable = mock { - whenever(it.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) - } - whenever(mockRadioButton.buttonDrawable).thenReturn(mockDrawable) - whenever(mockRadioButton.isChecked).thenReturn(true) - val checkBoxSize = - resolveRadioBoxSize(fakeIntrinsicDrawableHeight.toLong()) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedRadioButtonMapper.map( - mockRadioButton, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @TestTargetApi(Build.VERSION_CODES.M) - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { not checked, above M }`() { - // Given - val mockDrawable = mock { - whenever(it.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) - } - whenever(mockRadioButton.buttonDrawable).thenReturn(mockDrawable) - whenever(mockRadioButton.isChecked).thenReturn(false) - val checkBoxSize = - resolveRadioBoxSize(fakeIntrinsicDrawableHeight.toLong()) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedNotCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedRadioButtonMapper.map( - mockRadioButton, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { checked }`() { - // Given - whenever(mockRadioButton.isChecked).thenReturn(true) - val checkBoxSize = - resolveRadioBoxSize(CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_PX) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedRadioButtonMapper.map( - mockRadioButton, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M resolve the checkbox as ShapeWireframe W map() { not checked }`() { - // Given - whenever(mockRadioButton.isChecked).thenReturn(false) - val checkBoxSize = - resolveRadioBoxSize(CheckableCompoundButtonMapper.DEFAULT_CHECKABLE_HEIGHT_IN_PX) - val expectedCheckBoxWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x + CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - .densityNormalized(fakeMappingContext.systemInformation.screenDensity), - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - checkBoxSize) / 2, - width = checkBoxSize, - height = checkBoxSize, - border = MobileSegment.ShapeBorder( - color = fakeCurrentTextColorString, - width = CheckableTextViewMapper.CHECKABLE_BORDER_WIDTH - ), - shapeStyle = expectedNotCheckedShapeStyle(fakeCurrentTextColorString) - ) - - // When - val resolvedWireframes = testedRadioButtonMapper.map( - mockRadioButton, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedCheckBoxWireframe) - } - - @Test - fun `M ignore the checkbox W map() { unique id could not be generated }`() { - // Given - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockRadioButton, - CheckableTextViewMapper.CHECKABLE_KEY_NAME - ) - ).thenReturn(null) - - // When - val resolvedWireframes = testedRadioButtonMapper.map( - mockRadioButton, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) - } - - // endregion - - // region Internal - - private fun resolveRadioBoxSize(radioSize: Long): Long { - val density = fakeMappingContext.systemInformation.screenDensity - val size = radioSize - 2 * CheckableCompoundButtonMapper.MIN_PADDING_IN_PX - return size.densityNormalized(density) - } - - // endregion -} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt index f4d3ed6de2..997d7ba076 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt @@ -22,6 +22,7 @@ import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.annotation.StringForgery import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.Test import org.mockito.Mock @@ -184,6 +185,7 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe } @RepeatedTest(8) + @Disabled("TODO RUM-4715, will be immediately fixed in next commit") fun `M resolve the switch as wireframes W map() { can't generate id for trackWireframe }`( forge: Forge ) { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapperTest.kt index 241e442780..4d05442b81 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckBoxMapperTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.widget.CheckBox import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.tools.unit.extensions.ApiLevelExtension import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -14,6 +15,9 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -23,7 +27,7 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class CheckBoxMapperTest : BaseCheckBoxMapperTest() { +internal class CheckBoxMapperTest : BaseCheckableTextViewMapperTest() { override fun setupTestedMapper(): CheckBoxMapper { return CheckBoxMapper( @@ -31,7 +35,18 @@ internal class CheckBoxMapperTest : BaseCheckBoxMapperTest() { mockViewIdentifierResolver, mockColorStringFormatter, mockViewBoundsResolver, - mockDrawableToColorMapper + mockDrawableToColorMapper, + mockInternalLogger ) } + + override fun mockCheckableTextView(): CheckBox { + return mock { + whenever(it.textSize).thenReturn(fakeTextSize) + whenever(it.currentTextColor).thenReturn(fakeCurrentTextColor) + whenever(it.alpha) doReturn 1f + whenever(it.buttonDrawable) doReturn mockButtonDrawable + whenever(it.resources) doReturn mockResources + } + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapperTest.kt index 3eeb4e0672..e8e9b87f65 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckedTextViewMapperTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.widget.CheckedTextView import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.tools.unit.extensions.ApiLevelExtension import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -14,6 +15,9 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -23,7 +27,7 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class CheckedTextViewMapperTest : BaseCheckedTextViewMapperTest() { +internal class CheckedTextViewMapperTest : BaseCheckableTextViewMapperTest() { override fun setupTestedMapper(): CheckedTextViewMapper { return CheckedTextViewMapper( @@ -34,4 +38,14 @@ internal class CheckedTextViewMapperTest : BaseCheckedTextViewMapperTest() { mockDrawableToColorMapper ) } + + override fun mockCheckableTextView(): CheckedTextView { + return mock { + whenever(it.textSize).thenReturn(fakeTextSize) + whenever(it.currentTextColor).thenReturn(fakeCurrentTextColor) + whenever(it.alpha) doReturn 1f + whenever(it.checkMarkDrawable) doReturn mockButtonDrawable + whenever(it.resources) doReturn mockResources + } + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapperTest.kt index be2d46d526..e91f3cb1d3 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapperTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.widget.RadioButton import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.tools.unit.extensions.ApiLevelExtension import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -14,6 +15,9 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -23,7 +27,7 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class RadioButtonMapperTest : BaseRadioButtonMapperTest() { +internal class RadioButtonMapperTest : BaseCheckableTextViewMapperTest() { override fun setupTestedMapper(): RadioButtonMapper { return RadioButtonMapper( @@ -31,7 +35,18 @@ internal class RadioButtonMapperTest : BaseRadioButtonMapperTest() { mockViewIdentifierResolver, mockColorStringFormatter, mockViewBoundsResolver, - mockDrawableToColorMapper + mockDrawableToColorMapper, + mockInternalLogger ) } + + override fun mockCheckableTextView(): RadioButton { + return mock { + whenever(it.textSize).thenReturn(fakeTextSize) + whenever(it.currentTextColor).thenReturn(fakeCurrentTextColor) + whenever(it.alpha) doReturn 1f + whenever(it.buttonDrawable) doReturn mockButtonDrawable + whenever(it.resources) doReturn mockResources + } + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt index 1f6c79626a..467f246142 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt @@ -13,6 +13,7 @@ import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions @@ -93,6 +94,7 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { } @Test + @Disabled("TODO RUM-4715, will be immediately fixed in next commit") fun `M resolve the switch as wireframes W map() { not checked }`() { // Given whenever(mockSwitch.isChecked).thenReturn(false) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt index 038d445c9e..e46f22922d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt @@ -59,6 +59,9 @@ internal class DrawableUtilsTest { @Mock private lateinit var mockDrawable: Drawable + @Mock + private lateinit var mockCurrentDrawable: Drawable + @Mock private lateinit var mockBitmapWrapper: BitmapWrapper @@ -99,6 +102,8 @@ internal class DrawableUtilsTest { fun setup() { whenever(mockConstantState.newDrawable(mockResources)).thenReturn(mockSecondDrawable) whenever(mockDrawable.constantState).thenReturn(mockConstantState) + whenever(mockCurrentDrawable.constantState).thenReturn(mockConstantState) + whenever(mockDrawable.current).thenReturn(mockCurrentDrawable) whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) .thenReturn(mockBitmap) whenever(mockCanvasWrapper.createCanvas(any())) diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/RadioCheckBoxesFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/RadioCheckBoxesFragment.kt index 9ec61b6f59..565bd80385 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/RadioCheckBoxesFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/RadioCheckBoxesFragment.kt @@ -6,7 +6,42 @@ package com.datadog.android.sample.sessionreplay +import android.os.Bundle +import android.view.View +import android.widget.CheckBox import androidx.fragment.app.Fragment import com.datadog.android.sample.R -internal class RadioCheckBoxesFragment : Fragment(R.layout.fragment_radio_checkbox_components) +internal class RadioCheckBoxesFragment : Fragment(R.layout.fragment_radio_checkbox_components) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val defaultDisabledChecked = view.findViewById(R.id.checkbox_disabled_checked) + val defaultDisabledNotChecked = view.findViewById(R.id.checkbox_disabled_not_checked) + view.findViewById(R.id.default_checkbox).apply { + setOnCheckedChangeListener { _, isChecked -> + defaultDisabledChecked.isEnabled = isChecked + defaultDisabledNotChecked.isEnabled = isChecked + } + } + val appCompatDisabledChecked = view.findViewById(R.id.app_compat_checkbox_disabled_checked) + val appCompatDisabledNotChecked = view.findViewById(R.id.app_compat_checkbox_disabled_unchecked) + view.findViewById(R.id.app_compat_checkbox) + .apply { + setOnCheckedChangeListener { _, isChecked -> + appCompatDisabledChecked.isEnabled = isChecked + appCompatDisabledNotChecked.isEnabled = isChecked + } + } + + val materialDisabledChecked = view.findViewById(R.id.material_checkbox_disabled_checked) + val materialDisabledNotChecked = view.findViewById(R.id.material_checkbox_disabled_not_checked) + view.findViewById(R.id.material_checkbox) + .apply { + setOnCheckedChangeListener { _, isChecked -> + materialDisabledChecked.isEnabled = isChecked + materialDisabledNotChecked.isEnabled = isChecked + } + } + } +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/TextViewComponentsFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/TextViewComponentsFragment.kt index 9e9001af28..07ab4c4ff4 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/TextViewComponentsFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/TextViewComponentsFragment.kt @@ -10,6 +10,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.CheckedTextView +import androidx.appcompat.widget.AppCompatCheckedTextView import androidx.fragment.app.Fragment import com.datadog.android.sample.R @@ -20,6 +22,13 @@ internal class TextViewComponentsFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.fragment_text_view_components, container, false) + return inflater.inflate(R.layout.fragment_text_view_components, container, false).apply { + findViewById(R.id.checked_text_view).apply { + setOnClickListener { this.toggle() } + } + findViewById(R.id.app_compat_checked_text_view).apply { + setOnClickListener { this.toggle() } + } + } } } diff --git a/sample/kotlin/src/main/res/color/checkbox_state_tint.xml b/sample/kotlin/src/main/res/color/checkbox_state_tint.xml new file mode 100644 index 0000000000..9608a4d705 --- /dev/null +++ b/sample/kotlin/src/main/res/color/checkbox_state_tint.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/sample/kotlin/src/main/res/layout/fragment_radio_checkbox_components.xml b/sample/kotlin/src/main/res/layout/fragment_radio_checkbox_components.xml index e1627cc85b..b7ab2dc5c6 100644 --- a/sample/kotlin/src/main/res/layout/fragment_radio_checkbox_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_radio_checkbox_components.xml @@ -13,34 +13,111 @@ + + + + + + + + + + android:buttonTint="@color/checkbox_state_tint" + android:text="@string/material_checkbox" + app:layout_constraintTop_toBottomOf="@+id/app_compat_checkbox_disabled_unchecked" + app:useMaterialThemeColors="false" /> - + + + + diff --git a/sample/kotlin/src/main/res/values/strings.xml b/sample/kotlin/src/main/res/values/strings.xml index f0bab2d08b..da755ac5b3 100644 --- a/sample/kotlin/src/main/res/values/strings.xml +++ b/sample/kotlin/src/main/res/values/strings.xml @@ -138,9 +138,15 @@ Pick Current Time Pick Current Date Session Replay - Default CheckBox - App Compat CheckBox - Material CheckBox + Default CheckBox (Controls two check boxes below) + Default CheckBox Disabled Checked + Default CheckBox Disabled Not Checked + App Compat CheckBox (Controls two check boxes below) + App Compat CheckBox Disabled Checked + App Compat CheckBox Disabled Not checked + Material CheckBox With custom tint + Material CheckBox Disabled Checked With custom tint + Material CheckBox With Disabled Not Checked custom tint Radio and CheckBox Components Default Radio App Compat Radio From a06a585f42af5ae84dd16158245ca3c5509a72d7 Mon Sep 17 00:00:00 2001 From: luyi Date: Mon, 1 Jul 2024 16:25:31 +0200 Subject: [PATCH 2/3] RUM-4715: SwitchCompat mapper improvement --- detekt_custom.yml | 1 + .../mapper/CheckableTextViewMapper.kt | 2 +- .../mapper/CheckableWireframeMapper.kt | 2 +- .../recorder/mapper/SwitchCompatMapper.kt | 276 ++++++++---------- .../mapper/BaseSwitchCompatMapperTest.kt | 110 ++++--- .../recorder/mapper/SwitchCompatMapperTest.kt | 176 +++++------ .../DropDownSwitchersFragment.kt | 15 + ...ragment_drop_down_switchers_components.xml | 25 +- sample/kotlin/src/main/res/values/strings.xml | 2 + 9 files changed, 312 insertions(+), 297 deletions(-) diff --git a/detekt_custom.yml b/detekt_custom.yml index 07b5dd4d3f..c0a4cce167 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -992,6 +992,7 @@ datadog: - "kotlin.collections.listOf(kotlin.String)" - "kotlin.collections.listOf(okhttp3.ConnectionSpec)" - "kotlin.collections.listOfNotNull(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe?)" + - "kotlin.collections.listOfNotNull(kotlin.Array)" - "kotlin.collections.mapOf(kotlin.Array)" - "kotlin.collections.mapOf(kotlin.Pair)" - "kotlin.collections.mutableListOf()" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt index b44f7cffc1..e119b9e7a0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt @@ -52,7 +52,7 @@ internal abstract class CheckableTextViewMapper( view: T, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback - ): List? { + ): List { return listOfNotNull( createCheckableDrawableWireFrames( view, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt index 9994c7feac..7c14b9b622 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt @@ -71,5 +71,5 @@ internal abstract class CheckableWireframeMapper( view: T, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback - ): List? + ): List } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt index acb3a0e3d4..7abe894c4e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt @@ -6,18 +6,17 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper -import android.graphics.Rect import androidx.annotation.UiThread import androidx.appcompat.widget.SwitchCompat import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext -import com.datadog.android.sessionreplay.recorder.SystemInformation 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.GlobalBounds import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE import com.datadog.android.sessionreplay.utils.ViewBoundsResolver import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver @@ -47,110 +46,120 @@ internal open class SwitchCompatMapper( return textWireframeMapper.map(view, mappingContext, asyncJobStatusCallback, internalLogger) } - @Suppress("ReturnCount") - @UiThread - override fun resolveCheckable( + private fun createSwitchCompatDrawableWireFrames( view: SwitchCompat, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback - ): List? { - val trackThumbDimensions = resolveThumbAndTrackDimensions(view, mappingContext.systemInformation) ?: return null - - val wireframes = mutableListOf() + ): List { + var index = 0 + val thumbWireframe = createThumbWireframe(view, index, mappingContext, asyncJobStatusCallback) + if (thumbWireframe != null) { + index++ + } + val trackWireframe = createTrackWireframe(view, index, mappingContext, asyncJobStatusCallback) + return listOfNotNull(trackWireframe, thumbWireframe) + } - val trackWidth = trackThumbDimensions[TRACK_WIDTH_INDEX] - val trackHeight = trackThumbDimensions[TRACK_HEIGHT_INDEX] - val thumbHeight = trackThumbDimensions[THUMB_HEIGHT_INDEX] - val thumbWidth = trackThumbDimensions[THUMB_WIDTH_INDEX] - val checkableColor = resolveCheckableColor(view) - val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds( + private fun createTrackWireframe( + view: SwitchCompat, + prevIndex: Int, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): MobileSegment.Wireframe? { + val trackBounds = resolveTrackBounds( view, mappingContext.systemInformation.screenDensity ) - - val trackId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, TRACK_KEY_NAME) - if (trackId != null) { - val trackShapeStyle = resolveTrackShapeStyle(view, checkableColor) - val trackWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = trackId, - x = viewGlobalBounds.x + viewGlobalBounds.width - trackWidth, - y = viewGlobalBounds.y + (viewGlobalBounds.height - trackHeight) / 2, - width = trackWidth, - height = trackHeight, - border = null, - shapeStyle = trackShapeStyle - ) - wireframes.add(trackWireframe) + return trackBounds?.let { + return view.trackDrawable.constantState?.newDrawable(view.resources)?.apply { + setState(view.trackDrawable.state) + bounds = view.trackDrawable.bounds + view.trackTintList?.let { + setTintList(it) + } + }?.let { drawable -> + mappingContext.imageWireframeHelper.createImageWireframe( + view = view, + currentWireframeIndex = prevIndex + 1, + x = trackBounds.x.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), + y = trackBounds.y.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), + width = trackBounds.width, + height = trackBounds.height, + drawable = drawable, + shapeStyle = null, + border = null, + usePIIPlaceholder = true, + asyncJobStatusCallback = asyncJobStatusCallback + ) + } } - - val thumbId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, THUMB_KEY_NAME) - if (thumbId != null) { - val thumbShapeStyle = resolveThumbShapeStyle(view, checkableColor) - val thumbWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = thumbId, - x = viewGlobalBounds.x + viewGlobalBounds.width - thumbWidth, - y = viewGlobalBounds.y + (viewGlobalBounds.height - thumbHeight) / 2, - width = thumbWidth, - height = thumbHeight, - border = null, - shapeStyle = thumbShapeStyle - ) - wireframes.add(thumbWireframe) - } - return wireframes } - @Suppress("UnusedPrivateMember") - // TODO RUM-4715: Improve SwitchCompatMapper - @UiThread - private fun resolveNotCheckedCheckable( + private fun createThumbWireframe( view: SwitchCompat, - mappingContext: MappingContext - ): List? { - val trackThumbDimensions = resolveThumbAndTrackDimensions(view, mappingContext.systemInformation) ?: return null - - val wireframes = mutableListOf() - - val trackWidth = trackThumbDimensions[TRACK_WIDTH_INDEX] - val trackHeight = trackThumbDimensions[TRACK_HEIGHT_INDEX] - val thumbHeight = trackThumbDimensions[THUMB_HEIGHT_INDEX] - val thumbWidth = trackThumbDimensions[THUMB_WIDTH_INDEX] - val checkableColor = resolveCheckableColor(view) - val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds( + prevIndex: Int, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): MobileSegment.Wireframe? { + val thumbBounds = resolveThumbBounds( view, mappingContext.systemInformation.screenDensity ) - - val trackId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, TRACK_KEY_NAME) - if (trackId != null) { - val trackShapeStyle = resolveTrackShapeStyle(view, checkableColor) - val trackWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = trackId, - x = viewGlobalBounds.x + viewGlobalBounds.width - trackWidth, - y = viewGlobalBounds.y + (viewGlobalBounds.height - trackHeight) / 2, - width = trackWidth, - height = trackHeight, - border = null, - shapeStyle = trackShapeStyle - ) - wireframes.add(trackWireframe) + return view.thumbDrawable?.let { drawable -> + thumbBounds?.let { thumbBounds -> + mappingContext.imageWireframeHelper.createImageWireframe( + view = view, + currentWireframeIndex = prevIndex + 1, + x = thumbBounds.x.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), + y = thumbBounds.y.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), + width = drawable.intrinsicWidth, + height = drawable.intrinsicHeight, + drawable = drawable, + shapeStyle = null, + border = null, + usePIIPlaceholder = true, + clipping = null, + asyncJobStatusCallback = asyncJobStatusCallback + ) + } } + } - val thumbId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, THUMB_KEY_NAME) - if (thumbId != null) { - val thumbShapeStyle = resolveThumbShapeStyle(view, checkableColor) - val thumbWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = thumbId, - x = viewGlobalBounds.x + viewGlobalBounds.width - trackWidth, - y = viewGlobalBounds.y + (viewGlobalBounds.height - thumbHeight) / 2, - width = thumbWidth, - height = thumbHeight, - border = null, - shapeStyle = thumbShapeStyle + private fun resolveThumbBounds(view: SwitchCompat, pixelsDensity: Float): GlobalBoundsInPx? { + val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds(view, pixelsDensity) + val thumbDimensions = resolveThumbSizeInPx(view) ?: return null + val thumbLeft = (viewGlobalBounds.x * pixelsDensity).toInt() + + view.thumbDrawable.bounds.left + val thumbTop = (viewGlobalBounds.y * pixelsDensity).toInt() + + view.thumbDrawable.bounds.top + return GlobalBoundsInPx( + x = thumbLeft, + y = thumbTop, + width = thumbDimensions.first, + height = thumbDimensions.second + ) + } + + private fun resolveTrackBounds(view: SwitchCompat, pixelsDensity: Float): GlobalBoundsInPx? { + val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds(view, pixelsDensity) + val trackSize = resolveTrackSizeInPx(view) ?: return null + return view.trackDrawable?.let { + GlobalBoundsInPx( + x = (viewGlobalBounds.x * pixelsDensity).toInt() + it.bounds.left, + y = (viewGlobalBounds.y * pixelsDensity).toInt() + it.bounds.top, + width = trackSize.first, + height = trackSize.second ) - wireframes.add(thumbWireframe) } - return wireframes + } + + @UiThread + override fun resolveCheckable( + view: SwitchCompat, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): List { + return createSwitchCompatDrawableWireFrames(view, mappingContext, asyncJobStatusCallback) } @UiThread @@ -158,27 +167,20 @@ internal open class SwitchCompatMapper( view: SwitchCompat, mappingContext: MappingContext ): List? { - val trackThumbDimensions = resolveThumbAndTrackDimensions(view, mappingContext.systemInformation) ?: return null - + val pixelsDensity = mappingContext.systemInformation.screenDensity val wireframes = mutableListOf() - - val trackWidth = trackThumbDimensions[TRACK_WIDTH_INDEX] - val trackHeight = trackThumbDimensions[TRACK_HEIGHT_INDEX] + val trackBounds = resolveTrackBounds(view, pixelsDensity) ?: return null val checkableColor = resolveCheckableColor(view) - val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds( - view, - mappingContext.systemInformation.screenDensity - ) val trackId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, TRACK_KEY_NAME) if (trackId != null) { val trackShapeStyle = resolveTrackShapeStyle(view, checkableColor) val trackWireframe = MobileSegment.Wireframe.ShapeWireframe( id = trackId, - x = viewGlobalBounds.x + viewGlobalBounds.width - trackWidth, - y = viewGlobalBounds.y + (viewGlobalBounds.height - trackHeight) / 2, - width = trackWidth, - height = trackHeight, + x = trackBounds.x.densityNormalized(pixelsDensity).toLong(), + y = trackBounds.y.densityNormalized(pixelsDensity).toLong(), + width = trackBounds.width.densityNormalized(pixelsDensity).toLong(), + height = trackBounds.height.densityNormalized(pixelsDensity).toLong(), border = null, shapeStyle = trackShapeStyle ) @@ -192,71 +194,47 @@ internal open class SwitchCompatMapper( // region Internal - protected fun resolveCheckableColor(view: SwitchCompat): String { + private fun resolveCheckableColor(view: SwitchCompat): String { return colorStringFormatter.formatColorAndAlphaAsHexString(view.currentTextColor, OPAQUE_ALPHA_VALUE) } - private fun resolveThumbShapeStyle(view: SwitchCompat, checkBoxColor: String): MobileSegment.ShapeStyle { + private fun resolveTrackShapeStyle(view: SwitchCompat, checkBoxColor: String): MobileSegment.ShapeStyle { return MobileSegment.ShapeStyle( backgroundColor = checkBoxColor, - view.alpha, - cornerRadius = THUMB_CORNER_RADIUS + view.alpha ) } - protected fun resolveTrackShapeStyle(view: SwitchCompat, checkBoxColor: String): MobileSegment.ShapeStyle { - return MobileSegment.ShapeStyle( - backgroundColor = checkBoxColor, - view.alpha - ) + private fun resolveThumbSizeInPx(view: SwitchCompat): Pair? { + return view.thumbDrawable?.let { + Pair(it.intrinsicWidth, it.intrinsicHeight) + } } - protected fun resolveThumbAndTrackDimensions( - view: SwitchCompat, - systemInformation: SystemInformation - ): LongArray? { - val density = systemInformation.screenDensity - val thumbWidth: Long - val trackHeight: Long - // based on the implementation there is nothing drawn in the switcher area if one of - // these are null - val thumbDrawable = view.thumbDrawable - val trackDrawable = view.trackDrawable - if (thumbDrawable == null || trackDrawable == null) { - return null + private fun resolveTrackSizeInPx(view: SwitchCompat): Pair? { + return view.trackDrawable?.let { + // NinePatchDrawable optical size depends on its size + Pair(it.bounds.width(), it.bounds.height()) } - val paddingRect = Rect() - thumbDrawable.getPadding(paddingRect) - val totalHorizontalPadding = - paddingRect.left.densityNormalized(systemInformation.screenDensity) + - paddingRect.right.densityNormalized(systemInformation.screenDensity) - thumbWidth = thumbDrawable.intrinsicWidth.densityNormalized(density).toLong() - - totalHorizontalPadding - val thumbHeight: Long = thumbWidth - // for some reason there is no padding added in the trackDrawable - // in order to normalise with the padding applied to the width we will have to - // use the horizontal padding applied. - trackHeight = trackDrawable.intrinsicHeight.densityNormalized(density).toLong() - - totalHorizontalPadding - val trackWidth = thumbWidth * 2 - val dimensions = LongArray(NUMBER_OF_DIMENSIONS) - dimensions[THUMB_WIDTH_INDEX] = thumbWidth - dimensions[THUMB_HEIGHT_INDEX] = thumbHeight - dimensions[TRACK_WIDTH_INDEX] = trackWidth - dimensions[TRACK_HEIGHT_INDEX] = trackHeight - return dimensions } + /** + * Similar to [GlobalBounds] but in pixel. + */ + data class GlobalBoundsInPx( + val x: Int, + val y: Int, + val width: Int, + val height: Int + ) + // endregion companion object { - private const val NUMBER_OF_DIMENSIONS = 4 - internal const val THUMB_WIDTH_INDEX = 0 - internal const val THUMB_HEIGHT_INDEX = 1 - internal const val TRACK_WIDTH_INDEX = 2 - internal const val TRACK_HEIGHT_INDEX = 3 internal const val THUMB_KEY_NAME = "thumb" internal const val TRACK_KEY_NAME = "track" - internal const val THUMB_CORNER_RADIUS = 10 } } + +private typealias Width = Int +private typealias Height = Int diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt index 997d7ba076..318371dd46 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt @@ -6,10 +6,13 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.content.res.Resources import android.graphics.Rect import android.graphics.drawable.Drawable +import android.graphics.drawable.Drawable.ConstantState import androidx.appcompat.widget.SwitchCompat import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper @@ -20,17 +23,19 @@ 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 org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.Test import org.mockito.Mock import org.mockito.kotlin.any +import org.mockito.kotlin.anyVararg import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +@ForgeConfiguration(value = ForgeConfigurator::class, seed = 0x27e4b032201e5L) internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTest() { lateinit var testedSwitchCompatMapper: SwitchCompatMapper @@ -57,6 +62,12 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe @Mock lateinit var mockTrackDrawable: Drawable + @Mock + lateinit var mockConstantState: ConstantState + + @Mock + lateinit var mockCloneDrawable: Drawable + @IntForgery(min = 20, max = 200) var fakeThumbHeight: Int = 0 @@ -81,6 +92,15 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe @StringForgery(regex = "#[0-9A-F]{8}") lateinit var fakeCurrentTextColorString: String + lateinit var fakeThumbBounds: Rect + + lateinit var fakeTrackBounds: Rect + + var expectedThumbLeft: Long = 0 + var expectedThumbTop: Long = 0 + var expectedTrackLeft: Long = 0 + var expectedTrackTop: Long = 0 + private var normalizedThumbHeight: Long = 0 protected var normalizedThumbWidth: Long = 0 private var normalizedTrackWidth: Long = 0 @@ -90,6 +110,8 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe @BeforeEach fun `set up`(forge: Forge) { + fakeThumbBounds = Rect(forge.aSmallInt(), forge.aSmallInt(), forge.aSmallInt(), forge.aSmallInt()) + fakeTrackBounds = Rect(forge.aSmallInt(), forge.aSmallInt(), forge.aSmallInt(), forge.aSmallInt()) fakeTextWireframes = forge.aList(size = 1) { getForgery() } normalizedThumbHeight = fakeThumbHeight.toLong() .densityNormalized(fakeMappingContext.systemInformation.screenDensity) @@ -103,10 +125,15 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe .densityNormalized(fakeMappingContext.systemInformation.screenDensity) normalizedThumbRightPadding = fakeThumbRightPadding.toLong() .densityNormalized(fakeMappingContext.systemInformation.screenDensity) + whenever(mockCloneDrawable.bounds).thenReturn(fakeTrackBounds) + whenever(mockConstantState.newDrawable(anyVararg(Resources::class))).thenReturn(mockCloneDrawable) whenever(mockThumbDrawable.intrinsicHeight).thenReturn(fakeThumbHeight) whenever(mockThumbDrawable.intrinsicWidth).thenReturn(fakeThumbWidth) whenever(mockTrackDrawable.intrinsicHeight).thenReturn(fakeTrackHeight) whenever(mockTrackDrawable.intrinsicWidth).thenReturn(fakeTrackWidth) + whenever(mockTrackDrawable.constantState).thenReturn(mockConstantState) + whenever(mockTrackDrawable.bounds).thenReturn(fakeTrackBounds) + whenever(mockThumbDrawable.bounds).thenReturn(fakeThumbBounds) whenever(mockThumbDrawable.getPadding(any())).thenAnswer { val paddingRect = it.getArgument(0) paddingRect.left = fakeThumbLeftPadding @@ -130,7 +157,7 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe SwitchCompatMapper.THUMB_KEY_NAME ) ).thenReturn(fakeThumbIdentifier) - whenever(mockTextWireframeMapper.map(eq(mockSwitch), eq(fakeMappingContext), any(), eq(mockInternalLogger))) + whenever(mockTextWireframeMapper.map(eq(mockSwitch), any(), any(), eq(mockInternalLogger))) .thenReturn(fakeTextWireframes) whenever( mockViewBoundsResolver.resolveViewGlobalBounds( @@ -144,6 +171,23 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe ).thenReturn(fakeCurrentTextColorString) testedSwitchCompatMapper = setupTestedMapper() + val pixelsDensity = fakeMappingContext.systemInformation.screenDensity + expectedThumbLeft = ( + fakeViewGlobalBounds.x * pixelsDensity + + fakeThumbBounds.left + ).toLong().densityNormalized(density = pixelsDensity) + expectedThumbTop = ( + fakeViewGlobalBounds.y * pixelsDensity + + fakeThumbBounds.top + ).toLong().densityNormalized(density = pixelsDensity) + expectedTrackLeft = ( + fakeViewGlobalBounds.x * pixelsDensity + + fakeTrackBounds.left + ).toLong().densityNormalized(density = pixelsDensity) + expectedTrackTop = ( + fakeViewGlobalBounds.y * pixelsDensity + + fakeTrackBounds.top + ).toLong().densityNormalized(density = pixelsDensity) } internal abstract fun setupTestedMapper(): SwitchCompatMapper @@ -153,11 +197,12 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe // Given whenever(mockSwitch.thumbDrawable).thenReturn(null) whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) + val allowMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) // When val resolvedWireframes = testedSwitchCompatMapper.map( mockSwitch, - fakeMappingContext, + allowMappingContext, mockAsyncJobStatusCallback, mockInternalLogger ) @@ -166,16 +211,17 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) } - @Test + @RepeatedTest(10) fun `M resolve the switch as wireframes W map() { no trackDrawable }`(forge: Forge) { // Given whenever(mockSwitch.trackDrawable).thenReturn(null) whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) + val allowMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) // When val resolvedWireframes = testedSwitchCompatMapper.map( mockSwitch, - fakeMappingContext, + allowMappingContext, mockAsyncJobStatusCallback, mockInternalLogger ) @@ -183,58 +229,4 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe // Then assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) } - - @RepeatedTest(8) - @Disabled("TODO RUM-4715, will be immediately fixed in next commit") - fun `M resolve the switch as wireframes W map() { can't generate id for trackWireframe }`( - forge: Forge - ) { - // Given - whenever( - mockViewIdentifierResolver.resolveChildUniqueIdentifier( - mockSwitch, - SwitchCompatMapper.TRACK_KEY_NAME - ) - ).thenReturn(null) - val isChecked = forge.aBool() - whenever(mockSwitch.isChecked).thenReturn(isChecked) - val expectedThumbWidth = - normalizedThumbWidth - normalizedThumbRightPadding - normalizedThumbLeftPadding - val expectedTrackWidth = expectedThumbWidth * 2 - val expectedX = if (isChecked) { - fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedThumbWidth - } else { - fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedTrackWidth - } - val expectedThumbWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeThumbIdentifier, - x = expectedX, - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - expectedThumbWidth) / 2, - width = expectedThumbWidth, - height = expectedThumbWidth, - border = null, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeCurrentTextColorString, - mockSwitch.alpha, - cornerRadius = SwitchCompatMapper.THUMB_CORNER_RADIUS - ) - ) - - // When - val resolvedWireframes = testedSwitchCompatMapper.map( - mockSwitch, - fakeMappingContext, - mockAsyncJobStatusCallback, - mockInternalLogger - ) - - // Then - if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes + expectedThumbWireframe) - } else { - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes) - } - } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt index 467f246142..787a9bc2ad 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt @@ -6,19 +6,28 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.graphics.drawable.Drawable import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.mockito.ArgumentMatchers import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -30,6 +39,11 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { + private val xCaptor = argumentCaptor() + private val yCaptor = argumentCaptor() + private val widthCaptor = argumentCaptor() + private val heightCaptor = argumentCaptor() + private val drawableCaptor = argumentCaptor() override fun setupTestedMapper(): SwitchCompatMapper { return SwitchCompatMapper( mockTextWireframeMapper, @@ -40,40 +54,23 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { ) } - @Test - fun `M resolve the switch as wireframes W map() { checked }`() { + @RepeatedTest(8) + fun `M resolve the switch as wireframes W map()`(forge: Forge) { // Given - whenever(mockSwitch.isChecked).thenReturn(true) - val expectedThumbWidth = - normalizedThumbWidth - normalizedThumbRightPadding - normalizedThumbLeftPadding - val expectedTrackWidth = expectedThumbWidth * 2 - val expectedTrackHeight = - normalizedTrackHeight - normalizedThumbRightPadding - normalizedThumbLeftPadding + whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) + val density = fakeMappingContext.systemInformation.screenDensity val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( id = fakeTrackIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedTrackWidth, - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - expectedTrackHeight) / 2, - width = expectedTrackWidth, - height = expectedTrackHeight, + x = expectedTrackLeft, + y = expectedTrackTop, + width = fakeTrackBounds.width().toLong().densityNormalized(density), + height = fakeTrackBounds.height().toLong().densityNormalized(density), border = null, shapeStyle = MobileSegment.ShapeStyle( backgroundColor = fakeCurrentTextColorString, mockSwitch.alpha ) ) - val expectedThumbWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeThumbIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedThumbWidth, - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - expectedThumbWidth) / 2, - width = expectedThumbWidth, - height = expectedThumbWidth, - border = null, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeCurrentTextColorString, - mockSwitch.alpha, - cornerRadius = SwitchCompatMapper.THUMB_CORNER_RADIUS - ) - ) // When val resolvedWireframes = testedSwitchCompatMapper.map( @@ -84,107 +81,116 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { ) // Then - if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes + expectedTrackWireframe + expectedThumbWireframe) + if (fakeMappingContext.privacy != SessionReplayPrivacy.ALLOW) { + assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedTrackWireframe) } else { - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes + expectedTrackWireframe) + assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) + + verify(fakeMappingContext.imageWireframeHelper, times(2)).createImageWireframe( + view = eq(mockSwitch), + currentWireframeIndex = ArgumentMatchers.anyInt(), + x = xCaptor.capture(), + y = yCaptor.capture(), + width = widthCaptor.capture(), + height = heightCaptor.capture(), + usePIIPlaceholder = ArgumentMatchers.anyBoolean(), + drawable = drawableCaptor.capture(), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), + clipping = eq(null), + shapeStyle = eq(null), + border = eq(null), + prefix = ArgumentMatchers.anyString() + ) + + assertThat(xCaptor.allValues).containsOnly(expectedThumbLeft, expectedTrackLeft) + assertThat(yCaptor.allValues).containsOnly(expectedThumbTop, expectedTrackTop) + assertThat(widthCaptor.allValues).containsOnly( + mockThumbDrawable.intrinsicWidth, + (fakeTrackBounds.width()) + ) + assertThat(heightCaptor.allValues).containsOnly( + mockThumbDrawable.intrinsicHeight, + (fakeTrackBounds.height()) + ) + assertThat(drawableCaptor.allValues).containsOnly(mockThumbDrawable, mockCloneDrawable) } } @Test - @Disabled("TODO RUM-4715, will be immediately fixed in next commit") - fun `M resolve the switch as wireframes W map() { not checked }`() { + fun `M resolve the switch as wireframes W map() { can't generate id for thumbWireframe for masked view }`( + forge: Forge + ) { // Given - whenever(mockSwitch.isChecked).thenReturn(false) - val expectedThumbWidth = - normalizedThumbWidth - normalizedThumbRightPadding - normalizedThumbLeftPadding - val expectedTrackWidth = expectedThumbWidth * 2 - val expectedTrackHeight = - normalizedTrackHeight - normalizedThumbRightPadding - normalizedThumbLeftPadding + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier( + mockSwitch, + SwitchCompatMapper.THUMB_KEY_NAME + ) + ).thenReturn(null) + whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) + val density = fakeMappingContext.systemInformation.screenDensity val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( id = fakeTrackIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedTrackWidth, - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - expectedTrackHeight) / 2, - width = expectedTrackWidth, - height = expectedTrackHeight, + x = expectedTrackLeft, + y = expectedTrackTop, + width = fakeTrackBounds.width().toLong().densityNormalized(density), + height = fakeTrackBounds.height().toLong().densityNormalized(density), border = null, shapeStyle = MobileSegment.ShapeStyle( backgroundColor = fakeCurrentTextColorString, mockSwitch.alpha ) ) - val expectedThumbWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeThumbIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedTrackWidth, - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - expectedThumbWidth) / 2, - width = expectedThumbWidth, - height = expectedThumbWidth, - border = null, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeCurrentTextColorString, - mockSwitch.alpha, - cornerRadius = SwitchCompatMapper.THUMB_CORNER_RADIUS - ) - ) // When val resolvedWireframes = testedSwitchCompatMapper.map( mockSwitch, - fakeMappingContext, + fakeMappingContext.copy(privacy = SessionReplayPrivacy.MASK), mockAsyncJobStatusCallback, mockInternalLogger ) // Then - if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes + expectedTrackWireframe + expectedThumbWireframe) - } else { - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes + expectedTrackWireframe) - } + assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes + expectedTrackWireframe) } - @Test - fun `M resolve the switch as wireframes W map() { can't generate id for thumbWireframe }`( + @RepeatedTest(8) + fun `M resolve the switch as wireframes W map() { can't generate id for trackWireframe }`( forge: Forge ) { // Given whenever( mockViewIdentifierResolver.resolveChildUniqueIdentifier( mockSwitch, - SwitchCompatMapper.THUMB_KEY_NAME + SwitchCompatMapper.TRACK_KEY_NAME ) ).thenReturn(null) whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) - val expectedThumbWidth = normalizedThumbWidth - normalizedThumbRightPadding - normalizedThumbLeftPadding - val expectedTrackWidth = expectedThumbWidth * 2 - val expectedTrackHeight = normalizedTrackHeight - normalizedThumbRightPadding - normalizedThumbLeftPadding - val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeTrackIdentifier, - x = fakeViewGlobalBounds.x + fakeViewGlobalBounds.width - expectedTrackWidth, - y = fakeViewGlobalBounds.y + (fakeViewGlobalBounds.height - expectedTrackHeight) / 2, - width = expectedTrackWidth, - height = expectedTrackHeight, - border = null, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeCurrentTextColorString, - mockSwitch.alpha - ) - ) // When val resolvedWireframes = testedSwitchCompatMapper.map( mockSwitch, - fakeMappingContext, + fakeMappingContext.copy(privacy = SessionReplayPrivacy.MASK), mockAsyncJobStatusCallback, mockInternalLogger ) // Then - assertThat(resolvedWireframes) - .isEqualTo(fakeTextWireframes + expectedTrackWireframe) + assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) + verify(fakeMappingContext.imageWireframeHelper, never()).createImageWireframe( + view = any(), + currentWireframeIndex = any(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = any(), + asyncJobStatusCallback = any(), + clipping = any(), + shapeStyle = any(), + border = any(), + prefix = any() + ) } } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/DropDownSwitchersFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/DropDownSwitchersFragment.kt index 4d6e9dcb42..4e6614b42d 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/DropDownSwitchersFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/DropDownSwitchersFragment.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Spinner +import androidx.appcompat.widget.SwitchCompat import androidx.fragment.app.Fragment import com.datadog.android.sample.R @@ -19,6 +20,20 @@ internal class DropDownSwitchersFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val root = inflater.inflate(R.layout.fragment_drop_down_switchers_components, container, false) + val disabledSwitchCompat = root.findViewById(R.id.app_compat_switcher_disabled) + root.findViewById(R.id.app_compat_switcher).apply { + setOnCheckedChangeListener { _, isChecked -> + disabledSwitchCompat.isEnabled = isChecked + } + } + + val disabledSwitchMaterial = root.findViewById(R.id.material_switcher_disabled) + root.findViewById(R.id.material_switcher).apply { + setOnCheckedChangeListener { _, isChecked -> + disabledSwitchMaterial.isEnabled = isChecked + } + } + root.findViewById(R.id.default_spinner)?.let { spinner -> ArrayAdapter.createFromResource( requireContext(), diff --git a/sample/kotlin/src/main/res/layout/fragment_drop_down_switchers_components.xml b/sample/kotlin/src/main/res/layout/fragment_drop_down_switchers_components.xml index c4cbe705d3..eeb071e790 100644 --- a/sample/kotlin/src/main/res/layout/fragment_drop_down_switchers_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_drop_down_switchers_components.xml @@ -20,8 +20,18 @@ android:textColor="@color/datadog_violet" android:text="@string/app_compat_switch" /> + + + + Default spinner App Compat Spinner App Compat Switch + App Compat Switch (Disabled) Material Switch + Material Switch (Disabled) Dropdowns and Switchers Components Planet Sliders and Steppers Components From 747a8772b54dea1d7b18605fcb8349ecc0f7ce3e Mon Sep 17 00:00:00 2001 From: luyi Date: Mon, 8 Jul 2024 10:00:57 +0200 Subject: [PATCH 3/3] Fix compound button tests issue --- .../recorder/mapper/BaseCheckableTextViewMapperTest.kt | 9 +++++---- .../recorder/mapper/BaseSwitchCompatMapperTest.kt | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt index f128b17ca2..efeef50108 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt @@ -43,6 +43,7 @@ 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.verify import org.mockito.kotlin.whenever @@ -218,8 +219,8 @@ internal abstract class BaseCheckableTextViewMapperTest : drawable = eq(mockClonedDrawable), asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), clipping = eq(MobileSegment.WireframeClip()), - shapeStyle = eq(null), - border = eq(null), + shapeStyle = isNull(), + border = isNull(), prefix = anyString() ) } @@ -257,8 +258,8 @@ internal abstract class BaseCheckableTextViewMapperTest : drawable = eq(mockClonedDrawable), asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), clipping = eq(MobileSegment.WireframeClip()), - shapeStyle = eq(null), - border = eq(null), + shapeStyle = isNull(), + border = isNull(), prefix = anyString() ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt index 318371dd46..d1ceca3e1f 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt @@ -35,7 +35,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -@ForgeConfiguration(value = ForgeConfigurator::class, seed = 0x27e4b032201e5L) +@ForgeConfiguration(value = ForgeConfigurator::class) internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTest() { lateinit var testedSwitchCompatMapper: SwitchCompatMapper