Merge "Import translations. DO NOT MERGE ANYWHERE" into main
diff --git a/res/layout/bottom_sheet_shortcut.xml b/res/layout/bottom_sheet_shortcut.xml
index a55c2cc..e285604 100644
--- a/res/layout/bottom_sheet_shortcut.xml
+++ b/res/layout/bottom_sheet_shortcut.xml
@@ -14,11 +14,36 @@
   ~ limitations under the License.
   -->
 
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
-    android:layout_height="wrap_content">
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/quick_affordance_horizontal_list"
+    android:layout_height="wrap_content"
+    android:paddingHorizontal="16dp"
+    android:paddingBottom="16dp"
+    android:orientation="vertical">
+
+    <FrameLayout
         android:layout_width="match_parent"
-        android:layout_height="wrap_content" />
-</FrameLayout>
\ No newline at end of file
+        android:layout_height="wrap_content"
+        android:background="@drawable/picker_fragment_background">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/quick_affordance_horizontal_list"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:clipChildren="false"
+            android:clipToPadding="false"/>
+    </FrameLayout>
+
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:paddingVertical="8dp">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@id/slot_tabs"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center_horizontal"
+            android:clipToPadding="false" />
+    </FrameLayout>
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/quick_affordance_list_item.xml b/res/layout/quick_affordance_list_item.xml
new file mode 100644
index 0000000..c6b3fd4
--- /dev/null
+++ b/res/layout/quick_affordance_list_item.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ 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.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="64dp"
+    android:layout_height="wrap_content"
+    android:divider="@drawable/vertical_divider_8dp"
+    android:clipChildren="false"
+    android:showDividers="middle">
+
+    <FrameLayout
+        android:layout_width="64dp"
+        android:layout_height="64dp"
+        android:background="@drawable/option_item_background"
+        android:clipChildren="false">
+
+        <ImageView
+            android:id="@id/selection_border"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="@drawable/option_item_border"
+            android:alpha="0"
+            android:importantForAccessibility="no" />
+
+        <ImageView
+            android:id="@id/background"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="@drawable/option_item_background"
+            android:importantForAccessibility="no" />
+
+        <ImageView
+            android:id="@id/foreground"
+            android:layout_width="@dimen/keyguard_quick_affordance_icon_size"
+            android:layout_height="@dimen/keyguard_quick_affordance_icon_size"
+            android:layout_gravity="center"
+            android:tint="@color/system_on_surface" />
+    </FrameLayout>
+
+    <TextView
+        android:id="@id/text"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:gravity="center_horizontal"
+        android:textColor="@color/system_on_surface"
+        android:lines="2"
+        android:hyphenationFrequency="normal"
+        android:ellipsize="end" />
+</LinearLayout>
\ No newline at end of file
diff --git a/src/com/android/customization/picker/common/ui/view/KeyguardQuickAffordanceItemSpacing.kt b/src/com/android/customization/picker/common/ui/view/KeyguardQuickAffordanceItemSpacing.kt
new file mode 100644
index 0000000..c056d33
--- /dev/null
+++ b/src/com/android/customization/picker/common/ui/view/KeyguardQuickAffordanceItemSpacing.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2023 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.customization.picker.common.ui.view
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/** Item spacing used by the RecyclerView. */
+class KeyguardQuickAffordanceItemSpacing() : RecyclerView.ItemDecoration() {
+    override fun getItemOffsets(
+        outRect: Rect,
+        view: View,
+        parent: RecyclerView,
+        state: RecyclerView.State
+    ) {
+        val itemIndex = parent.getChildAdapterPosition(view)
+        val columnIndex = itemIndex / 2
+        val isRtl = parent.layoutManager?.layoutDirection == View.LAYOUT_DIRECTION_RTL
+        val density = parent.context.resources.displayMetrics.density
+
+        val itemCount = parent.adapter?.itemCount ?: 0
+        val columnCount = (itemCount + 1) / 2
+        when {
+            columnCount == 1 -> {
+                outRect.left = EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+                outRect.right = EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+            }
+            columnIndex > 0 && columnIndex < columnCount - 1 -> {
+                outRect.left = COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+                outRect.right = COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+            }
+            columnIndex == 0 -> {
+                outRect.left =
+                    if (!isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+                    else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+                outRect.right =
+                    if (isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+                    else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+            }
+            columnIndex == columnCount - 1 -> {
+                outRect.right =
+                    if (!isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+                    else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+                outRect.left =
+                    if (isRtl) EDGE_ITEM_HORIZONTAL_SPACING_DP.toPx(density)
+                    else COMMON_HORIZONTAL_SPACING_DP.toPx(density)
+            }
+        }
+
+        if (itemIndex % 2 == 0) {
+            outRect.top = FIRST_ROW_TOP_SPACING_DP.toPx(density)
+            outRect.bottom = FIRST_ROW_BOTTOM_SPACING_DP.toPx(density)
+        } else {
+            outRect.bottom = SECOND_ROW_BOTTOM_SPACING_DP.toPx(density)
+        }
+    }
+
+    private fun Int.toPx(density: Float): Int {
+        return (this * density).toInt()
+    }
+
+    companion object {
+        const val EDGE_ITEM_HORIZONTAL_SPACING_DP = 20
+        const val COMMON_HORIZONTAL_SPACING_DP = 9
+        const val FIRST_ROW_TOP_SPACING_DP = 20
+        const val FIRST_ROW_BOTTOM_SPACING_DP = 8
+        const val SECOND_ROW_BOTTOM_SPACING_DP = 24
+    }
+}
diff --git a/src/com/android/wallpaper/customization/ui/binder/ShortcutBottomSheetBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ShortcutBottomSheetBinder.kt
new file mode 100644
index 0000000..de95fc3
--- /dev/null
+++ b/src/com/android/wallpaper/customization/ui/binder/ShortcutBottomSheetBinder.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.wallpaper.customization.ui.binder
+
+import android.app.Dialog
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.view.accessibility.AccessibilityEvent
+import android.widget.ImageView
+import androidx.core.view.AccessibilityDelegateCompat
+import androidx.core.view.ViewCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.common.ui.view.ItemSpacing
+import com.android.customization.picker.common.ui.view.KeyguardQuickAffordanceItemSpacing
+import com.android.customization.picker.quickaffordance.ui.adapter.SlotTabAdapter
+import com.android.themepicker.R
+import com.android.wallpaper.customization.ui.viewmodel.KeyguardQuickAffordancePickerViewModel2
+import com.android.wallpaper.picker.common.dialog.ui.viewbinder.DialogViewBinder
+import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
+import com.android.wallpaper.picker.common.icon.ui.viewbinder.IconViewBinder
+import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
+import com.android.wallpaper.picker.option.ui.adapter.OptionItemAdapter
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.collectIndexed
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalCoroutinesApi::class)
+object ShortcutBottomSheetBinder {
+
+    fun bind(
+        view: View,
+        viewModel: KeyguardQuickAffordancePickerViewModel2,
+        lifecycleOwner: LifecycleOwner,
+    ) {
+        val quickAffordanceAdapter =
+            OptionItemAdapter(
+                layoutResourceId = R.layout.quick_affordance_list_item,
+                lifecycleOwner = lifecycleOwner,
+                bindIcon = { foregroundView: View, gridIcon: Icon ->
+                    val imageView = foregroundView as? ImageView
+                    imageView?.let { IconViewBinder.bind(imageView, gridIcon) }
+                },
+            )
+        val quickAffordanceList =
+            view.requireViewById<RecyclerView>(R.id.quick_affordance_horizontal_list).apply {
+                adapter = quickAffordanceAdapter
+                layoutManager =
+                    GridLayoutManager(
+                        view.context.applicationContext,
+                        2,
+                        GridLayoutManager.HORIZONTAL,
+                        false
+                    )
+                addItemDecoration(KeyguardQuickAffordanceItemSpacing())
+            }
+        val slotTabAdapter = SlotTabAdapter()
+        val slotTabView: RecyclerView =
+            view.requireViewById<RecyclerView>(R.id.slot_tabs).apply {
+                adapter = slotTabAdapter
+                layoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+                addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP))
+            }
+        // Setting a custom accessibility delegate so that the default content descriptions
+        // for items in a list aren't announced (for left & right shortcuts). We populate
+        // the content description for these shortcuts later on with the right (expected)
+        // values.
+        val slotTabViewDelegate: AccessibilityDelegateCompat =
+            object : AccessibilityDelegateCompat() {
+                override fun onRequestSendAccessibilityEvent(
+                    host: ViewGroup,
+                    child: View,
+                    event: AccessibilityEvent
+                ): Boolean {
+                    if (event.eventType != AccessibilityEvent.TYPE_VIEW_FOCUSED) {
+                        child.contentDescription = null
+                    }
+                    return super.onRequestSendAccessibilityEvent(host, child, event)
+                }
+            }
+        ViewCompat.setAccessibilityDelegate(slotTabView, slotTabViewDelegate)
+
+        var dialog: Dialog? = null
+
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch {
+                    viewModel.slots
+                        .map { slotById -> slotById.values }
+                        .collect { slots -> slotTabAdapter.setItems(slots.toList()) }
+                }
+
+                launch {
+                    viewModel.quickAffordances.collect { affordances ->
+                        quickAffordanceAdapter.setItems(affordances)
+                    }
+                }
+
+                launch {
+                    viewModel.quickAffordances
+                        .flatMapLatest { affordances ->
+                            combine(affordances.map { affordance -> affordance.isSelected }) {
+                                selectedFlags ->
+                                selectedFlags.indexOfFirst { it }
+                            }
+                        }
+                        .collectIndexed { index, selectedPosition ->
+                            // Scroll the view to show the first selected affordance.
+                            if (selectedPosition != -1) {
+                                // We use "post" because we need to give the adapter item a pass to
+                                // update the view.
+                                quickAffordanceList.post {
+                                    if (index == 0) {
+                                        // don't animate on initial collection
+                                        quickAffordanceList.scrollToPosition(selectedPosition)
+                                    } else {
+                                        quickAffordanceList.smoothScrollToPosition(selectedPosition)
+                                    }
+                                }
+                            }
+                        }
+                }
+
+                launch {
+                    viewModel.dialog.distinctUntilChanged().collect { dialogRequest ->
+                        dialog?.dismiss()
+                        dialog =
+                            if (dialogRequest != null) {
+                                showDialog(
+                                    context = view.context,
+                                    request = dialogRequest,
+                                    onDismissed = viewModel::onDialogDismissed
+                                )
+                            } else {
+                                null
+                            }
+                    }
+                }
+
+                launch {
+                    viewModel.activityStartRequests.collect { intent ->
+                        if (intent != null) {
+                            view.context.startActivity(intent)
+                            viewModel.onActivityStarted()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun showDialog(
+        context: Context,
+        request: DialogViewModel,
+        onDismissed: () -> Unit,
+    ): Dialog {
+        return DialogViewBinder.show(
+            context = context,
+            viewModel = request,
+            onDismissed = onDismissed,
+        )
+    }
+}
diff --git a/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
index 349c7c5..06374fc 100644
--- a/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
+++ b/src/com/android/wallpaper/customization/ui/binder/ThemePickerCustomizationOptionBinder.kt
@@ -29,8 +29,10 @@
 import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
 import javax.inject.Inject
 import javax.inject.Singleton
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.launch
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @Singleton
 class ThemePickerCustomizationOptionsBinder
 @Inject
@@ -77,5 +79,11 @@
                 }
             }
         }
+
+        ShortcutBottomSheetBinder.bind(
+            view,
+            viewModel.keyguardQuickAffordancePickerViewModel2,
+            lifecycleOwner,
+        )
     }
 }
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt b/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
new file mode 100644
index 0000000..a7fafe5
--- /dev/null
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/KeyguardQuickAffordancePickerViewModel2.kt
@@ -0,0 +1,447 @@
+/*
+ * 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.wallpaper.customization.ui.viewmodel
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import androidx.annotation.DrawableRes
+import com.android.customization.module.logging.ThemesUserEventLogger
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.themepicker.R
+import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle
+import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel
+import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
+import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
+import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.android.scopes.ViewModelScoped
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+class KeyguardQuickAffordancePickerViewModel2
+@AssistedInject
+constructor(
+    @ApplicationContext private val applicationContext: Context,
+    private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor,
+    private val logger: ThemesUserEventLogger,
+    @Assisted private val viewModelScope: CoroutineScope,
+) {
+
+    /** A locally-selected slot, if the user ever switched from the original one. */
+    private val _selectedSlotId = MutableStateFlow<String?>(null)
+    /** The ID of the selected slot. */
+    val selectedSlotId: StateFlow<String> =
+        combine(
+                quickAffordanceInteractor.slots,
+                _selectedSlotId,
+            ) { slots, selectedSlotIdOrNull ->
+                if (selectedSlotIdOrNull != null) {
+                    slots.first { slot -> slot.id == selectedSlotIdOrNull }
+                } else {
+                    // If we haven't yet selected a new slot locally, default to the first slot.
+                    slots[0]
+                }
+            }
+            .map { selectedSlot -> selectedSlot.id }
+            .stateIn(
+                scope = viewModelScope,
+                started = SharingStarted.WhileSubscribed(),
+                initialValue = "",
+            )
+
+    /** View-models for each slot, keyed by slot ID. */
+    val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
+        combine(
+            quickAffordanceInteractor.slots,
+            quickAffordanceInteractor.affordances,
+            quickAffordanceInteractor.selections,
+            selectedSlotId,
+        ) { slots, affordances, selections, selectedSlotId ->
+            slots.associate { slot ->
+                val selectedAffordanceIds =
+                    selections
+                        .filter { selection -> selection.slotId == slot.id }
+                        .map { selection -> selection.affordanceId }
+                        .toSet()
+                val selectedAffordances =
+                    affordances.filter { affordance ->
+                        selectedAffordanceIds.contains(affordance.id)
+                    }
+                val isSelected = selectedSlotId == slot.id
+                slot.id to
+                    KeyguardQuickAffordanceSlotViewModel(
+                        name = getSlotName(slot.id),
+                        isSelected = isSelected,
+                        selectedQuickAffordances =
+                            selectedAffordances.map { affordanceModel ->
+                                OptionItemViewModel<Icon>(
+                                    key =
+                                        MutableStateFlow("${slot.id}::${affordanceModel.id}")
+                                            as StateFlow<String>,
+                                    payload =
+                                        Icon.Loaded(
+                                            drawable =
+                                                getAffordanceIcon(affordanceModel.iconResourceId),
+                                            contentDescription =
+                                                Text.Loaded(getSlotContentDescription(slot.id)),
+                                        ),
+                                    text = Text.Loaded(affordanceModel.name),
+                                    isSelected = MutableStateFlow(true) as StateFlow<Boolean>,
+                                    onClicked = flowOf(null),
+                                    onLongClicked = null,
+                                    isEnabled = true,
+                                )
+                            },
+                        maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
+                        onClicked =
+                            if (isSelected) {
+                                null
+                            } else {
+                                { _selectedSlotId.tryEmit(slot.id) }
+                            },
+                    )
+            }
+        }
+
+    /**
+     * The set of IDs of the currently-selected affordances. These change with user selection of new
+     * or different affordances in the currently-selected slot or when slot selection changes.
+     */
+    private val selectedAffordanceIds: Flow<Set<String>> =
+        combine(
+                quickAffordanceInteractor.selections,
+                selectedSlotId,
+            ) { selections, selectedSlotId ->
+                selections
+                    .filter { selection -> selection.slotId == selectedSlotId }
+                    .map { selection -> selection.affordanceId }
+                    .toSet()
+            }
+            .shareIn(
+                scope = viewModelScope,
+                started = SharingStarted.WhileSubscribed(),
+                replay = 1,
+            )
+
+    /** The list of all available quick affordances for the selected slot. */
+    val quickAffordances: Flow<List<OptionItemViewModel<Icon>>> =
+        quickAffordanceInteractor.affordances.map { affordances ->
+            val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }.stateIn(viewModelScope)
+            listOf(
+                none(
+                    slotId = selectedSlotId,
+                    isSelected = isNoneSelected,
+                    onSelected =
+                        combine(
+                            isNoneSelected,
+                            selectedSlotId,
+                        ) { isSelected, selectedSlotId ->
+                            if (!isSelected) {
+                                {
+                                    viewModelScope.launch {
+                                        quickAffordanceInteractor.unselectAllFromSlot(
+                                            selectedSlotId
+                                        )
+                                        logger.logShortcutApplied(
+                                            shortcut = "none",
+                                            shortcutSlotId = selectedSlotId,
+                                        )
+                                    }
+                                }
+                            } else {
+                                null
+                            }
+                        }
+                )
+            ) +
+                affordances.map { affordance ->
+                    val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
+                    val isSelectedFlow: StateFlow<Boolean> =
+                        selectedAffordanceIds
+                            .map { it.contains(affordance.id) }
+                            .stateIn(viewModelScope)
+                    OptionItemViewModel<Icon>(
+                        key =
+                            selectedSlotId
+                                .map { slotId -> "$slotId::${affordance.id}" }
+                                .stateIn(viewModelScope),
+                        payload = Icon.Loaded(drawable = affordanceIcon, contentDescription = null),
+                        text = Text.Loaded(affordance.name),
+                        isSelected = isSelectedFlow,
+                        onClicked =
+                            if (affordance.isEnabled) {
+                                combine(
+                                    isSelectedFlow,
+                                    selectedSlotId,
+                                ) { isSelected, selectedSlotId ->
+                                    if (!isSelected) {
+                                        {
+                                            viewModelScope.launch {
+                                                quickAffordanceInteractor.select(
+                                                    slotId = selectedSlotId,
+                                                    affordanceId = affordance.id,
+                                                )
+                                                logger.logShortcutApplied(
+                                                    shortcut = affordance.id,
+                                                    shortcutSlotId = selectedSlotId,
+                                                )
+                                            }
+                                        }
+                                    } else {
+                                        null
+                                    }
+                                }
+                            } else {
+                                flowOf {
+                                    showEnablementDialog(
+                                        icon = affordanceIcon,
+                                        name = affordance.name,
+                                        explanation = affordance.enablementExplanation,
+                                        actionText = affordance.enablementActionText,
+                                        actionIntent = affordance.enablementActionIntent,
+                                    )
+                                }
+                            },
+                        onLongClicked =
+                            if (affordance.configureIntent != null) {
+                                { requestActivityStart(affordance.configureIntent) }
+                            } else {
+                                null
+                            },
+                        isEnabled = affordance.isEnabled,
+                    )
+                }
+        }
+
+    @SuppressLint("UseCompatLoadingForDrawables")
+    val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> =
+        slots.map { slots ->
+            val icon2 =
+                (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
+                        ?.selectedQuickAffordances
+                        ?.firstOrNull())
+                    ?.payload
+            val icon1 =
+                (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
+                        ?.selectedQuickAffordances
+                        ?.firstOrNull())
+                    ?.payload
+
+            KeyguardQuickAffordanceSummaryViewModel(
+                description = toDescriptionText(applicationContext, slots),
+                icon1 =
+                    icon1
+                        ?: if (icon2 == null) {
+                            Icon.Resource(
+                                res = R.drawable.link_off,
+                                contentDescription = null,
+                            )
+                        } else {
+                            null
+                        },
+                icon2 = icon2,
+            )
+        }
+
+    private val _dialog = MutableStateFlow<DialogViewModel?>(null)
+    /**
+     * The current dialog to show. If `null`, no dialog should be shown.
+     *
+     * When the dialog is dismissed, [onDialogDismissed] must be called.
+     */
+    val dialog: Flow<DialogViewModel?> = _dialog.asStateFlow()
+
+    private val _activityStartRequests = MutableStateFlow<Intent?>(null)
+    /**
+     * Requests to start an activity with the given [Intent].
+     *
+     * Important: once the activity is started, the [Intent] should be consumed by calling
+     * [onActivityStarted].
+     */
+    val activityStartRequests: StateFlow<Intent?> = _activityStartRequests.asStateFlow()
+
+    /** Notifies that the dialog has been dismissed in the UI. */
+    fun onDialogDismissed() {
+        _dialog.value = null
+    }
+
+    /**
+     * Notifies that an activity request from [activityStartRequests] has been fulfilled (e.g. the
+     * activity was started and the view-model can forget needing to start this activity).
+     */
+    fun onActivityStarted() {
+        _activityStartRequests.value = null
+    }
+
+    private fun requestActivityStart(
+        intent: Intent,
+    ) {
+        _activityStartRequests.value = intent
+    }
+
+    private fun showEnablementDialog(
+        icon: Drawable,
+        name: String,
+        explanation: String,
+        actionText: String?,
+        actionIntent: Intent?,
+    ) {
+        _dialog.value =
+            DialogViewModel(
+                icon =
+                    Icon.Loaded(
+                        drawable = icon,
+                        contentDescription = null,
+                    ),
+                headline = Text.Resource(R.string.keyguard_affordance_enablement_dialog_headline),
+                message = Text.Loaded(explanation),
+                buttons =
+                    buildList {
+                        add(
+                            ButtonViewModel(
+                                text =
+                                    Text.Resource(
+                                        if (actionText != null) {
+                                            // This is not the only button on the dialog.
+                                            R.string.cancel
+                                        } else {
+                                            // This is the only button on the dialog.
+                                            R.string
+                                                .keyguard_affordance_enablement_dialog_dismiss_button
+                                        }
+                                    ),
+                                style = ButtonStyle.Secondary,
+                            ),
+                        )
+
+                        if (actionText != null) {
+                            add(
+                                ButtonViewModel(
+                                    text = Text.Loaded(actionText),
+                                    style = ButtonStyle.Primary,
+                                    onClicked = {
+                                        actionIntent?.let { intent -> requestActivityStart(intent) }
+                                    }
+                                ),
+                            )
+                        }
+                    },
+            )
+    }
+
+    /** Returns a view-model for the special "None" option. */
+    @SuppressLint("UseCompatLoadingForDrawables")
+    private suspend fun none(
+        slotId: StateFlow<String>,
+        isSelected: StateFlow<Boolean>,
+        onSelected: Flow<(() -> Unit)?>,
+    ): OptionItemViewModel<Icon> {
+        return OptionItemViewModel<Icon>(
+            key = slotId.map { "$it::none" }.stateIn(viewModelScope),
+            payload = Icon.Resource(res = R.drawable.link_off, contentDescription = null),
+            text = Text.Resource(res = R.string.keyguard_affordance_none),
+            isSelected = isSelected,
+            onClicked = onSelected,
+            onLongClicked = null,
+            isEnabled = true,
+        )
+    }
+
+    private fun getSlotName(slotId: String): String {
+        return applicationContext.getString(
+            when (slotId) {
+                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START ->
+                    R.string.keyguard_slot_name_bottom_start
+                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END ->
+                    R.string.keyguard_slot_name_bottom_end
+                else -> error("No name for slot with ID of \"$slotId\"!")
+            }
+        )
+    }
+
+    private fun getSlotContentDescription(slotId: String): String {
+        return applicationContext.getString(
+            when (slotId) {
+                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START ->
+                    R.string.keyguard_slot_name_bottom_start
+                KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END ->
+                    R.string.keyguard_slot_name_bottom_end
+                else -> error("No accessibility label for slot with ID \"$slotId\"!")
+            }
+        )
+    }
+
+    private suspend fun getAffordanceIcon(@DrawableRes iconResourceId: Int): Drawable {
+        return quickAffordanceInteractor.getAffordanceIcon(iconResourceId)
+    }
+
+    private fun toDescriptionText(
+        context: Context,
+        slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
+    ): Text {
+        val bottomStartAffordanceName =
+            slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
+                ?.selectedQuickAffordances
+                ?.firstOrNull()
+                ?.text
+        val bottomEndAffordanceName =
+            slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
+                ?.selectedQuickAffordances
+                ?.firstOrNull()
+                ?.text
+
+        return when {
+            bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
+                Text.Loaded(
+                    context.getString(
+                        R.string.keyguard_quick_affordance_two_selected_template,
+                        bottomStartAffordanceName.asString(context),
+                        bottomEndAffordanceName.asString(context),
+                    )
+                )
+            }
+            bottomStartAffordanceName != null -> bottomStartAffordanceName
+            bottomEndAffordanceName != null -> bottomEndAffordanceName
+            else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected)
+        }
+    }
+
+    @ViewModelScoped
+    @AssistedFactory
+    interface Factory {
+        fun create(viewModelScope: CoroutineScope): KeyguardQuickAffordancePickerViewModel2
+    }
+}
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
index cc909b5..6628b3d 100644
--- a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
@@ -18,19 +18,30 @@
 
 import com.android.wallpaper.customization.ui.util.ThemePickerCustomizationOptionUtil
 import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
