Merge "Add pin shortcut to Dock items" into main
diff --git a/docklib-util/Android.bp b/docklib-util/Android.bp
index 0aa1173..d7e2f5a 100644
--- a/docklib-util/Android.bp
+++ b/docklib-util/Android.bp
@@ -30,6 +30,7 @@
     static_libs: [
         "androidx.lifecycle_lifecycle-extensions",
         "com.google.android.material_material",
+        "car-ui-lib",
     ],
 
     manifest: "AndroidManifest.xml",
diff --git a/docklib-util/res/drawable/ic_dock_unpin.xml b/docklib-util/res/drawable/ic_dock_unpin.xml
new file mode 100644
index 0000000..ea3a291
--- /dev/null
+++ b/docklib-util/res/drawable/ic_dock_unpin.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
+</vector>
diff --git a/docklib-util/res/values/strings.xml b/docklib-util/res/values/strings.xml
index 38b2ca5..2c24df7 100644
--- a/docklib-util/res/values/strings.xml
+++ b/docklib-util/res/values/strings.xml
@@ -18,4 +18,5 @@
 <resources>
     <!-- todo(b/314817575): update the string to final value -->
     <string name="dock_pin_shortcut_label">Pin to the dock</string>
+    <string name="dock_unpin_shortcut_label">Unpin from the dock</string>
 </resources>
diff --git a/docklib-util/src/com/android/car/dockutil/events/DockEventSenderHelper.java b/docklib-util/src/com/android/car/dockutil/events/DockEventSenderHelper.java
index 3353617..4f8b52f 100644
--- a/docklib-util/src/com/android/car/dockutil/events/DockEventSenderHelper.java
+++ b/docklib-util/src/com/android/car/dockutil/events/DockEventSenderHelper.java
@@ -64,13 +64,20 @@
     }
 
     /**
-     * Used to send unpin event to the dock. Generally used when an app should be unpinned from the
-     * dock.
+     * @see #sendUnpinEvent(ComponentName)
      */
     public void sendUnpinEvent(@NonNull ActivityManager.RunningTaskInfo taskInfo) {
         sendEventBroadcast(DockEvent.UNPIN, taskInfo);
     }
 
