diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 535d530ea3..54ee6a6078 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -95,7 +95,7 @@ abstract class com.datadog.android.sessionreplay.recorder.mapper.BaseAsyncBackgr constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) override fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List protected open fun resolveViewBackground(android.view.View, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? - protected open fun resolveBackgroundAsShapeWireframe(android.view.View, com.datadog.android.sessionreplay.utils.GlobalBounds, Int, Int, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle?): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ShapeWireframe? + protected open fun resolveBackgroundAsShapeWireframe(android.view.View, com.datadog.android.sessionreplay.utils.GlobalBounds, Int, Int, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle?, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ShapeWireframe? protected open fun resolveBackgroundAsImageWireframe(android.view.View, com.datadog.android.sessionreplay.utils.GlobalBounds, Int, Int, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? companion object open class com.datadog.android.sessionreplay.recorder.mapper.BaseViewGroupMapper : BaseAsyncBackgroundWireframeMapper, TraverseAllChildrenMapper @@ -104,6 +104,7 @@ abstract class com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMa constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) protected fun resolveViewId(android.view.View): Long protected fun resolveShapeStyle(android.graphics.drawable.Drawable, Float, com.datadog.android.api.InternalLogger): com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? + protected fun resolveBackgroundStyleInfo(android.graphics.drawable.Drawable, Float, Float, com.datadog.android.api.InternalLogger): Pair class com.datadog.android.sessionreplay.recorder.mapper.EditTextMapper : TextViewMapper constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) override fun resolveCapturedText(android.widget.EditText, com.datadog.android.sessionreplay.TextAndInputPrivacy, Boolean): String diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 5676f43536..29619e2ede 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -1518,7 +1518,8 @@ public abstract class com/datadog/android/sessionreplay/recorder/mapper/BaseAsyn public fun (Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver;Lcom/datadog/android/sessionreplay/utils/ColorStringFormatter;Lcom/datadog/android/sessionreplay/utils/ViewBoundsResolver;Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper;)V public fun map (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List; protected fun resolveBackgroundAsImageWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; - protected fun resolveBackgroundAsShapeWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; + protected fun resolveBackgroundAsShapeWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; + public static synthetic fun resolveBackgroundAsShapeWireframe$default (Lcom/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper;Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; protected fun resolveViewBackground (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } @@ -1535,6 +1536,7 @@ public abstract class com/datadog/android/sessionreplay/recorder/mapper/BaseWire protected final fun getDrawableToColorMapper ()Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper; protected final fun getViewBoundsResolver ()Lcom/datadog/android/sessionreplay/utils/ViewBoundsResolver; protected final fun getViewIdentifierResolver ()Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver; + protected final fun resolveBackgroundStyleInfo (Landroid/graphics/drawable/Drawable;FFLcom/datadog/android/api/InternalLogger;)Lkotlin/Pair; protected final fun resolveShapeStyle (Landroid/graphics/drawable/Drawable;FLcom/datadog/android/api/InternalLogger;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; protected final fun resolveViewId (Landroid/view/View;)J } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt index 29595f696a..0c87fea64e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt @@ -46,26 +46,30 @@ internal class ActionBarContainerMapper( // Fortunately, the ActionBarContainer.mBackground field we're interested in is package private, // which allows us to access it via the DatadogActionBarContainerAccessor. val background = DatadogActionBarContainerAccessor(view).getBackgroundDrawable() - val shapeStyle = background?.let { resolveShapeStyle(it, view.alpha, internalLogger) } val id = viewIdentifierResolver.resolveChildUniqueIdentifier(view, PREFIX_BACKGROUND_DRAWABLE) - if ((shapeStyle != null) && (id != null)) { + return if (background != null && id != null) { val density = mappingContext.systemInformation.screenDensity - val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, density) + val (shapeStyle, shapeBorder) = resolveBackgroundStyleInfo(background, view.alpha, density, internalLogger) - return listOf( - MobileSegment.Wireframe.ShapeWireframe( - id, - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height, - shapeStyle = shapeStyle, - border = null + if (shapeStyle != null) { + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, density) + listOf( + MobileSegment.Wireframe.ShapeWireframe( + id, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + shapeStyle = shapeStyle, + border = shapeBorder + ) ) - ) + } else { + emptyList() + } } else { - return emptyList() + emptyList() } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/DrawableStyleExtractor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/DrawableStyleExtractor.kt new file mode 100644 index 0000000000..58afc73957 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/DrawableStyleExtractor.kt @@ -0,0 +1,176 @@ +/* + * 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.annotation.SuppressLint +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.os.Build +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper + +/** + * Extracted style information from a drawable, including fill color, border, and corner radius. + */ +internal data class DrawableStyleInfo( + val color: Int?, + val cornerRadius: Float, + val borderColor: Int?, + val borderWidth: Float +) + +/** + * Extracts full style information (fill color, border, corner radius) from drawables. + * Complements [DrawableToColorMapper] by also extracting border and corner radius from + * [GradientDrawable] backgrounds via reflection. + */ +internal class DrawableStyleExtractor( + private val drawableToColorMapper: DrawableToColorMapper +) { + + fun extractStyleInfo(drawable: Drawable, internalLogger: InternalLogger): DrawableStyleInfo { + val color = drawableToColorMapper.mapDrawableToColor(drawable, internalLogger) + val gradientDrawable = findGradientDrawable(drawable) + + val cornerRadius = gradientDrawable?.let { extractCornerRadius(it) } ?: 0f + val borderColor: Int? + val borderWidth: Float + + if (gradientDrawable != null) { + val strokeInfo = extractStrokeInfo(gradientDrawable) + borderColor = strokeInfo.first + borderWidth = strokeInfo.second + } else { + borderColor = null + borderWidth = 0f + } + + return DrawableStyleInfo( + color = color, + cornerRadius = cornerRadius, + borderColor = borderColor, + borderWidth = borderWidth + ) + } + + /** + * Recursively unwraps wrapper drawables to find the inner [GradientDrawable]. + */ + private fun findGradientDrawable(drawable: Drawable): GradientDrawable? { + return when (drawable) { + is GradientDrawable -> drawable + is StateListDrawable -> { + @Suppress("UNNECESSARY_SAFE_CALL") + drawable.current?.let { findGradientDrawable(it) } + } + is RippleDrawable -> findGradientDrawableInLayers(drawable) + is LayerDrawable -> findGradientDrawableInLayers(drawable) + is InsetDrawable -> drawable.drawable?.let { findGradientDrawable(it) } + else -> null + } + } + + private fun findGradientDrawableInLayers(drawable: LayerDrawable): GradientDrawable? { + for (i in 0 until drawable.numberOfLayers) { + @Suppress("UnsafeThirdPartyFunctionCall") + val layer = drawable.getDrawable(i) + if (layer != null) { + val result = findGradientDrawable(layer) + if (result != null) return result + } + } + return null + } + + @Suppress("SwallowedException") + private fun extractCornerRadius(drawable: GradientDrawable): Float { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + drawable.cornerRadius + } else { + try { + @Suppress("UnsafeThirdPartyFunctionCall") + val state = gradientStateField?.get(drawable) ?: return 0f + @Suppress("UnsafeThirdPartyFunctionCall") + (radiusField?.get(state) as? Float) ?: 0f + } catch (e: IllegalAccessException) { + 0f + } catch (e: IllegalArgumentException) { + 0f + } + } + } + + @Suppress("SwallowedException") + private fun extractStrokeInfo(drawable: GradientDrawable): Pair { + val strokePaint = try { + @Suppress("UnsafeThirdPartyFunctionCall") + strokePaintField?.get(drawable) as? Paint + } catch (e: IllegalAccessException) { + null + } catch (e: IllegalArgumentException) { + null + } catch (e: ExceptionInInitializerError) { + null + } + + @Suppress("UnsafeThirdPartyFunctionCall") + return if (strokePaint != null && strokePaint.strokeWidth > 0f) { + Pair(strokePaint.color, strokePaint.strokeWidth) + } else { + Pair(null, 0f) + } + } + + companion object { + @SuppressLint("DiscouragedPrivateApi") + @Suppress("PrivateAPI", "SwallowedException", "TooGenericExceptionCaught") + internal val strokePaintField = try { + GradientDrawable::class.java.getDeclaredField("mStrokePaint").apply { + this.isAccessible = true + } + } catch (e: NoSuchFieldException) { + null + } catch (e: SecurityException) { + null + } catch (e: NullPointerException) { + null + } + + @SuppressLint("DiscouragedPrivateApi") + @Suppress("PrivateAPI", "SwallowedException", "TooGenericExceptionCaught") + private val gradientStateField = try { + GradientDrawable::class.java.getDeclaredField("mGradientState").apply { + this.isAccessible = true + } + } catch (e: NoSuchFieldException) { + null + } catch (e: SecurityException) { + null + } catch (e: NullPointerException) { + null + } + + @SuppressLint("DiscouragedPrivateApi") + @Suppress("PrivateAPI", "SwallowedException", "TooGenericExceptionCaught") + private val radiusField = try { + gradientStateField?.type?.getDeclaredField("mRadius")?.apply { + this.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/ViewWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt index da6c35ea8b..d9beda701f 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt @@ -40,10 +40,13 @@ internal class ViewWireframeMapper( view, mappingContext.systemInformation.screenDensity ) - val shapeStyle = view.background?.let { resolveShapeStyle(it, view.alpha, internalLogger) } + val background = view.background ?: return emptyList() - if (shapeStyle != null) { - return listOf( + val density = mappingContext.systemInformation.screenDensity + val (shapeStyle, shapeBorder) = resolveBackgroundStyleInfo(background, view.alpha, density, internalLogger) + + return if (shapeStyle != null) { + listOf( MobileSegment.Wireframe.ShapeWireframe( resolveViewId(view), viewGlobalBounds.x, @@ -51,11 +54,11 @@ internal class ViewWireframeMapper( viewGlobalBounds.width, viewGlobalBounds.height, shapeStyle = shapeStyle, - border = null + border = shapeBorder ) ) } else { - return emptyList() + emptyList() } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt index 75d2eb57b9..ee108a081d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt @@ -72,9 +72,11 @@ abstract class BaseAsyncBackgroundWireframeMapper ( asyncJobStatusCallback: AsyncJobStatusCallback, internalLogger: InternalLogger ): MobileSegment.Wireframe? { - val shapeStyle = view.background?.let { resolveShapeStyle(it, view.alpha, internalLogger) } + val background = view.background ?: return null + val density = mappingContext.systemInformation.screenDensity + val (shapeStyle, shapeBorder) = resolveBackgroundStyleInfo(background, view.alpha, density, internalLogger) - val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, mappingContext.systemInformation.screenDensity) + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, density) val width = view.width val height = view.height @@ -93,7 +95,8 @@ abstract class BaseAsyncBackgroundWireframeMapper ( bounds = bounds, width = width, height = height, - shapeStyle = shapeStyle + shapeStyle = shapeStyle, + border = shapeBorder ) } } @@ -106,13 +109,15 @@ abstract class BaseAsyncBackgroundWireframeMapper ( * @param width the view width. * @param height the view height. * @param shapeStyle the optional [MobileSegment.ShapeStyle] to use. + * @param border the optional [MobileSegment.ShapeBorder] to use. */ protected open fun resolveBackgroundAsShapeWireframe( view: View, bounds: GlobalBounds, width: Int, height: Int, - shapeStyle: MobileSegment.ShapeStyle? + shapeStyle: MobileSegment.ShapeStyle?, + border: MobileSegment.ShapeBorder? = null ): MobileSegment.Wireframe.ShapeWireframe? { val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier( view, @@ -128,7 +133,7 @@ abstract class BaseAsyncBackgroundWireframeMapper ( width = width.densityNormalized(density).toLong(), height = height.densityNormalized(density).toLong(), shapeStyle = shapeStyle, - border = null + border = border ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt index aff15ffee3..333b1f95a6 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt @@ -9,6 +9,8 @@ package com.datadog.android.sessionreplay.recorder.mapper import android.graphics.drawable.Drawable import android.view.View import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.utils.densityNormalized +import com.datadog.android.sessionreplay.internal.recorder.mapper.DrawableStyleExtractor import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.DrawableToColorMapper @@ -35,6 +37,8 @@ abstract class BaseWireframeMapper( protected val drawableToColorMapper: DrawableToColorMapper ) : WireframeMapper { + internal val drawableStyleExtractor = DrawableStyleExtractor(drawableToColorMapper) + /** * Resolves the [View] unique id to be used in the mapped [MobileSegment.Wireframe]. */ @@ -57,4 +61,40 @@ abstract class BaseWireframeMapper( null } } + + /** + * Resolves both [MobileSegment.ShapeStyle] and [MobileSegment.ShapeBorder] from a drawable, + * extracting fill color, corner radius, and stroke/border info. + */ + protected fun resolveBackgroundStyleInfo( + drawable: Drawable, + viewAlpha: Float, + density: Float, + internalLogger: InternalLogger + ): Pair { + val styleInfo = drawableStyleExtractor.extractStyleInfo(drawable, internalLogger) + + val shapeStyle = if (styleInfo.color != null) { + val cornerRadiusNormalized = styleInfo.cornerRadius.toLong().densityNormalized(density) + MobileSegment.ShapeStyle( + backgroundColor = colorStringFormatter.formatColorAsHexString(styleInfo.color), + opacity = viewAlpha, + cornerRadius = if (cornerRadiusNormalized > 0) cornerRadiusNormalized else null + ) + } else { + null + } + + val shapeBorder = if (styleInfo.borderColor != null && styleInfo.borderWidth > 0f) { + MobileSegment.ShapeBorder( + color = colorStringFormatter.formatColorAsHexString(styleInfo.borderColor), + width = styleInfo.borderWidth.toLong().densityNormalized(density) + ) + } else { + null + } + + @Suppress("UnsafeThirdPartyFunctionCall") + return Pair(shapeStyle, shapeBorder) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/DrawableStyleExtractorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/DrawableStyleExtractorTest.kt new file mode 100644 index 0000000000..b69423db3d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/DrawableStyleExtractorTest.kt @@ -0,0 +1,221 @@ +/* + * 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.Paint +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.StateListDrawable +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue +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.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(value = ForgeConfigurator::class) +internal class DrawableStyleExtractorTest { + + private lateinit var testedExtractor: DrawableStyleExtractor + + @Mock + lateinit var mockDrawableToColorMapper: DrawableToColorMapper + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @IntForgery + var fakeFillColor: Int = 0 + + @BeforeEach + fun `set up`() { + testedExtractor = DrawableStyleExtractor(mockDrawableToColorMapper) + } + + // region GradientDrawable stroke extraction + + @Test + fun `M extract stroke info W extractStyleInfo { GradientDrawable with stroke }`( + @IntForgery fakeStrokeColor: Int, + @FloatForgery(min = 1f, max = 20f) fakeStrokeWidth: Float + ) { + // Given + assumeTrue(DrawableStyleExtractor.strokePaintField != null) + val gradientDrawable = GradientDrawable() + // Mock the Paint since Robolectric's Paint shadow may not properly + // store/return color via getColor() in CI + val strokePaint = mock() + whenever(strokePaint.color).thenReturn(fakeStrokeColor) + whenever(strokePaint.strokeWidth).thenReturn(fakeStrokeWidth) + DrawableStyleExtractor.strokePaintField!!.set(gradientDrawable, strokePaint) + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(gradientDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(gradientDrawable, mockInternalLogger) + + // Then + assertThat(result.color).isEqualTo(fakeFillColor) + assertThat(result.borderColor).isEqualTo(fakeStrokeColor) + assertThat(result.borderWidth).isEqualTo(fakeStrokeWidth) + } + + @Test + fun `M return null border W extractStyleInfo { GradientDrawable without stroke }`() { + // Given + val gradientDrawable = GradientDrawable() + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(gradientDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(gradientDrawable, mockInternalLogger) + + // Then + assertThat(result.color).isEqualTo(fakeFillColor) + assertThat(result.borderColor).isNull() + assertThat(result.borderWidth).isEqualTo(0f) + } + + // endregion + + // region GradientDrawable corner radius extraction + + @Test + fun `M extract corner radius W extractStyleInfo { GradientDrawable with corner radius }`( + @FloatForgery(min = 1f, max = 50f) fakeRadius: Float + ) { + // Given + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = fakeRadius + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(gradientDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(gradientDrawable, mockInternalLogger) + + // Then + assertThat(result.cornerRadius).isEqualTo(fakeRadius) + } + + @Test + fun `M return zero corner radius W extractStyleInfo { GradientDrawable without corner radius }`() { + // Given + val gradientDrawable = GradientDrawable() + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(gradientDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(gradientDrawable, mockInternalLogger) + + // Then + assertThat(result.cornerRadius).isEqualTo(0f) + } + + // endregion + + // region Drawable unwrapping + + @Test + fun `M extract style from inner GradientDrawable W extractStyleInfo { StateListDrawable }`( + @FloatForgery(min = 1f, max = 50f) fakeRadius: Float + ) { + // Given + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = fakeRadius + val stateListDrawable = mock() + whenever(stateListDrawable.current).thenReturn(gradientDrawable) + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(stateListDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(stateListDrawable, mockInternalLogger) + + // Then + assertThat(result.cornerRadius).isEqualTo(fakeRadius) + } + + @Test + fun `M extract style from inner GradientDrawable W extractStyleInfo { InsetDrawable }`( + @FloatForgery(min = 1f, max = 50f) fakeRadius: Float + ) { + // Given + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = fakeRadius + val insetDrawable = mock() + whenever(insetDrawable.drawable).thenReturn(gradientDrawable) + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(insetDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(insetDrawable, mockInternalLogger) + + // Then + assertThat(result.cornerRadius).isEqualTo(fakeRadius) + } + + // endregion + + // region Non-GradientDrawable + + @Test + fun `M return no border or radius W extractStyleInfo { ColorDrawable }`() { + // Given + val colorDrawable = ColorDrawable(fakeFillColor) + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(colorDrawable), any())) + .thenReturn(fakeFillColor) + + // When + val result = testedExtractor.extractStyleInfo(colorDrawable, mockInternalLogger) + + // Then + assertThat(result.color).isEqualTo(fakeFillColor) + assertThat(result.cornerRadius).isEqualTo(0f) + assertThat(result.borderColor).isNull() + assertThat(result.borderWidth).isEqualTo(0f) + } + + @Test + fun `M return no border or radius W extractStyleInfo { unknown drawable }`() { + // Given + val unknownDrawable = mock() + whenever(mockDrawableToColorMapper.mapDrawableToColor(eq(unknownDrawable), any())) + .thenReturn(null) + + // When + val result = testedExtractor.extractStyleInfo(unknownDrawable, mockInternalLogger) + + // Then + assertThat(result.color).isNull() + assertThat(result.cornerRadius).isEqualTo(0f) + assertThat(result.borderColor).isNull() + assertThat(result.borderWidth).isEqualTo(0f) + } + + // endregion +}