+import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModelFactory
 import com.android.wallpaper.picker.customization.ui.viewmodel.DefaultCustomizationOptionsViewModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
 import dagger.hilt.android.scopes.ViewModelScoped
-import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 
-@ViewModelScoped
 class ThemePickerCustomizationOptionsViewModel
-@Inject
+@AssistedInject
 constructor(
-    private val defaultCustomizationOptionsViewModel: DefaultCustomizationOptionsViewModel
+    defaultCustomizationOptionsViewModelFactory: DefaultCustomizationOptionsViewModel.Factory,
+    keyguardQuickAffordancePickerViewModel2Factory: KeyguardQuickAffordancePickerViewModel2.Factory,
+    @Assisted private val viewModelScope: CoroutineScope,
 ) : CustomizationOptionsViewModel {
 
+    private val defaultCustomizationOptionsViewModel =
+        defaultCustomizationOptionsViewModelFactory.create(viewModelScope)
+
+    val keyguardQuickAffordancePickerViewModel2 =
+        keyguardQuickAffordancePickerViewModel2Factory.create(viewModelScope = viewModelScope)
+
     override val selectedOption = defaultCustomizationOptionsViewModel.selectedOption
 
     override fun deselectOption(): Boolean = defaultCustomizationOptionsViewModel.deselectOption()
@@ -61,4 +72,12 @@
                 null
             }
         }