+    /**
+     * Used to send unpin event to the dock. Generally used when an app should be unpinned from the
+     * dock.
+     */
+    public void sendUnpinEvent(@NonNull ComponentName componentName) {
+        sendEventBroadcast(DockEvent.UNPIN, componentName);
+    }
+
     @VisibleForTesting
     void sendEventBroadcast(@NonNull DockEvent event,
                             @NonNull ActivityManager.RunningTaskInfo taskInfo) {
diff --git a/docklib-util/src/com/android/car/dockutil/shortcuts/PinShortcutItem.kt b/docklib-util/src/com/android/car/dockutil/shortcuts/PinShortcutItem.kt
new file mode 100644
index 0000000..6aba157
--- /dev/null
+++ b/docklib-util/src/com/android/car/dockutil/shortcuts/PinShortcutItem.kt
@@ -0,0 +1,47 @@
+package com.android.car.dockutil.shortcuts
+
+import android.content.res.Resources
+import com.android.car.dockutil.R
+import com.android.car.ui.shortcutspopup.CarUiShortcutsPopup
+
+/**
+ * {@link CarUiShortcutsPopup.ShortcutItem} to pin or unpin an app to the dock.
+ * @param isItemPinned if the app is pinned to the dock
+ * @param pinItemClickDelegate {@link Runnable} to pin the app to the dock
+ * @param unpinItemClickDelegate {@link Runnable} to unpin the app to the dock
+ */
+class PinShortcutItem(
+    private val resources: Resources,
+    private val isItemPinned: Boolean,
+    private val pinItemClickDelegate: Runnable,
+    private val unpinItemClickDelegate: Runnable
+) : CarUiShortcutsPopup.ShortcutItem {
+
+    override fun data(): CarUiShortcutsPopup.ItemData {
+        return if (isItemPinned) {
+            CarUiShortcutsPopup.ItemData(
+                R.drawable.ic_dock_unpin, // leftDrawable
+                resources.getString(R.string.dock_unpin_shortcut_label) // shortcutName
+            )
+        } else {
+            CarUiShortcutsPopup.ItemData(
+                R.drawable.ic_dock_pin, // leftDrawable
+                resources.getString(R.string.dock_pin_shortcut_label) // shortcutName
+            )
+        }
+    }
+
+    override fun onClick(): Boolean {
+        // todo(b/314835197): fix pinning/opening media apps
+        if (isItemPinned) {
+            unpinItemClickDelegate.run()
+        } else {
+            pinItemClickDelegate.run()
+        }
+        return true
+    }
+
+    override fun isEnabled(): Boolean {
+        return true
+    }
+}
diff --git a/docklib-util/tests/Android.bp b/docklib-util/tests/Android.bp
index 9133985..6883e97 100644
--- a/docklib-util/tests/Android.bp
+++ b/docklib-util/tests/Android.bp
@@ -21,7 +21,10 @@
 android_test {
     name: "CarDockUtilLibTests",
 
-    srcs: ["src/**/*.java"],
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
 
     libs: [
         "android.test.base",
@@ -35,6 +38,7 @@
         "androidx.test.runner",
         "androidx.test.ext.junit",
         "mockito-target-extended",
+        "mockito-kotlin2",
         "truth",
         "CarDockUtilLib",
     ],
diff --git a/docklib-util/tests/src/com/android/car/dockutil/shortcuts/PinShortcutItemTest.kt b/docklib-util/tests/src/com/android/car/dockutil/shortcuts/PinShortcutItemTest.kt
new file mode 100644
index 0000000..d3498ae
--- /dev/null
+++ b/docklib-util/tests/src/com/android/car/dockutil/shortcuts/PinShortcutItemTest.kt
@@ -0,0 +1,43 @@
+package com.android.car.dockutil.shortcuts
+
+import android.content.res.Resources
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+
+@RunWith(AndroidJUnit4::class)
+class PinShortcutItemTest {
+    private val resourcesMock = mock<Resources> {}
+    private val pinItemClickDelegateMock = mock<Runnable> {}
+    private val unpinItemClickDelegateMock = mock<Runnable> {}
+
+    @Test
+    fun onClick_pinnedItem_runUnpinDelegate() {
+        val pinShortcutItem = PinShortcutItem(
+            resourcesMock,
+            isItemPinned = true,
+            pinItemClickDelegateMock,
+            unpinItemClickDelegateMock
+        )
+
+        pinShortcutItem.onClick()
+
+        verify(unpinItemClickDelegateMock).run()
+    }
+
+    @Test
+    fun onClick_unpinnedItem_runPinDelegate() {
+        val pinShortcutItem = PinShortcutItem(
+            resourcesMock,
+            isItemPinned = false,
+            pinItemClickDelegateMock,
+            unpinItemClickDelegateMock
+        )
+
+        pinShortcutItem.onClick()
+
+        verify(pinItemClickDelegateMock).run()
+    }
+}
diff --git a/docklib/res/values/dimens.xml b/docklib/res/values/dimens.xml
index 3005a47..d38260f 100644
--- a/docklib/res/values/dimens.xml
+++ b/docklib/res/values/dimens.xml
@@ -21,7 +21,8 @@
     <dimen name="dock_item_size">72dp</dimen>
     <dimen name="dock_item_spacing">2dp</dimen>
 
-    <dimen name="icon_stroke_width">6dp</dimen>
+    <dimen name="static_icon_stroke_width">0dp</dimen>
+    <dimen name="dynamic_icon_stroke_width">6dp</dimen>
     <dimen name="icon_stroke_width_excited">30dp</dimen>
 
 </resources>
diff --git a/docklib/src/com/android/car/docklib/data/DockAppItem.kt b/docklib/src/com/android/car/docklib/data/DockAppItem.kt
index 9009728..3e67950 100644
--- a/docklib/src/com/android/car/docklib/data/DockAppItem.kt
+++ b/docklib/src/com/android/car/docklib/data/DockAppItem.kt
@@ -27,6 +27,7 @@
     val icon: Drawable,
     val isDistractionOptimized: Boolean,
 ) {
+    // todo(b/315210225): handle getting icon lazily
     enum class Type(val value: String) {
         DYNAMIC("DYNAMIC"),
         STATIC("STATIC");
diff --git a/docklib/src/com/android/car/docklib/view/DockAdapter.kt b/docklib/src/com/android/car/docklib/view/DockAdapter.kt
index 294e3d4..5a5d746 100644
--- a/docklib/src/com/android/car/docklib/view/DockAdapter.kt
+++ b/docklib/src/com/android/car/docklib/view/DockAdapter.kt
@@ -15,19 +15,47 @@
 import com.android.car.docklib.data.DockAppItem
 import java.util.function.Consumer
 
+/**
+ * [RecyclerView.Adapter] used to bind Dock items
+ * @param numItems maximum num of items present in the dock
+ * @param items initial list of items in the Dock
+ */
 class DockAdapter(
     private val numItems: Int,
     private val intentDelegate: Consumer<Intent>,
     private val userContext: Context,
+    private val items: Array<DockAppItem?> = arrayOfNulls(numItems)
 ) : RecyclerView.Adapter<DockItemViewHolder>() {
     companion object {
         private val DEBUG = Build.isDebuggable()
         private const val TAG = "DockAdapter"
     }
 
-    private val items: Array<DockAppItem?> = arrayOfNulls(numItems)
     private var carPackageManager: CarPackageManager? = null
 
+    enum class PayloadType {
+        CHANGE_SAME_ITEM_TYPE,
+    }
+
+    override fun onBindViewHolder(
+        viewHolder: DockItemViewHolder,
+        position: Int,
+        payloads: MutableList<Any>
+    ) {
+        if (payloads.isEmpty() ||
+            payloads.getOrNull(0) == null ||
+            payloads[0] !is PayloadType
+        ) {
+            return super.onBindViewHolder(viewHolder, position, payloads)
+        }
+        when (payloads[0]) {
+            PayloadType.CHANGE_SAME_ITEM_TYPE ->
+                items[position]?.let {
+                    viewHolder.itemTypeChanged(it)
+                }
+        }
+    }
+
     override fun onCreateViewHolder(parent: ViewGroup, p1: Int): DockItemViewHolder {
         val view = LayoutInflater.from(parent.context).inflate(
             R.layout.dock_app_item_view, // resource
@@ -53,9 +81,10 @@
     }
 
     /**
-     * Pin app to the given position
+     * Pin new app to the given position
      */
     fun pinItemAt(position: Int, componentName: ComponentName) {
+        // todo(b/315222570): move to controller
         if (!isValidPosition(position)) {
             return
         }
@@ -82,6 +111,33 @@
     }
 
     /**
+     * Pin the DockItem at the given position. If the app is already pinned this call is a no-op.
+     */
+    fun pinItemAt(position: Int) {
+        // todo(b/315222570): move to controller
+        changeItemType(position, DockAppItem.Type.STATIC)
+    }
+
+    /**
+     * Unpin the DockItem at the given position. If the app is already unpinned this call is a
+     * no-op.
+     */
+    fun unpinItemAt(position: Int) {
+        // todo(b/315222570): move to controller
+        changeItemType(position, DockAppItem.Type.DYNAMIC)
+    }
+
+    private fun changeItemType(position: Int, newItemType: DockAppItem.Type) {
+        if (!isValidPosition(position) || items[position]?.type == newItemType) {
+            return
+        }
+        items[position]?.let {
+            items[position] = it.copy(type = newItemType)
+            notifyItemChanged(position, PayloadType.CHANGE_SAME_ITEM_TYPE)
+        }
+    }
+
+    /**
      * Setter for CarPackageManager
      */
     fun setCarPackageManager(carPackageManager: CarPackageManager) {
diff --git a/docklib/src/com/android/car/docklib/view/DockItemLongClickListener.kt b/docklib/src/com/android/car/docklib/view/DockItemLongClickListener.kt
new file mode 100644
index 0000000..2e65b74
--- /dev/null
+++ b/docklib/src/com/android/car/docklib/view/DockItemLongClickListener.kt
@@ -0,0 +1,72 @@
+package com.android.car.docklib.view
+
+import android.content.res.Resources
+import android.view.View
+import androidx.annotation.OpenForTesting
+import androidx.annotation.VisibleForTesting
+import com.android.car.docklib.data.DockAppItem
+import com.android.car.dockutil.shortcuts.PinShortcutItem
+import com.android.car.ui.shortcutspopup.CarUiShortcutsPopup
+
+/**
+ * {@link View.OnLongClickListener} for handling long clicks on dock item.
+ * It is responsible to create and show th popup window
+ *
+ * @param dockAppItem the {@link DockAppItem} to be used on long click.
+ * @param pinItemClickDelegate called when item should be pinned at that position
+ * @param unpinItemClickDelegate called when item should be unpinned at that position
+ */
+@OpenForTesting
+open class DockItemLongClickListener(
+    private var dockAppItem: DockAppItem,
+    private val pinItemClickDelegate: Runnable,
+    private val unpinItemClickDelegate: Runnable
+) : View.OnLongClickListener {
+    override fun onLongClick(view: View?): Boolean {
+        if (view == null) return false
+
+        createCarUiShortcutsPopupBuilder()
+            .addShortcut(
+                createPinShortcutItem(
+                    view.context.resources,
+                    isItemPinned = (dockAppItem.type == DockAppItem.Type.STATIC),
+                    pinItemClickDelegate,
+                    unpinItemClickDelegate
+                )
+            )
+            .build(view.context, view)
+            .show()
+        return true
+    }
+
+    /**
+     * Set the {@link DockAppItem} to be used on long click.
+     */
+    fun setDockAppItem(dockAppItem: DockAppItem) {
+        this.dockAppItem = dockAppItem
+    }
+
+    /**
+     * Need to be overridden in test.
+     */
+    @VisibleForTesting
+    @OpenForTesting
+    open fun createCarUiShortcutsPopupBuilder(): CarUiShortcutsPopup.Builder =
+        CarUiShortcutsPopup.Builder()
+
+    /**
+     * Need to be overridden in test.
+     */
+    @VisibleForTesting
+    fun createPinShortcutItem(
+        resources: Resources,
+        isItemPinned: Boolean,
+        pinItemClickDelegate: Runnable,
+        unpinItemClickDelegate: Runnable
+    ): PinShortcutItem = PinShortcutItem(
+        resources,
+        isItemPinned,
+        pinItemClickDelegate,
+        unpinItemClickDelegate
+    )
+}
diff --git a/docklib/src/com/android/car/docklib/view/DockItemViewHolder.kt b/docklib/src/com/android/car/docklib/view/DockItemViewHolder.kt
index 5be920a..9750f36 100644
--- a/docklib/src/com/android/car/docklib/view/DockItemViewHolder.kt
+++ b/docklib/src/com/android/car/docklib/view/DockItemViewHolder.kt
@@ -19,21 +19,24 @@
     private val intentDelegate: Consumer<Intent>,
 ) : RecyclerView.ViewHolder(itemView) {
 
-    private val iconStrokeWidth: Int
-    private val excitedIconStrokeWidth: Int
-    private val iconPadding: Int
-    private val excitedIconPadding: Int
+    companion object {
+        private const val DEFAULT_STROKE_WIDTH = 0f
+    }
+
+    private val staticIconStrokeWidth: Float
+    private val dynamicIconStrokeWidth: Float
+    private val excitedIconStrokeWidth: Float
     private val iconStrokeColor: Int
     private val excitedIconStrokeColor: Int
     private val appIcon: ShapeableImageView
+    private var dockItemLongClickListener: DockItemLongClickListener? = null
+    private var iconStrokeWidth: Float = DEFAULT_STROKE_WIDTH
 
     init {
-        appIcon = itemView.requireViewById(R.id.dock_app_icon)
-        iconStrokeWidth = itemView.resources.getDimensionPixelSize(R.dimen.icon_stroke_width)
-        iconPadding = iconStrokeWidth / 2
-        excitedIconStrokeWidth =
-            itemView.resources.getDimensionPixelSize(R.dimen.icon_stroke_width_excited)
-        excitedIconPadding = excitedIconStrokeWidth / 2
+        staticIconStrokeWidth = itemView.resources.getDimension(R.dimen.static_icon_stroke_width)
+        dynamicIconStrokeWidth = itemView.resources.getDimension(R.dimen.dynamic_icon_stroke_width)
+        excitedIconStrokeWidth = itemView.resources.getDimension(R.dimen.icon_stroke_width_excited)
+        // todo(b/314859977): iconStrokeColor should be decided by the app primary color
         iconStrokeColor = itemView.resources.getColor(
             R.color.icon_default_stroke_color,
             null // theme
@@ -42,12 +45,15 @@
             R.color.icon_excited_stroke_color,
             null // theme
         )
+        appIcon = itemView.requireViewById(R.id.dock_app_icon)
     }
 
     fun bind(dockAppItem: DockAppItem?) {
         reset()
         if (dockAppItem == null) return
 
+        itemTypeChanged(dockAppItem)
+
         appIcon.contentDescription = dockAppItem.name
         appIcon.setImageDrawable(dockAppItem.icon)
         appIcon.setOnClickListener {
@@ -58,6 +64,14 @@
                     .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
             intentDelegate.accept(intent)
         }
+        dockItemLongClickListener = DockItemLongClickListener(
+            dockAppItem,
+            pinItemClickDelegate =
+            { (bindingAdapter as? DockAdapter)?.pinItemAt(bindingAdapterPosition) },
+            unpinItemClickDelegate =
+            { (bindingAdapter as? DockAdapter)?.unpinItemAt(bindingAdapterPosition) }
+        )
+        appIcon.onLongClickListener = dockItemLongClickListener
 
         itemView.setOnDragListener(
             DockDragListener(
@@ -83,8 +97,8 @@
                     override fun getDropLocation(): Point {
                         val iconLocation = appIcon.locationOnScreen
                         return Point(
-                            (iconLocation[0] + iconStrokeWidth),
-                            (iconLocation[1] + iconStrokeWidth)
+                            (iconLocation[0] + iconStrokeWidth.toInt()),
+                            (iconLocation[1] + iconStrokeWidth.toInt())
                         )
                     }
 
@@ -100,6 +114,17 @@
         )
     }
 
+    fun itemTypeChanged(dockAppItem: DockAppItem) {
+        iconStrokeWidth = when (dockAppItem.type) {
+            DockAppItem.Type.STATIC -> staticIconStrokeWidth
+            DockAppItem.Type.DYNAMIC -> dynamicIconStrokeWidth
+        }
+        appIcon.strokeWidth = iconStrokeWidth
+
+        appIcon.invalidate()
+        dockItemLongClickListener?.setDockAppItem(dockAppItem)
+    }
+
     private fun pinNewItem(componentName: ComponentName) {
         (bindingAdapter as? DockAdapter)?.pinItemAt(bindingAdapterPosition, componentName)
     }
@@ -108,20 +133,21 @@
         // todo(b/312737692): add animations
         appIcon.strokeColor = ColorStateList.valueOf(excitedIconStrokeColor)
         appIcon.setColorFilter(Color.argb(0.3f, 0f, 0f, 0f), PorterDuff.Mode.DARKEN)
-        appIcon.strokeWidth = excitedIconStrokeWidth.toFloat()
-        appIcon.setPadding(excitedIconStrokeWidth / 2)
+        appIcon.strokeWidth = excitedIconStrokeWidth
+        appIcon.setPadding(getPaddingFromStrokeWidth(excitedIconStrokeWidth))
         appIcon.invalidate()
     }
 
     private fun resetAppIcon() {
         appIcon.strokeColor = ColorStateList.valueOf(iconStrokeColor)
         appIcon.colorFilter = null
-        appIcon.strokeWidth = iconStrokeWidth.toFloat()
-        appIcon.setPadding(iconStrokeWidth / 2)
+        appIcon.strokeWidth = iconStrokeWidth
+        appIcon.setPadding(getPaddingFromStrokeWidth(iconStrokeWidth))
         appIcon.invalidate()
     }
 
     private fun reset() {
+        iconStrokeWidth = DEFAULT_STROKE_WIDTH
         resetAppIcon()
         appIcon.contentDescription = null
         appIcon.setImageDrawable(null)
@@ -129,5 +155,7 @@
         itemView.setOnDragListener(null)
     }
 
+    private fun getPaddingFromStrokeWidth(strokeWidth: Float): Int = (strokeWidth / 2).toInt()
+
     // TODO: b/301484526 Add animation when app icon is changed
 }
diff --git a/docklib/tests/src/com/android/car/docklib/data/DockAppItemTest.kt b/docklib/tests/src/com/android/car/docklib/data/DockAppItemTest.kt
index 049a516..2da0f3e 100644
--- a/docklib/tests/src/com/android/car/docklib/data/DockAppItemTest.kt
+++ b/docklib/tests/src/com/android/car/docklib/data/DockAppItemTest.kt
@@ -22,8 +22,8 @@
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.mockito.Mockito.mock
-import org.mockito.Mockito.`when`
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
 
 @RunWith(AndroidJUnit4::class)
 class DockAppItemTest {
@@ -61,10 +61,10 @@
 
     @Test
     fun compareAppItems_notEqual_differentIcons() {
-        val icon1 = mock(Drawable::class.java)
-        `when`(icon1.constantState).thenReturn(null)
-        val icon2 = mock(Drawable::class.java)
-        `when`(icon2.constantState).thenReturn(mock(Drawable.ConstantState::class.java))
+        val icon1 = mock<Drawable>()
+        whenever(icon1.constantState).thenReturn(null)
+        val icon2 = mock<Drawable>()
+        whenever(icon2.constantState).thenReturn(mock<Drawable.ConstantState>())
 
         val item1: DockAppItem = TestUtils.createAppItem(icon = icon1)
         val item2: DockAppItem = TestUtils.createAppItem(icon = icon2)
diff --git a/docklib/tests/src/com/android/car/docklib/view/DockAdapterTest.kt b/docklib/tests/src/com/android/car/docklib/view/DockAdapterTest.kt
index 564c30e..d39ed0f 100644
--- a/docklib/tests/src/com/android/car/docklib/view/DockAdapterTest.kt
+++ b/docklib/tests/src/com/android/car/docklib/view/DockAdapterTest.kt
@@ -4,6 +4,7 @@
 import android.content.Intent
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.car.docklib.TestUtils
+import com.android.car.docklib.data.DockAppItem
 import com.google.common.truth.Truth.assertThat
 import java.util.function.Consumer
 import org.junit.Test
@@ -11,12 +12,15 @@
 import org.mockito.Mockito.spy
 import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
+import org.mockito.kotlin.eq
 import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
 
 @RunWith(AndroidJUnit4::class)
 class DockAdapterTest {
     private val contextMock = mock<Context> {}
     private val intentConsumerMock = mock<Consumer<Intent>> {}
+    private val dockItemViewHolderMock = mock<DockItemViewHolder> {}
 
     @Test
     fun setItems_dockSizeEqualToListSize_adapterHasDockSize() {
@@ -63,4 +67,56 @@
         verify(adapter).notifyItemChanged(1)
         verify(adapter, times(0)).notifyItemChanged(2)
     }
+
+    @Test
+    fun onBindViewHolder_emptyPayload_onBindViewHolderWithoutPayloadCalled() {
+        val adapter = spy(DockAdapter(3, intentConsumerMock, contextMock))
+
+        adapter.onBindViewHolder(dockItemViewHolderMock, 1, MutableList(0) {})
+
+        verify(adapter).onBindViewHolder(eq(dockItemViewHolderMock), eq(1))
+    }
+
+    @Test
+    fun onBindViewHolder_nullPayload_onBindViewHolderWithoutPayloadCalled() {
+        val adapter = spy(DockAdapter(3, intentConsumerMock, contextMock))
+
+        adapter.onBindViewHolder(dockItemViewHolderMock, 1, MutableList(1) {})
+
+        verify(adapter).onBindViewHolder(eq(dockItemViewHolderMock), eq(1))
+    }
+
+    @Test
+    fun onBindViewHolder_payloadOfIncorrectType_onBindViewHolderWithoutPayloadCalled() {
+        class DummyPayload
+        val adapter = spy(DockAdapter(3, intentConsumerMock, contextMock))
+
+        adapter.onBindViewHolder(dockItemViewHolderMock, 1, MutableList(1) {
+            DummyPayload()
+        })
+
+        verify(adapter).onBindViewHolder(eq(dockItemViewHolderMock), eq(1))
+    }
+
+    @Test
+    fun onBindViewHolder_payload_CHANGE_SAME_ITEM_TYPE_itemTypeChangedCalled() {
+        val dockAppItem0 = mock<DockAppItem> {}
+        val dockAppItem1 = mock<DockAppItem> {}
+        val dockAppItem2 = mock<DockAppItem> {}
+        val adapter = spy(
+            DockAdapter(
+                numItems = 3,
+                intentConsumerMock,
+                contextMock,
+                items = arrayOf(dockAppItem0, dockAppItem1, dockAppItem2)
+            )
+        )
+
+        adapter.onBindViewHolder(dockItemViewHolderMock, 1, MutableList(1) {
+            DockAdapter.PayloadType.CHANGE_SAME_ITEM_TYPE
+        })
+
+        verify(adapter, never()).onBindViewHolder(eq(dockItemViewHolderMock), eq(1))
+        verify(dockItemViewHolderMock).itemTypeChanged(eq(dockAppItem1))
+    }
 }
diff --git a/docklib/tests/src/com/android/car/docklib/view/DockItemLongClickListenerTest.kt b/docklib/tests/src/com/android/car/docklib/view/DockItemLongClickListenerTest.kt
new file mode 100644
index 0000000..d8ba54b
--- /dev/null
+++ b/docklib/tests/src/com/android/car/docklib/view/DockItemLongClickListenerTest.kt
@@ -0,0 +1,89 @@
+package com.android.car.docklib.view
+
+import android.content.Context
+import android.content.res.Resources
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.car.docklib.TestUtils
+import com.android.car.docklib.data.DockAppItem
+import com.android.car.ui.shortcutspopup.CarUiShortcutsPopup
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+
+@RunWith(AndroidJUnit4::class)
+class DockItemLongClickListenerTest {
+    private val dockAppItemMock = mock<DockAppItem>()
+    private val resourcesMock = mock<Resources>()
+    private val contextMock = mock<Context> { on { resources } doReturn resourcesMock }
+    private val viewMock = mock<View> { on { context } doReturn contextMock }
+    private val runnableMock1 = mock<Runnable>()
+    private val runnableMock2 = mock<Runnable>()
+    private val carUiShortcutsPopupMock = mock<CarUiShortcutsPopup>()
+    private val carUiShortcutsPopupBuilderMock = mock<CarUiShortcutsPopup.Builder>() {
+        on { addShortcut(any<CarUiShortcutsPopup.ShortcutItem>()) } doReturn it
+        on { build(any<Context>(), any<View>()) } doReturn carUiShortcutsPopupMock
+    }
+    private lateinit var dockItemLongClickListener: DockItemLongClickListener
+
+    @Before
+    fun setup() {
+        dockItemLongClickListener = createDockItemLongClickListener()
+    }
+
+    @Test
+    fun onLongClick_shortcutShown() {
+        dockItemLongClickListener.onLongClick(viewMock)
+
+        verify(carUiShortcutsPopupMock).show()
+    }
+
+    @Test
+    fun onLongClick_typeStatic_pinShortcutItem_parameterIsItemPinnedIsTrue() {
+        dockItemLongClickListener =
+            createDockItemLongClickListener(TestUtils.createAppItem(DockAppItem.Type.STATIC))
+
+        dockItemLongClickListener.onLongClick(viewMock)
+
+        verify(dockItemLongClickListener).createPinShortcutItem(
+            any<Resources>(),
+            eq(true),
+            any<Runnable>(),
+            any<Runnable>()
+        )
+    }
+
+    @Test
+    fun onLongClick_typeDynamic_pinShortcutItem_parameterIsItemPinnedIsFalse() {
+        dockItemLongClickListener =
+            createDockItemLongClickListener(TestUtils.createAppItem(DockAppItem.Type.DYNAMIC))
+
+        dockItemLongClickListener.onLongClick(viewMock)
+
+        verify(dockItemLongClickListener).createPinShortcutItem(
+            any<Resources>(),
+            eq(false),
+            any<Runnable>(),
+            any<Runnable>()
+        )
+    }
+
+    private fun createDockItemLongClickListener(
+        dockAppItem: DockAppItem = dockAppItemMock
+    ): DockItemLongClickListener {
+        return spy(object : DockItemLongClickListener(
+            dockAppItem,
+            runnableMock1,
+            runnableMock2
+        ) {
+            override fun createCarUiShortcutsPopupBuilder(): CarUiShortcutsPopup.Builder =
+                carUiShortcutsPopupBuilderMock
+        })
+    }
+}
diff --git a/libs/appgrid/lib/src/com/android/car/carlauncher/AppLauncherUtils.java b/libs/appgrid/lib/src/com/android/car/carlauncher/AppLauncherUtils.java
index 47ba16e..dcf7c93 100644
--- a/libs/appgrid/lib/src/com/android/car/carlauncher/AppLauncherUtils.java
+++ b/libs/appgrid/lib/src/com/android/car/carlauncher/AppLauncherUtils.java
@@ -58,6 +58,7 @@
 import androidx.annotation.Nullable;
 
 import com.android.car.dockutil.events.DockEventSenderHelper;
+import com.android.car.dockutil.shortcuts.PinShortcutItem;
 import com.android.car.media.common.source.MediaSourceUtil;
 import com.android.car.ui.shortcutspopup.CarUiShortcutsPopup;
 
@@ -496,27 +497,11 @@
 
     private static CarUiShortcutsPopup.ShortcutItem buildPinToDockShortcut(
             ComponentName componentName, Context context) {
-        // todo(b/314835197): fix pinning/opening media apps
-        return new CarUiShortcutsPopup.ShortcutItem() {
-            @Override
-            public CarUiShortcutsPopup.ItemData data() {
-                return new CarUiShortcutsPopup.ItemData(/* leftDrawable= */ R.drawable.ic_dock_pin,
-                        /* shortcutName= */
-                        context.getResources().getString(R.string.dock_pin_shortcut_label));
-            }
-
-            @Override
-            public boolean onClick() {
-                DockEventSenderHelper mHelper = new DockEventSenderHelper(context);
-                mHelper.sendPinEvent(componentName);
-                return true;
-            }
-
-            @Override
-            public boolean isEnabled() {
-                return true;
-            }
-        };
+        DockEventSenderHelper mHelper = new DockEventSenderHelper(context);
+        return new PinShortcutItem(context.getResources(), /* isItemPinned= */ false,
+                /* pinItemClickDelegate= */ () -> mHelper.sendPinEvent(componentName),
+                /* unpinItemClickDelegate= */ () -> mHelper.sendUnpinEvent(componentName)
+        );
     }
 
     /**