Use DoubleShadowIconDrawable to draw shadow behind archived cloud icon

- Refactor Launcher3 ShadowInfo out of DoubleShadowBubbleTextView

Bug: 350758155
Test: locally verified + screenshot test
Flag: com.android.launcher3.use_new_icon_for_archived_apps
Change-Id: I8e5e5936296fafbfbf3caf80a936ca1038c125e3
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index af91b5a..5e1d8a5 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -306,6 +306,7 @@
     <dimen name="blur_size_medium_outline">2dp</dimen>
     <dimen name="blur_size_click_shadow">4dp</dimen>
     <dimen name="click_shadow_high_shift">2dp</dimen>
+    <dimen name="app_title_icon_shadow_inset">1dp</dimen>
 
     <!-- Pending widget -->
     <dimen name="pending_widget_min_padding">8dp</dimen>
diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java
index 2eb5034..1eccbff 100644
--- a/src/com/android/launcher3/BubbleTextView.java
+++ b/src/com/android/launcher3/BubbleTextView.java
@@ -873,7 +873,7 @@
         if (drawable == null) {
             setText(text);
             Log.w(TAG, "setTextWithStartIcon: start icon Drawable not found from resources"
-                    + ", will just set text instead. text=" + text);
+                    + ", will just set text instead.");
             return;
         }
         drawable.setTint(getCurrentTextColor());
diff --git a/src/com/android/launcher3/views/DoubleShadowBubbleTextView.java b/src/com/android/launcher3/views/DoubleShadowBubbleTextView.java
index bc66a33..ef66ffe 100644
--- a/src/com/android/launcher3/views/DoubleShadowBubbleTextView.java
+++ b/src/com/android/launcher3/views/DoubleShadowBubbleTextView.java
@@ -19,11 +19,19 @@
 import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound;
 
 import android.content.Context;
-import android.content.res.TypedArray;
 import android.graphics.Canvas;
 import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.ImageSpan;
 import android.util.AttributeSet;
-import android.widget.TextView;
+import android.util.Log;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
 
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.R;
@@ -45,22 +53,65 @@
 
     public DoubleShadowBubbleTextView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        mShadowInfo = new ShadowInfo(context, attrs, defStyle);
-        setShadowLayer(mShadowInfo.ambientShadowBlur, 0, 0, mShadowInfo.ambientShadowColor);
+        mShadowInfo = ShadowInfo.Companion.fromContext(context, attrs, defStyle);
+        setShadowLayer(
+                mShadowInfo.getAmbientShadowBlur(),
+                0,
+                0,
+                mShadowInfo.getAmbientShadowColor()
+        );
+    }
+
+    @Override
+    public void setTextWithStartIcon(CharSequence text, @DrawableRes int drawableId) {
+        Drawable drawable = getContext().getDrawable(drawableId);
+        if (drawable == null) {
+            setText(text);
+            Log.w(TAG, "setTextWithStartIcon: start icon Drawable not found from resources"
+                    + ", will just set text instead.");
+            return;
+        }
+        drawable.setTint(getCurrentTextColor());
+        int textSize = Math.round(getTextSize());
+        ImageSpan imageSpan;
+        if (!skipDoubleShadow() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            drawable = getDoubleShadowDrawable(drawable, textSize);
+        }
+        drawable.setBounds(0, 0, textSize, textSize);
+        imageSpan = new ImageSpan(drawable, ImageSpan.ALIGN_CENTER);
+        // First space will be replaced with Drawable, second space is for space before text.
+        SpannableString spannable = new SpannableString("  " + text);
+        spannable.setSpan(imageSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+        setText(spannable);
+    }
+
+    @RequiresApi(Build.VERSION_CODES.S)
+    private DoubleShadowIconDrawable getDoubleShadowDrawable(
+            @NonNull Drawable drawable, int textSize
+    ) {
+        // add some padding via inset to avoid shadow clipping
+        int iconInsetSize = getContext().getResources()
+                .getDimensionPixelSize(R.dimen.app_title_icon_shadow_inset);
+        return new DoubleShadowIconDrawable(
+                mShadowInfo,
+                drawable,
+                textSize,
+                iconInsetSize
+        );
     }
 
     @Override
     public void onDraw(Canvas canvas) {
         // If text is transparent or shadow alpha is 0, don't draw any shadow
-        if (mShadowInfo.skipDoubleShadow(this)) {
+        if (skipDoubleShadow()) {
             super.onDraw(canvas);
             return;
         }
         int alpha = Color.alpha(getCurrentTextColor());
 
         // We enhance the shadow by drawing the shadow twice
-        getPaint().setShadowLayer(mShadowInfo.ambientShadowBlur, 0, 0,
-                getTextShadowColor(mShadowInfo.ambientShadowColor, alpha));
+        getPaint().setShadowLayer(mShadowInfo.getAmbientShadowBlur(), 0, 0,
+                getTextShadowColor(mShadowInfo.getAmbientShadowColor(), alpha));
 
         drawWithoutDot(canvas);
         canvas.save();
@@ -69,10 +120,10 @@
                 getScrollY() + getHeight());
 
         getPaint().setShadowLayer(
-                mShadowInfo.keyShadowBlur,
-                mShadowInfo.keyShadowOffsetX,
-                mShadowInfo.keyShadowOffsetY,
-                getTextShadowColor(mShadowInfo.keyShadowColor, alpha));
+                mShadowInfo.getKeyShadowBlur(),
+                mShadowInfo.getKeyShadowOffsetX(),
+                mShadowInfo.getKeyShadowOffsetY(),
+                getTextShadowColor(mShadowInfo.getKeyShadowColor(), alpha));
         drawWithoutDot(canvas);
         canvas.restore();
 
@@ -80,55 +131,30 @@
         drawRunningAppIndicatorIfNecessary(canvas);
     }
 