+
+    @ViewModelScoped
+    @AssistedFactory
+    interface Factory : CustomizationOptionsViewModelFactory {
+        override fun create(
+            viewModelScope: CoroutineScope
+        ): ThemePickerCustomizationOptionsViewModel
+    }
 }
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerActivityRetainedModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerActivityRetainedModule.kt
new file mode 100644
index 0000000..9462c6a
--- /dev/null
+++ b/src_override/com/android/wallpaper/modules/ThemePickerActivityRetainedModule.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.wallpaper.modules
+
+import com.android.wallpaper.picker.preview.data.util.DefaultLiveWallpaperDownloader
+import com.android.wallpaper.picker.preview.data.util.LiveWallpaperDownloader
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+abstract class ThemePickerActivityRetainedModule {
+
+    @Binds
+    @ActivityRetainedScoped
+    abstract fun bindLiveWallpaperDownloader(
+        impl: DefaultLiveWallpaperDownloader
+    ): LiveWallpaperDownloader
+}
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
index 8f144d8..3f41f0b 100644
--- a/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
+++ b/src_override/com/android/wallpaper/modules/ThemePickerAppModule.kt
@@ -32,8 +32,6 @@
 import com.android.wallpaper.module.logging.UserEventLogger
 import com.android.wallpaper.picker.customization.ui.binder.CustomizationOptionsBinder
 import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