-    public static class ShadowInfo {
-        public final float ambientShadowBlur;
-        public final int ambientShadowColor;
-
-        public final float keyShadowBlur;
-        public final float keyShadowOffsetX;
-        public final float keyShadowOffsetY;
-        public final int keyShadowColor;
-
-        public ShadowInfo(Context c, AttributeSet attrs, int defStyle) {
-
-            TypedArray a = c.obtainStyledAttributes(
-                    attrs, R.styleable.ShadowInfo, defStyle, 0);
-
-            ambientShadowBlur = a.getDimensionPixelSize(
-                    R.styleable.ShadowInfo_ambientShadowBlur, 0);
-            ambientShadowColor = a.getColor(R.styleable.ShadowInfo_ambientShadowColor, 0);
-
-            keyShadowBlur = a.getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowBlur, 0);
-            keyShadowOffsetX = a.getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowOffsetX, 0);
-            keyShadowOffsetY = a.getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowOffsetY, 0);
-            keyShadowColor = a.getColor(R.styleable.ShadowInfo_keyShadowColor, 0);
-            a.recycle();
-        }
-
-        public boolean skipDoubleShadow(TextView textView) {
-            int textAlpha = Color.alpha(textView.getCurrentTextColor());
-            int keyShadowAlpha = Color.alpha(keyShadowColor);
-            int ambientShadowAlpha = Color.alpha(ambientShadowColor);
-            if (textAlpha == 0 || (keyShadowAlpha == 0 && ambientShadowAlpha == 0)) {
-                textView.getPaint().clearShadowLayer();
-                return true;
-            } else if (ambientShadowAlpha > 0 && keyShadowAlpha == 0) {
-                textView.getPaint().setShadowLayer(ambientShadowBlur, 0, 0,
-                        getTextShadowColor(ambientShadowColor, textAlpha));
-                return true;
-            } else if (keyShadowAlpha > 0 && ambientShadowAlpha == 0) {
-                textView.getPaint().setShadowLayer(
-                        keyShadowBlur,
-                        keyShadowOffsetX,
-                        keyShadowOffsetY,
-                        getTextShadowColor(keyShadowColor, textAlpha));
-                return true;
-            } else {
-                return false;
-            }
+    private boolean skipDoubleShadow() {
+        int textAlpha = Color.alpha(getCurrentTextColor());
+        int keyShadowAlpha = Color.alpha(mShadowInfo.getKeyShadowColor());
+        int ambientShadowAlpha = Color.alpha(mShadowInfo.getAmbientShadowColor());
+        if (textAlpha == 0 || (keyShadowAlpha == 0 && ambientShadowAlpha == 0)) {
+            getPaint().clearShadowLayer();
+            return true;
+        } else if (ambientShadowAlpha > 0 && keyShadowAlpha == 0) {
+            getPaint().setShadowLayer(mShadowInfo.getAmbientShadowBlur(), 0, 0,
+                    getTextShadowColor(mShadowInfo.getAmbientShadowColor(), textAlpha));
+            return true;
+        } else if (keyShadowAlpha > 0 && ambientShadowAlpha == 0) {
+            getPaint().setShadowLayer(
+                    mShadowInfo.getKeyShadowBlur(),
+                    mShadowInfo.getKeyShadowOffsetX(),
+                    mShadowInfo.getKeyShadowOffsetY(),
+                    getTextShadowColor(mShadowInfo.getKeyShadowColor(), textAlpha));
+            return true;
+        } else {
+            return false;
         }
     }
 
+
     // Multiplies the alpha of shadowColor by textAlpha.
     private static int getTextShadowColor(int shadowColor, int textAlpha) {
         return setColorAlphaBound(shadowColor,
diff --git a/src/com/android/launcher3/views/DoubleShadowIconDrawable.kt b/src/com/android/launcher3/views/DoubleShadowIconDrawable.kt
new file mode 100644
index 0000000..7ac7c94
--- /dev/null
+++ b/src/com/android/launcher3/views/DoubleShadowIconDrawable.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.views
+
+import android.content.res.ColorStateList
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorFilter
+import android.graphics.PixelFormat
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffColorFilter
+import android.graphics.RenderEffect
+import android.graphics.RenderNode
+import android.graphics.Shader
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.InsetDrawable
+import android.os.Build.VERSION_CODES
+import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Launcher wrapper for Drawables to provide a double shadow effect. Currently for use with
+ * [DoubleShadowBubbleTextView] to provide a similar shadow to inline icons.
+ */
+@RequiresApi(VERSION_CODES.S)
+class DoubleShadowIconDrawable(
+    private val shadowInfo: ShadowInfo,
+    iconDrawable: Drawable,
+    private val iconSize: Int,
+    iconInsetSize: Int
+) : Drawable() {
+    private val mIconDrawable: InsetDrawable
+    private val mDoubleShadowNode: RenderNode?
+
+    init {
+        mIconDrawable = InsetDrawable(iconDrawable, iconInsetSize)
+        mIconDrawable.setBounds(0, 0, iconSize, iconSize)
+        mDoubleShadowNode = createShadowRenderNode()
+    }
+
+    @VisibleForTesting
+    fun createShadowRenderNode(): RenderNode {
+        val renderNode = RenderNode("DoubleShadowNode")
+        renderNode.setPosition(0, 0, iconSize, iconSize)
+        // Create render effects
+        val ambientShadow =
+            createShadowRenderEffect(
+                shadowInfo.ambientShadowBlur,
+                0f,
+                0f,
+                Color.alpha(shadowInfo.ambientShadowColor).toFloat()
+            )
+        val keyShadow =
+            createShadowRenderEffect(
+                shadowInfo.keyShadowBlur,
+                shadowInfo.keyShadowOffsetX,
+                shadowInfo.keyShadowOffsetY,
+                Color.alpha(shadowInfo.keyShadowColor).toFloat()
+            )
+        val blend = RenderEffect.createBlendModeEffect(ambientShadow, keyShadow, BlendMode.DST_ATOP)
+        renderNode.setRenderEffect(blend)
+        return renderNode
+    }
+
+    @VisibleForTesting
+    fun createShadowRenderEffect(
+        radius: Float,
+        offsetX: Float,
+        offsetY: Float,
+        alpha: Float
+    ): RenderEffect {
+        return RenderEffect.createColorFilterEffect(
+            PorterDuffColorFilter(Color.argb(alpha, 0f, 0f, 0f), PorterDuff.Mode.MULTIPLY),
+            RenderEffect.createOffsetEffect(
+                offsetX,
+                offsetY,
+                RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)
+            )
+        )
+    }
+
+    override fun draw(canvas: Canvas) {
+        if (canvas.isHardwareAccelerated && mDoubleShadowNode != null) {
+            if (!mDoubleShadowNode.hasDisplayList()) {
+                // Record render node if its display list is not recorded or discarded
+                // (which happens when it's no longer drawn by anything).
+                val recordingCanvas = mDoubleShadowNode.beginRecording()
+                mIconDrawable.draw(recordingCanvas)
+                mDoubleShadowNode.endRecording()
+            }
+            canvas.drawRenderNode(mDoubleShadowNode)
+        }
+        mIconDrawable.draw(canvas)
+    }
+
+    override fun getIntrinsicHeight() = iconSize
+
+    override fun getIntrinsicWidth() = iconSize
+
+    override fun getOpacity() = PixelFormat.TRANSPARENT
+
+    override fun setAlpha(alpha: Int) {
+        mIconDrawable.alpha = alpha
+    }
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {
+        mIconDrawable.colorFilter = colorFilter
+    }
+
+    override fun setTint(color: Int) {
+        mIconDrawable.setTint(color)
+    }
+
+    override fun setTintList(tint: ColorStateList?) {
+        mIconDrawable.setTintList(tint)
+    }
+}
diff --git a/src/com/android/launcher3/views/ShadowInfo.kt b/src/com/android/launcher3/views/ShadowInfo.kt
new file mode 100644
index 0000000..4f626ec
--- /dev/null
+++ b/src/com/android/launcher3/views/ShadowInfo.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.views
+
+import android.content.Context
+import android.util.AttributeSet
+import com.android.launcher3.R
+
+/**
+ * Launcher data holder for classes such as [DoubleShadowBubbleTextView] to model shadows for
+ * "double shadow" effect.
+ */
+data class ShadowInfo(
+    val ambientShadowBlur: Float,
+    val ambientShadowColor: Int,
+    val keyShadowBlur: Float,
+    val keyShadowOffsetX: Float,
+    val keyShadowOffsetY: Float,
+    val keyShadowColor: Int
+) {
+
+    companion object {
+        /** Constructs instance of ShadowInfo from Context and given attribute set. */
+        @JvmStatic
+        fun fromContext(context: Context, attrs: AttributeSet?, defStyle: Int): ShadowInfo {
+            val styledAttrs =
+                context.obtainStyledAttributes(attrs, R.styleable.ShadowInfo, defStyle, 0)
+            val shadowInfo =
+                ShadowInfo(
+                    ambientShadowBlur =
+                        styledAttrs
+                            .getDimensionPixelSize(R.styleable.ShadowInfo_ambientShadowBlur, 0)
+                            .toFloat(),
+                    ambientShadowColor =
+                        styledAttrs.getColor(R.styleable.ShadowInfo_ambientShadowColor, 0),
+                    keyShadowBlur =
+                        styledAttrs
+                            .getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowBlur, 0)
+                            .toFloat(),
+                    keyShadowOffsetX =
+                        styledAttrs
+                            .getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowOffsetX, 0)
+                            .toFloat(),
+                    keyShadowOffsetY =
+                        styledAttrs
+                            .getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowOffsetY, 0)
+                            .toFloat(),
+                    keyShadowColor = styledAttrs.getColor(R.styleable.ShadowInfo_keyShadowColor, 0)
+                )
+            styledAttrs.recycle()
+            return shadowInfo
+        }
+    }
+}
diff --git a/tests/src/com/android/launcher3/ui/DoubleShadowIconDrawableTest.kt b/tests/src/com/android/launcher3/ui/DoubleShadowIconDrawableTest.kt
new file mode 100644
index 0000000..1cee71c
--- /dev/null
+++ b/tests/src/com/android/launcher3/ui/DoubleShadowIconDrawableTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.ui
+
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.views.DoubleShadowIconDrawable
+import com.android.launcher3.views.ShadowInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class DoubleShadowIconDrawableTest {
+
+    @Test
+    fun `DoubleShadowIconDrawable is setup correctly from given ShadowInfo`() {
+        // Given
+        val shadowInfo: ShadowInfo = mock()
+        val originalDrawable: Drawable = mock()
+        val iconSize = 2
+        val iconInsetSize = 1
+        // When
+        val drawableUnderTest =
+            DoubleShadowIconDrawable(shadowInfo, originalDrawable, iconSize, iconInsetSize)
+        // Then
+        assertThat(drawableUnderTest.intrinsicHeight).isEqualTo(iconSize)
+        assertThat(drawableUnderTest.intrinsicWidth).isEqualTo(iconSize)
+    }
+
+    @Test
+    fun `createShadowRenderNode creates RenderNode for shadow effects`() {
+        // Given
+        val shadowInfo =
+            ShadowInfo(
+                ambientShadowBlur = 1f,
+                ambientShadowColor = 2,
+                keyShadowBlur = 3f,
+                keyShadowOffsetX = 4f,
+                keyShadowOffsetY = 5f,
+                keyShadowColor = 6
+            )
+        val originalDrawable: Drawable = mock()
+        val iconSize = 2
+        val iconInsetSize = 1
+        // When
+        val shadowDrawableUnderTest =
+            spy(DoubleShadowIconDrawable(shadowInfo, originalDrawable, iconSize, iconInsetSize))
+        shadowDrawableUnderTest.createShadowRenderNode()
+        // Then
+        verify(shadowDrawableUnderTest)
+            .createShadowRenderEffect(
+                shadowInfo.ambientShadowBlur,
+                0f,
+                0f,
+                Color.alpha(shadowInfo.ambientShadowColor).toFloat()
+            )
+        verify(shadowDrawableUnderTest)
+            .createShadowRenderEffect(
+                shadowInfo.keyShadowBlur,
+                shadowInfo.keyShadowOffsetX,
+                shadowInfo.keyShadowOffsetY,
+                Color.alpha(shadowInfo.keyShadowColor).toFloat()
+            )
+    }
+}
diff --git a/tests/src/com/android/launcher3/ui/ShadowInfoTest.kt b/tests/src/com/android/launcher3/ui/ShadowInfoTest.kt
new file mode 100644
index 0000000..ef4dc1a
--- /dev/null
+++ b/tests/src/com/android/launcher3/ui/ShadowInfoTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.ui
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.util.AttributeSet
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.R
+import com.android.launcher3.views.ShadowInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class ShadowInfoTest {
+
+    @Test
+    fun `ShadowInfo is created correctly from context`() {
+        // Given
+        val mockContext: Context = mock()
+        val mockAttrs: AttributeSet = mock()
+        val styledAttrs: TypedArray = mock()
+        val expectedShadowInfo =
+            ShadowInfo(
+                ambientShadowBlur = 1f,
+                ambientShadowColor = 2,
+                keyShadowBlur = 3f,
+                keyShadowOffsetX = 4f,
+                keyShadowOffsetY = 5f,
+                keyShadowColor = 6
+            )
+        doReturn(styledAttrs)
+            .whenever(mockContext)
+            .obtainStyledAttributes(mockAttrs, R.styleable.ShadowInfo, 0, 0)
+        doReturn(1)
+            .whenever(styledAttrs)
+            .getDimensionPixelSize(R.styleable.ShadowInfo_ambientShadowBlur, 0)
+        doReturn(2).whenever(styledAttrs).getColor(R.styleable.ShadowInfo_ambientShadowColor, 0)
+        doReturn(3)
+            .whenever(styledAttrs)
+            .getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowBlur, 0)
+        doReturn(4)
+            .whenever(styledAttrs)
+            .getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowOffsetX, 0)
+        doReturn(5)
+            .whenever(styledAttrs)
+            .getDimensionPixelSize(R.styleable.ShadowInfo_keyShadowOffsetY, 0)
+        doReturn(6).whenever(styledAttrs).getColor(R.styleable.ShadowInfo_keyShadowColor, 0)
+        // When
+        val actualShadowInfo = ShadowInfo.fromContext(mockContext, mockAttrs, 0)
+        // Then
+        assertThat(actualShadowInfo.ambientShadowBlur).isEqualTo(1)
+        assertThat(actualShadowInfo.ambientShadowColor).isEqualTo(2)
+        assertThat(actualShadowInfo.keyShadowBlur).isEqualTo(3)
+        assertThat(actualShadowInfo.keyShadowOffsetX).isEqualTo(4)
+        assertThat(actualShadowInfo.keyShadowOffsetY).isEqualTo(5)
+        assertThat(actualShadowInfo.keyShadowColor).isEqualTo(6)
+        assertThat(actualShadowInfo).isEqualTo(expectedShadowInfo)
+    }
+}