-import com.android.wallpaper.picker.preview.data.util.DefaultLiveWallpaperDownloader
-import com.android.wallpaper.picker.preview.data.util.LiveWallpaperDownloader
 import com.android.wallpaper.picker.preview.ui.util.DefaultImageEffectDialogUtil
 import com.android.wallpaper.picker.preview.ui.util.ImageEffectDialogUtil
 import com.android.wallpaper.util.converter.DefaultWallpaperModelFactory
@@ -68,12 +66,6 @@
 
     @Binds
     @Singleton
-    abstract fun bindLiveWallpaperDownloader(
-        impl: DefaultLiveWallpaperDownloader
-    ): LiveWallpaperDownloader
-
-    @Binds
-    @Singleton
     abstract fun bindPartnerProvider(impl: DefaultPartnerProvider): PartnerProvider
 
     @Binds
diff --git a/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt b/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt
index 3a80437..3a2da15 100644
--- a/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt
+++ b/src_override/com/android/wallpaper/modules/ThemePickerViewModelModule.kt
@@ -17,7 +17,7 @@
 package com.android.wallpaper.modules
 
 import com.android.wallpaper.customization.ui.viewmodel.ThemePickerCustomizationOptionsViewModel
-import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
+import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModelFactory
 import dagger.Binds
 import dagger.Module
 import dagger.hilt.InstallIn
@@ -30,7 +30,7 @@
 
     @Binds
     @ViewModelScoped
-    abstract fun bindCustomizationOptionsViewModel(
-        impl: ThemePickerCustomizationOptionsViewModel
-    ): CustomizationOptionsViewModel
+    abstract fun bindCustomizationOptionsViewModelFactory(
+        impl: ThemePickerCustomizationOptionsViewModel.Factory
+    ): CustomizationOptionsViewModelFactory
 }
diff --git a/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt b/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
index ca89c01..249f6af 100644
--- a/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
+++ b/tests/module/src/com/android/wallpaper/ThemePickerTestModule.kt
@@ -40,11 +40,9 @@
 import com.android.wallpaper.picker.customization.ui.binder.DefaultCustomizationOptionsBinder
 import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
 import com.android.wallpaper.picker.di.modules.EffectsModule
-import com.android.wallpaper.picker.preview.data.util.LiveWallpaperDownloader
 import com.android.wallpaper.picker.preview.ui.util.DefaultImageEffectDialogUtil
 import com.android.wallpaper.picker.preview.ui.util.ImageEffectDialogUtil
 import com.android.wallpaper.testing.FakeDefaultRequester
-import com.android.wallpaper.testing.FakeLiveWallpaperDownloader
 import com.android.wallpaper.testing.TestPartnerProvider
 import com.android.wallpaper.util.converter.DefaultWallpaperModelFactory
 import com.android.wallpaper.util.converter.WallpaperModelFactory
@@ -99,12 +97,6 @@
 
     @Binds
     @Singleton
-    abstract fun bindLiveWallpaperDownloader(
-        impl: FakeLiveWallpaperDownloader
-    ): LiveWallpaperDownloader
-
-    @Binds
-    @Singleton
     abstract fun providePartnerProvider(impl: TestPartnerProvider): PartnerProvider
 
     @Binds