Merge "Import translations. DO NOT MERGE ANYWHERE"
diff --git a/Android.bp b/Android.bp
index 7bbbb10..64fc327 100644
--- a/Android.bp
+++ b/Android.bp
@@ -55,11 +55,15 @@
     static_libs: [
         "guava",
         "monet",
-	"renderscript_toolkit",
+	    "renderscript_toolkit",
         "wallpaper-common-deps",
         "SettingsLibSettingsTheme",
         "SystemUI-statsd",
         "styleprotoslite",
+        "androidx.lifecycle_lifecycle-runtime-ktx",
+        "androidx.lifecycle_lifecycle-viewmodel-ktx",
+        "androidx.recyclerview_recyclerview",
+        "SystemUICustomizationLib",
     ],
 
     jni_libs: [
diff --git a/res/color/keyguard_quick_affordance_slot_tab_background_color.xml b/res/color/keyguard_quick_affordance_slot_tab_background_color.xml
new file mode 100644
index 0000000..4708cef
--- /dev/null
+++ b/res/color/keyguard_quick_affordance_slot_tab_background_color.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_selected="true" android:color="@color/color_accent_primary" />
+    <item android:color="@android:color/transparent" />
+</selector>
diff --git a/res/color/keyguard_quick_affordance_slot_tab_text_color.xml b/res/color/keyguard_quick_affordance_slot_tab_text_color.xml
new file mode 100644
index 0000000..84502d4
--- /dev/null
+++ b/res/color/keyguard_quick_affordance_slot_tab_text_color.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_selected="true" android:color="@color/text_color_on_accent" />
+    <item android:color="@color/text_color_primary" />
+</selector>
diff --git a/res/drawable/keyguard_quick_affordance_icon_container_background.xml b/res/drawable/keyguard_quick_affordance_icon_container_background.xml
new file mode 100644
index 0000000..8bd8af4
--- /dev/null
+++ b/res/drawable/keyguard_quick_affordance_icon_container_background.xml
@@ -0,0 +1,20 @@
+<!--
+     Copyright (C) 2021 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.
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="20dp" />
+    <solid android:color="@color/color_surface_variant" />
+</shape>
diff --git a/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml b/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml
new file mode 100644
index 0000000..93a80eb
--- /dev/null
+++ b/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml
@@ -0,0 +1,34 @@
+<!--
+     Copyright (C) 2021 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.
+-->
+<shape
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle" >
+
+    <stroke
+        android:width="2dp"
+        android:color="@color/text_color_primary" />
+
+    <solid android:color="@color/color_surface_variant" />
+
+    <corners android:radius="20dp" />
+
+    <padding
+        android:left="5dp"
+        android:top="5dp"
+        android:right="5dp"
+        android:bottom="5dp" />
+
+</shape>
diff --git a/res/drawable/keyguard_quick_affordance_picker_background.xml b/res/drawable/keyguard_quick_affordance_picker_background.xml
new file mode 100644
index 0000000..3a49d7a
--- /dev/null
+++ b/res/drawable/keyguard_quick_affordance_picker_background.xml
@@ -0,0 +1,20 @@
+<!--
+     Copyright (C) 2021 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.
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="28dp" />
+    <solid android:color="@color/color_surface" />
+</shape>
diff --git a/res/drawable/keyguard_quick_affordance_slot_tab_background.xml b/res/drawable/keyguard_quick_affordance_slot_tab_background.xml
new file mode 100644
index 0000000..3fbced3
--- /dev/null
+++ b/res/drawable/keyguard_quick_affordance_slot_tab_background.xml
@@ -0,0 +1,20 @@
+<!--
+     Copyright (C) 2021 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.
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="50dp" />
+    <solid android:color="@color/keyguard_quick_affordance_slot_tab_background_color" />
+</shape>
diff --git a/res/drawable/link_off.xml b/res/drawable/link_off.xml
new file mode 100644
index 0000000..f16a63f
--- /dev/null
+++ b/res/drawable/link_off.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="@dimen/keyguard_quick_affordance_icon_size"
+    android:height="@dimen/keyguard_quick_affordance_icon_size"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M14.39,11L16,12.61V11zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1 0,1.27 -0.77,2.37 -1.87,2.84l1.4,1.4C21.05,15.36 22,13.79 22,12c0,-2.76 -2.24,-5 -5,-5zM2,4.27l3.11,3.11C3.29,8.12 2,9.91 2,12c0,2.76 2.24,5 5,5h4v-1.9H7c-1.71,0 -3.1,-1.39 -3.1,-3.1 0,-1.59 1.21,-2.9 2.76,-3.07L8.73,11H8v2h2.73L13,15.27V17h1.73l4.01,4.01 1.41,-1.41L3.41,2.86 2,4.27z"/>
+</vector>
diff --git a/res/drawable/selectable.xml b/res/drawable/selectable.xml
new file mode 100644
index 0000000..1364d68
--- /dev/null
+++ b/res/drawable/selectable.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_selected="true" android:drawable="@drawable/selectable_selected" />
+    <item android:drawable="@android:color/transparent" />
+</selector>
diff --git a/res/drawable/selectable_selected.xml b/res/drawable/selectable_selected.xml
new file mode 100644
index 0000000..2ba8948
--- /dev/null
+++ b/res/drawable/selectable_selected.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="8dp" />
+    <stroke android:width="2dp" android:color="@android:color/white" />
+</shape>
diff --git a/res/layout/fragment_lock_screen_quick_affordances.xml b/res/layout/fragment_lock_screen_quick_affordances.xml
new file mode 100644
index 0000000..9927e6a
--- /dev/null
+++ b/res/layout/fragment_lock_screen_quick_affordances.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <FrameLayout
+        android:id="@+id/section_header_container"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <include layout="@layout/section_header" />
+
+    </FrameLayout>
+
+    <!-- TODO(b/254858701): plug in the preview here. -->
+    <View
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1" />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:layout_marginHorizontal="24dp"
+        android:layout_marginBottom="28dp"
+        android:background="@drawable/keyguard_quick_affordance_picker_background"
+        android:paddingTop="22dp"
+        android:paddingBottom="62dp">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@id/slot_tabs"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:clipToPadding="false"
+            android:paddingHorizontal="16dp" />
+
+        <View
+            android:layout_width="0dp"
+            android:layout_height="22dp" />
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@id/affordances"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:clipToPadding="false"
+            android:paddingHorizontal="16dp" />
+
+    </LinearLayout>
+
+</LinearLayout>
diff --git a/res/layout/keyguard_quick_affordance.xml b/res/layout/keyguard_quick_affordance.xml
new file mode 100644
index 0000000..90ba68e
--- /dev/null
+++ b/res/layout/keyguard_quick_affordance.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 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"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="@dimen/keyguard_quick_affordance_picker_item_width"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:gravity="center_horizontal">
+
+    <FrameLayout
+        android:id="@+id/icon_container"
+        android:layout_width="@dimen/keyguard_quick_affordance_icon_container_size"
+        android:layout_height="@dimen/keyguard_quick_affordance_icon_container_size" >
+
+        <ImageView
+            android:id="@+id/icon"
+            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/text_color_primary" />
+
+    </FrameLayout>
+
+    <View
+        android:layout_width="0dp"
+        android:layout_height="8dp" />
+
+    <TextView
+        android:id="@+id/name"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textColor="@color/text_color_primary"
+        android:singleLine="true"
+        android:ellipsize="end"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/keyguard_quick_affordance_section_view.xml b/res/layout/keyguard_quick_affordance_section_view.xml
new file mode 100644
index 0000000..fc2a1ba
--- /dev/null
+++ b/res/layout/keyguard_quick_affordance_section_view.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2022 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.
+-->
+<com.android.customization.picker.quickaffordance.ui.view.KeyguardQuickAffordanceSectionView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="?selectableItemBackground"
+    android:clickable="true"
+    android:paddingVertical="@dimen/section_top_padding"
+    android:paddingHorizontal="@dimen/section_horizontal_padding"
+    android:orientation="horizontal">
+
+    <LinearLayout
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:orientation="vertical">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/keyguard_quick_affordance_section_title"
+            style="@style/SectionTitleTextStyle" />
+
+        <TextView
+            android:id="@+id/keyguard_quick_affordance_description"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            style="@style/SectionSubtitleTextStyle"/>
+    </LinearLayout>
+
+    <LinearLayout
+        android:layout_width="@dimen/option_tile_width"
+        android:layout_height="@dimen/option_tile_width"
+        android:orientation="horizontal"
+        android:background="@drawable/option_border_color"
+        android:importantForAccessibility="noHideDescendants"
+        android:gravity="center">
+
+        <ImageView
+            android:id="@+id/icon_1"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:visibility="gone" />
+
+        <View
+            android:id="@+id/icon_spacer"
+            android:layout_width="14dp"
+            android:layout_height="0dp"
+            android:visibility="gone" />
+
+        <ImageView
+            android:id="@+id/icon_2"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:visibility="gone" />
+
+    </LinearLayout>
+
+
+</com.android.customization.picker.quickaffordance.ui.view.KeyguardQuickAffordanceSectionView>
\ No newline at end of file
diff --git a/res/layout/keyguard_quick_affordance_slot_tab.xml b/res/layout/keyguard_quick_affordance_slot_tab.xml
new file mode 100644
index 0000000..ba233cd
--- /dev/null
+++ b/res/layout/keyguard_quick_affordance_slot_tab.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<TextView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/text"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:textColor="@color/keyguard_quick_affordance_slot_tab_text_color"
+    android:paddingVertical="8dp"
+    android:paddingHorizontal="16dp"
+    android:minWidth="48dp"
+    android:minHeight="48dp"
+    android:gravity="center"
+    android:background="@drawable/keyguard_quick_affordance_slot_tab_background" />
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 0ad221e..225d7b0 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -135,4 +135,12 @@
     <dimen name="color_seed_option_tile_padding">10dp</dimen>
     <dimen name="color_seed_option_tile_padding_selected">6dp</dimen>
     <dimen name="color_seed_chip_margin">14dp</dimen>
+
+    <!-- Keyguard quick affordances -->
+    <!-- Size for the container for the icon of a quick affordance for the lock screen in the picker experience. -->
+    <dimen name="keyguard_quick_affordance_icon_container_size">74dp</dimen>
+    <!-- Size for the icon of a quick affordance for the lock screen in the picker experience. -->
+    <dimen name="keyguard_quick_affordance_icon_size">24dp</dimen>
+    <!-- Width of a single selectable item in the lock screen quick affordance picker. -->
+    <dimen name="keyguard_quick_affordance_picker_item_width">74dp</dimen>
 </resources>
diff --git a/res/values/ids.xml b/res/values/ids.xml
new file mode 100644
index 0000000..1ed004d
--- /dev/null
+++ b/res/values/ids.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2022 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.
+  ~
+  -->
+
+<resources>
+    <item name="start_affordance" type="id" />
+    <item name="end_affordance" type="id" />
+    <item name="slot_tabs" type="id" />
+    <item name="affordances" type="id" />
+</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 7aef401..63fb560 100755
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -267,4 +267,81 @@
     <!-- Title of a section of color selection option that obtains colors automatically from the
         wallpaper instead of a set color [CHAR LIMIT=15] -->
     <string name="adaptive_color_title">Dynamic</string>
+
+    <!--
+    Name of the slot on the "start" side of the bottom of the lock screen, where quick affordance
+    buttons can be added to the lock screen. In left-to-right languages, this is the left-hand side
+    button. In right-to-left languages, this is the right-hand side button. [CHAR LIMIT=16].
+    -->
+    <string name="keyguard_slot_name_bottom_start">Left button</string>
+
+    <!--
+    Name of the slot on the "end" side of the bottom of the lock screen, where quick affordance
+    buttons can be added to the lock screen. In left-to-right languages, this is the right-hand side
+    button. In right-to-left languages, this is the left-hand side button. [CHAR LIMIT=16].
+    -->
+    <string name="keyguard_slot_name_bottom_end">Right button</string>
+
+    <!--
+    Name for an option to have no quick affordance selected for one of the sides of the lock
+    screen. We show this as an option in a settings experience, where users get to choose which
+    quick affordances (or buttons) are available on their device's lock screen. [CHAR LIMIT=10].
+    -->
+    <string name="keyguard_affordance_none">None</string>
+
+    <!--
+    Title for a popup dialog shown when the user attempts to select an option that is not currently
+    enabled. The dialog contains a list of instructions that the user needs to take in order to
+    enable the option before it can be selected again. [CHAR LIMIT=NONE].
+    -->
+    <string name="keyguard_affordance_enablement_dialog_title">Additional setup needed</string>
+
+    <!--
+    Template for an action that opens a specific app. [CHAR LIMIT=16]
+    -->
+    <string name="keyguard_affordance_enablement_dialog_action_template">Open <xliff:g id="appName" example="Wallet">%1$s</xliff:g></string>
+
+    <!--
+    Template for a message shown right before a list of instructions that tell the user what to do
+    in order to enable a shortcut to a specific app. [CHAR LIMIT=NONE]
+    -->
+    <string name="keyguard_affordance_enablement_dialog_message">To add the <xliff:g id="appName" example="Wallet">%1$s</xliff:g> app as a shortcut, make sure</string>
+
+    <!--
+    Label for button in a dialog shown to the user with a list of instructions that the user should
+    follow in order to make a piece of functionality available as a lock screen quick affordance.
+    [CHAR LIMIT=10].
+    -->
+    <string name="keyguard_affordance_enablement_dialog_dismiss_button">Done</string>
+
+    <!--
+    Title for a screen where the user can configure the lock screen shortcut buttons that appear on
+    the device without unlocking.
+    [CHAR LIMIT=32].
+    -->
+    <string name="keyguard_quick_affordance_title">Shortcuts</string>
+
+    <!--
+    Label for a menu item on a settings screen that helps the user open a new screen where they can
+    configure the lock screen shortcut buttons that appear on the device without unlocking.
+    [CHAR LIMIT=16].
+    -->
+    <string name="keyguard_quick_affordance_section_title">Shortcuts</string>
+
+    <!--
+    Template for text that shows the names of two currently-selected lock screen shortcuts on the
+    lock screen. For example, it may say "Camera, Wallet", if the first selected shortcut opens the
+    camera app and the second one opens the tap-to-pay wallet experience.
+    [CHAR LIMIT=60].
+    -->
+    <string name="keyguard_quick_affordance_two_selected_template"><xliff:g id="first">%1$s</xliff:g>, <xliff:g id="second">%2$s</xliff:g></string>
+
+    <!--
+    Placeholder text that shows when no lock screen shortcuts are currently selected on the lock
+    screen. When selected, "None" is replaced by another string that shows what is currently
+    selected. For example, it may say "Camera, Wallet", if the first selected shortcut opens the
+    camera app and the second one opens the tap-to-pay wallet experience.
+    [CHAR LIMIT=60].
+    -->
+    <string name="keyguard_quick_affordance_none_selected">None</string>
 </resources>
diff --git a/robolectric_tests/Android.mk b/robolectric_tests/Android.mk
deleted file mode 100644
index f5ab901..0000000
--- a/robolectric_tests/Android.mk
+++ /dev/null
@@ -1,60 +0,0 @@
-# Copyright (C) 2019 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.
-
-#############################################
-# ThenePicker Robolectric test target.      #
-#############################################
-LOCAL_PATH := $(call my-dir)
-include $(CLEAR_VARS)
-
-LOCAL_MODULE := ThemePickerRoboTests
-LOCAL_LICENSE_KINDS := SPDX-license-identifier-Apache-2.0
-LOCAL_LICENSE_CONDITIONS := notice
-LOCAL_SDK_VERSION := system_current
-LOCAL_SRC_FILES := $(call all-java-files-under, src)
-LOCAL_STATIC_JAVA_LIBRARIES := \
-    androidx.test.core \
-    androidx.test.runner \
-    androidx.test.rules \
-    mockito-robolectric-prebuilt \
-    truth-prebuilt
-LOCAL_JAVA_LIBRARIES := \
-    platform-robolectric-4.8.2-prebuilt
-
-LOCAL_JAVA_RESOURCE_DIRS := config
-
-LOCAL_INSTRUMENTATION_FOR := ThemePicker
-LOCAL_MODULE_TAGS := optional
-
-include $(BUILD_STATIC_JAVA_LIBRARY)
-
-############################################
-# Target to run the previous target.       #
-############################################
-include $(CLEAR_VARS)
-
-LOCAL_MODULE := RunThemePickerRoboTests
-LOCAL_LICENSE_KINDS := SPDX-license-identifier-Apache-2.0
-LOCAL_LICENSE_CONDITIONS := notice
-LOCAL_SDK_VERSION := system_current
-LOCAL_JAVA_LIBRARIES := \
-    ThemePickerRoboTests
-
-LOCAL_TEST_PACKAGE := ThemePicker
-
-LOCAL_INSTRUMENT_SOURCE_DIRS := packages/apps/ThemePicker/src \
-
-LOCAL_ROBOTEST_TIMEOUT := 36000
-
-include prebuilts/misc/common/robolectric/4.8.2/run_robotests.mk
diff --git a/src/com/android/customization/module/CustomizationInjector.java b/src/com/android/customization/module/CustomizationInjector.java
index 85853de..2cc1245 100644
--- a/src/com/android/customization/module/CustomizationInjector.java
+++ b/src/com/android/customization/module/CustomizationInjector.java
@@ -22,6 +22,7 @@
 import com.android.customization.model.theme.OverlayManagerCompat;
 import com.android.customization.model.theme.ThemeBundleProvider;
 import com.android.customization.model.theme.ThemeManager;
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor;
 import com.android.wallpaper.module.Injector;
 
 public interface CustomizationInjector extends Injector {
@@ -30,4 +31,11 @@
 
     ThemeManager getThemeManager(ThemeBundleProvider provider, FragmentActivity activity,
             OverlayManagerCompat overlayManagerCompat, ThemesUserEventLogger logger);
+
+
+    /**
+     * Get {@link KeyguardQuickAffordancePickerInteractor}
+     */
+    KeyguardQuickAffordancePickerInteractor getKeyguardQuickAffordancePickerInteractor(
+            Context context);
 }
diff --git a/src/com/android/customization/module/DefaultCustomizationSections.java b/src/com/android/customization/module/DefaultCustomizationSections.java
index 21f2c84..7eb8865 100644
--- a/src/com/android/customization/module/DefaultCustomizationSections.java
+++ b/src/com/android/customization/module/DefaultCustomizationSections.java
@@ -1,9 +1,9 @@
 package com.android.customization.module;
 
-import android.app.Activity;
 import android.os.Bundle;
 
 import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
 import androidx.lifecycle.LifecycleOwner;
 
 import com.android.customization.model.color.ColorSectionController;
@@ -28,9 +28,12 @@
 public final class DefaultCustomizationSections implements CustomizationSections {
 
     @Override
-    public List<CustomizationSectionController<?>> getAllSectionControllers(Activity activity,
-            LifecycleOwner lifecycleOwner, WallpaperColorsViewModel wallpaperColorsViewModel,
-            WorkspaceViewModel workspaceViewModel, PermissionRequester permissionRequester,
+    public List<CustomizationSectionController<?>> getAllSectionControllers(
+            FragmentActivity activity,
+            LifecycleOwner lifecycleOwner,
+            WallpaperColorsViewModel wallpaperColorsViewModel,
+            WorkspaceViewModel workspaceViewModel,
+            PermissionRequester permissionRequester,
             WallpaperPreviewNavigator wallpaperPreviewNavigator,
             CustomizationSectionNavigationController sectionNavigationController,
             @Nullable Bundle savedInstanceState) {
diff --git a/src/com/android/customization/module/ThemePickerInjector.java b/src/com/android/customization/module/ThemePickerInjector.java
index ef2b60a..4069b50 100644
--- a/src/com/android/customization/module/ThemePickerInjector.java
+++ b/src/com/android/customization/module/ThemePickerInjector.java
@@ -21,6 +21,7 @@
 import static com.android.wallpaper.picker.PreviewFragment.ARG_VIEW_AS_HOME;
 import static com.android.wallpaper.picker.PreviewFragment.ARG_WALLPAPER;
 
+import android.app.Activity;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
@@ -32,6 +33,11 @@
 import com.android.customization.model.theme.OverlayManagerCompat;
 import com.android.customization.model.theme.ThemeBundleProvider;
 import com.android.customization.model.theme.ThemeManager;
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository;
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor;
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel;
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClient;
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClientImpl;
 import com.android.wallpaper.model.LiveWallpaperInfo;
 import com.android.wallpaper.model.WallpaperInfo;
 import com.android.wallpaper.module.CustomizationSections;
@@ -42,6 +48,8 @@
 import com.android.wallpaper.picker.LivePreviewFragment;
 import com.android.wallpaper.picker.PreviewFragment;
 
+import kotlinx.coroutines.Dispatchers;
+
 /**
  * A concrete, real implementation of the dependency provider.
  */
@@ -50,9 +58,12 @@
     private CustomizationSections mCustomizationSections;
     private ThemesUserEventLogger mUserEventLogger;
     private WallpaperPreferences mPrefs;
+    private KeyguardQuickAffordancePickerInteractor mKeyguardQuickAffordancePickerInteractor;
+    private KeyguardQuickAffordancePickerViewModel.Factory
+            mKeyguardQuickAffordancePickerViewModelFactory;
 
     @Override
-    public CustomizationSections getCustomizationSections() {
+    public CustomizationSections getCustomizationSections(Activity activity) {
         if (mCustomizationSections == null) {
             mCustomizationSections = new DefaultCustomizationSections();
         }
@@ -122,4 +133,31 @@
             OverlayManagerCompat overlayManagerCompat, ThemesUserEventLogger logger) {
         return new ThemeManager(provider, activity, overlayManagerCompat, logger);
     }
+
+    @Override
+    public KeyguardQuickAffordancePickerInteractor getKeyguardQuickAffordancePickerInteractor(
+            Context context) {
+        if (mKeyguardQuickAffordancePickerInteractor == null) {
+            final KeyguardQuickAffordanceProviderClient client =
+                    new KeyguardQuickAffordanceProviderClientImpl(context, Dispatchers.getIO());
+            mKeyguardQuickAffordancePickerInteractor = new KeyguardQuickAffordancePickerInteractor(
+                    new KeyguardQuickAffordancePickerRepository(client, Dispatchers.getIO()),
+                    client);
+        }
+        return mKeyguardQuickAffordancePickerInteractor;
+    }
+
+    /**
+     * Returns a {@link KeyguardQuickAffordancePickerViewModel.Factory}.
+     */
+    public KeyguardQuickAffordancePickerViewModel.Factory
+            getKeyguardQuickAffordancePickerViewModelFactory(Context context) {
+        if (mKeyguardQuickAffordancePickerViewModelFactory == null) {
+            mKeyguardQuickAffordancePickerViewModelFactory =
+                    new KeyguardQuickAffordancePickerViewModel.Factory(
+                            context,
+                            getKeyguardQuickAffordancePickerInteractor(context));
+        }
+        return mKeyguardQuickAffordancePickerViewModelFactory;
+    }
 }
diff --git a/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt b/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt
new file mode 100644
index 0000000..5846107
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.data.repository
+
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerAffordanceModel as AffordanceModel
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerSelectionModel as SelectionModel
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerSlotModel as SlotModel
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClient as Client
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderContract as Contract
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+
+/**
+ * Abstracts access to application state related to functionality for selecting, picking, or setting
+ * lock screen quick affordances.
+ */
+class KeyguardQuickAffordancePickerRepository(
+    private val client: Client,
+    private val backgroundDispatcher: CoroutineDispatcher,
+) {
+    /** Whether the feature is enabled. */
+    val isFeatureEnabled: Flow<Boolean> =
+        client.observeFlags().map { flags -> flags.isFeatureEnabled() }
+
+    /** List of slots available on the device. */
+    val slots: Flow<List<SlotModel>> =
+        client.observeSlots().map { slots -> slots.map { slot -> slot.toModel() } }
+
+    /** List of all available quick affordances. */
+    val affordances: Flow<List<AffordanceModel>> =
+        client.observeAffordances().map { affordances ->
+            affordances.map { affordance -> affordance.toModel() }
+        }
+
+    /** List of slot-affordance pairs, modeling what the user has currently chosen for each slot. */
+    val selections: Flow<List<SelectionModel>> =
+        client.observeSelections().map { selections ->
+            selections.map { selection -> selection.toModel() }
+        }
+
+    suspend fun isFeatureEnabled(): Boolean {
+        return withContext(backgroundDispatcher) { client.queryFlags().isFeatureEnabled() }
+    }
+
+    private fun List<Client.Flag>.isFeatureEnabled(): Boolean {
+        return find { flag -> flag.name == Contract.FlagsTable.FLAG_NAME_FEATURE_ENABLED }?.value ==
+            true
+    }
+
+    private fun Client.Slot.toModel(): SlotModel {
+        return SlotModel(
+            id = id,
+            maxSelectedQuickAffordances = capacity,
+        )
+    }
+
+    private fun Client.Affordance.toModel(): AffordanceModel {
+        return AffordanceModel(
+            id = id,
+            name = name,
+            iconResourceId = iconResourceId,
+            isEnabled = isEnabled,
+            enablementInstructions = enablementInstructions ?: emptyList(),
+            enablementActionText = enablementActionText,
+            enablementActionComponentName = enablementActionComponentName,
+        )
+    }
+
+    private fun Client.Selection.toModel(): SelectionModel {
+        return SelectionModel(
+            slotId = slotId,
+            affordanceId = affordanceId,
+        )
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
new file mode 100644
index 0000000..87cedf5
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.domain.interactor
+
+import android.graphics.drawable.Drawable
+import androidx.annotation.DrawableRes
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerAffordanceModel as AffordanceModel
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerSelectionModel as SelectionModel
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerSlotModel as SlotModel
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClient as Client
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Single entry-point for all application state and business logic related to quick affordances on
+ * the lock screen.
+ */
+class KeyguardQuickAffordancePickerInteractor(
+    private val repository: KeyguardQuickAffordancePickerRepository,
+    private val client: Client,
+) {
+    /** Whether the feature is enabled. */
+    val isFeatureEnabled: Flow<Boolean> = repository.isFeatureEnabled
+
+    /** List of slots available on the device. */
+    val slots: Flow<List<SlotModel>> = repository.slots
+
+    /** List of all available quick affordances. */
+    val affordances: Flow<List<AffordanceModel>> = repository.affordances
+
+    /** List of slot-affordance pairs, modeling what the user has currently chosen for each slot. */
+    val selections: Flow<List<SelectionModel>> = repository.selections
+
+    /**
+     * Selects an affordance with the given ID for a slot with the given ID.
+     *
+     * Note that the maximum affordance per slot is automatically managed. If trying to select an
+     * affordance for a slot that's already full, the oldest affordance is removed to make room.
+     *
+     * Note that if an affordance with the given ID is already selected on the slot with the given
+     * ID, that affordance is moved to the newest position on the slot.
+     */
+    suspend fun select(slotId: String, affordanceId: String) {
+        client.insertSelection(
+            slotId = slotId,
+            affordanceId = affordanceId,
+        )
+    }
+
+    /** Unselects an affordance with the given ID from the slot with the given ID. */
+    suspend fun unselect(slotId: String, affordanceId: String) {
+        client.deleteSelection(
+            slotId = slotId,
+            affordanceId = affordanceId,
+        )
+    }
+
+    /** Unselects all affordances from the slot with the given ID. */
+    suspend fun unselectAll(slotId: String) {
+        client.deleteAllSelections(
+            slotId = slotId,
+        )
+    }
+
+    /** Returns a [Drawable] for the given resource ID, from the system UI package. */
+    suspend fun getAffordanceIcon(
+        @DrawableRes iconResourceId: Int,
+    ): Drawable {
+        return client.getAffordanceIcon(iconResourceId)
+    }
+
+    /** Returns `true` if the feature is enabled; `false` otherwise. */
+    suspend fun isFeatureEnabled(): Boolean {
+        return repository.isFeatureEnabled()
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt
new file mode 100644
index 0000000..1b18af7
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.shared.model
+
+import androidx.annotation.DrawableRes
+
+/** Models a quick affordance. */
+data class KeyguardQuickAffordancePickerAffordanceModel(
+    val id: String,
+    val name: String,
+    /**
+     * The resource ID for a drawable of the icon. This is in the namespace of system UI so it
+     * should be queries from that package.
+     */
+    @DrawableRes val iconResourceId: Int,
+    /** Whether this quick affordance is enabled. */
+    val isEnabled: Boolean,
+    /** If not enabled, the list of user-visible steps to re-enable it. */
+    val enablementInstructions: List<String>,
+    /**
+     * If not enabled, an optional label for a button that takes the user to a destination where
+     * they can re-enable it.
+     */
+    val enablementActionText: String?,
+    /**
+     * If not enabled, an optional component name (package and action) for a button that takes the
+     * user to a destination where they can re-enable it.
+     */
+    val enablementActionComponentName: String?,
+)
diff --git a/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerSelectionModel.kt b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerSelectionModel.kt
new file mode 100644
index 0000000..eea8b2a
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerSelectionModel.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.shared.model
+
+/** Models a selection of an affordance on a slot. */
+data class KeyguardQuickAffordancePickerSelectionModel(
+    val slotId: String,
+    val affordanceId: String,
+)
diff --git a/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerSlotModel.kt b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerSlotModel.kt
new file mode 100644
index 0000000..7e662e0
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerSlotModel.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.shared.model
+
+/** Models a lock screen quick affordance slot (or position) where affordances can be displayed. */
+data class KeyguardQuickAffordancePickerSlotModel(
+    val id: String,
+    /** Maximum number of affordances allowed to be set on this slot. */
+    val maxSelectedQuickAffordances: Int,
+)
diff --git a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt b/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt
new file mode 100644
index 0000000..b0dc350
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel
+import com.android.wallpaper.R
+
+/** Adapts between lock screen quick affordance items and views. */
+class AffordancesAdapter : RecyclerView.Adapter<AffordancesAdapter.ViewHolder>() {
+
+    private val items = mutableListOf<KeyguardQuickAffordanceViewModel>()
+
+    fun setItems(items: List<KeyguardQuickAffordanceViewModel>) {
+        this.items.clear()
+        this.items.addAll(items)
+        notifyDataSetChanged()
+    }
+
+    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        val iconContainerView: View = itemView.requireViewById(R.id.icon_container)
+        val iconView: ImageView = itemView.requireViewById(R.id.icon)
+        val nameView: TextView = itemView.requireViewById(R.id.name)
+    }
+
+    override fun getItemCount(): Int {
+        return items.size
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        return ViewHolder(
+            LayoutInflater.from(parent.context)
+                .inflate(
+                    R.layout.keyguard_quick_affordance,
+                    parent,
+                    false,
+                )
+        )
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val item = items[position]
+        holder.itemView.alpha =
+            if (item.isEnabled) {
+                ALPHA_ENABLED
+            } else {
+                ALPHA_DISABLED
+            }
+
+        holder.itemView.setOnClickListener(
+            if (item.onClicked != null) {
+                View.OnClickListener { item.onClicked.invoke() }
+            } else {
+                null
+            }
+        )
+        holder.iconContainerView.setBackgroundResource(
+            if (item.isSelected) {
+                R.drawable.keyguard_quick_affordance_icon_container_background_selected
+            } else {
+                R.drawable.keyguard_quick_affordance_icon_container_background
+            }
+        )
+        holder.iconView.isSelected = item.isSelected
+        holder.nameView.isSelected = item.isSelected
+        holder.iconView.setImageDrawable(item.icon)
+        holder.nameView.text = item.contentDescription
+        holder.nameView.isSelected = item.isSelected
+    }
+
+    companion object {
+        private const val ALPHA_ENABLED = 1f
+        private const val ALPHA_DISABLED = 0.3f
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt b/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt
new file mode 100644
index 0000000..acafef4
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
+import com.android.wallpaper.R
+
+/** Adapts between lock screen quick affordance slot items and views. */
+class SlotTabAdapter : RecyclerView.Adapter<SlotTabAdapter.ViewHolder>() {
+
+    private val items = mutableListOf<KeyguardQuickAffordanceSlotViewModel>()
+
+    fun setItems(items: List<KeyguardQuickAffordanceSlotViewModel>) {
+        this.items.clear()
+        this.items.addAll(items)
+        notifyDataSetChanged()
+    }
+
+    override fun getItemCount(): Int {
+        return items.size
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        return ViewHolder(
+            LayoutInflater.from(parent.context)
+                .inflate(
+                    R.layout.keyguard_quick_affordance_slot_tab,
+                    parent,
+                    false,
+                )
+        )
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val item = items[position]
+        holder.itemView.isSelected = item.isSelected
+        holder.textView.text = item.name
+        holder.textView.setOnClickListener(
+            if (item.onClicked != null) {
+                View.OnClickListener { item.onClicked.invoke() }
+            } else {
+                null
+            }
+        )
+    }
+
+    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        val textView: TextView = itemView.requireViewById(R.id.text)
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
new file mode 100644
index 0000000..62f2e26
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.binder
+
+import android.app.AlertDialog
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.graphics.Rect
+import android.view.View
+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.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.quickaffordance.ui.adapter.AffordancesAdapter
+import com.android.customization.picker.quickaffordance.ui.adapter.SlotTabAdapter
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
+import com.android.wallpaper.R
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+object KeyguardQuickAffordancePickerBinder {
+
+    /** Binds view with view-model for a lock screen quick affordance picker experience. */
+    @JvmStatic
+    fun bind(
+        view: View,
+        viewModel: KeyguardQuickAffordancePickerViewModel,
+        lifecycleOwner: LifecycleOwner,
+    ) {
+        val slotTabView: RecyclerView = view.requireViewById(R.id.slot_tabs)
+        val affordancesView: RecyclerView = view.requireViewById(R.id.affordances)
+
+        val slotTabAdapter = SlotTabAdapter()
+        slotTabView.adapter = slotTabAdapter
+        slotTabView.layoutManager =
+            LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+        slotTabView.addItemDecoration(ItemSpacing())
+        val affordancesAdapter = AffordancesAdapter()
+        affordancesView.adapter = affordancesAdapter
+        affordancesView.layoutManager =
+            LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+        affordancesView.addItemDecoration(ItemSpacing())
+
+        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 ->
+                        affordancesAdapter.setItems(affordances)
+                    }
+                }
+
+                launch {
+                    viewModel.dialog.distinctUntilChanged().collect { dialogRequest ->
+                        dialog?.dismiss()
+                        dialog =
+                            if (dialogRequest != null) {
+                                showDialog(
+                                    context = view.context,
+                                    request = dialogRequest,
+                                    onDismissed = viewModel::onDialogDismissed
+                                )
+                            } else {
+                                dialog?.dismiss()
+                                null
+                            }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun showDialog(
+        context: Context,
+        request: KeyguardQuickAffordancePickerViewModel.DialogViewModel,
+        onDismissed: () -> Unit,
+    ): Dialog {
+        // TODO(b/254858701): make this dialog prettier and probably use a DialogFragment.
+        return AlertDialog.Builder(context, context.themeResId)
+            .setTitle(context.getString(R.string.keyguard_affordance_enablement_dialog_title))
+            .setMessage(
+                buildString {
+                    append(request.instructionHeader)
+                    if (request.instructions.isNotEmpty()) {
+                        append("\n")
+                    }
+                    request.instructions.forEachIndexed { index, instruction ->
+                        append(instruction)
+                        if (index < request.instructions.size - 1) {
+                            append("\n")
+                        }
+                    }
+                }
+            )
+            .setOnDismissListener { onDismissed.invoke() }
+            .setPositiveButton(
+                request.actionText,
+                if (request.intent != null) {
+                    DialogInterface.OnClickListener { _, _ ->
+                        context.startActivity(request.intent)
+                    }
+                } else {
+                    DialogInterface.OnClickListener { _, _ -> onDismissed() }
+                },
+            )
+            .show()
+    }
+
+    private class ItemSpacing : RecyclerView.ItemDecoration() {
+        override fun getItemOffsets(outRect: Rect, itemPosition: Int, parent: RecyclerView) {
+            val addSpacingToStart = itemPosition > 0
+            val addSpacingToEnd = itemPosition < (parent.adapter?.itemCount ?: 0) - 1
+            val isRtl = parent.layoutManager?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL
+            val density = parent.context.resources.displayMetrics.density
+            if (!isRtl) {
+                outRect.left = if (addSpacingToStart) ITEM_SPACING_DP.toPx(density) else 0
+                outRect.right = if (addSpacingToEnd) ITEM_SPACING_DP.toPx(density) else 0
+            } else {
+                outRect.left = if (addSpacingToEnd) ITEM_SPACING_DP.toPx(density) else 0
+                outRect.right = if (addSpacingToStart) ITEM_SPACING_DP.toPx(density) else 0
+            }
+        }
+
+        private fun Int.toPx(density: Float): Int {
+            return (this * density).toInt()
+        }
+
+        companion object {
+            private const val ITEM_SPACING_DP = 8
+        }
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerPreviewBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerPreviewBinder.kt
new file mode 100644
index 0000000..13ee553
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerPreviewBinder.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.binder
+
+import android.view.View
+import android.widget.ImageView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.wallpaper.R
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.launch
+
+object KeyguardQuickAffordancePickerPreviewBinder {
+
+    /** Binds view with view-model for a lock screen quick affordance preview experience. */
+    @JvmStatic
+    fun bind(
+        view: View,
+        viewModel: KeyguardQuickAffordancePickerViewModel,
+        lifecycleOwner: LifecycleOwner,
+    ) {
+        val startView: ImageView = view.requireViewById(R.id.start_affordance)
+        val endView: ImageView = view.requireViewById(R.id.end_affordance)
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch {
+                    updateView(
+                        view = startView,
+                        viewModel = viewModel,
+                        slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                    )
+                }
+
+                launch {
+                    updateView(
+                        view = endView,
+                        viewModel = viewModel,
+                        slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+                    )
+                }
+            }
+        }
+    }
+
+    private suspend fun updateView(
+        view: ImageView,
+        viewModel: KeyguardQuickAffordancePickerViewModel,
+        slotId: String,
+    ) {
+        viewModel.slots
+            .mapNotNull { slotById -> slotById[slotId] }
+            .map { slot -> slot.selectedQuickAffordances.firstOrNull() }
+            .collect { affordance ->
+                view.setImageDrawable(affordance?.icon)
+                view.contentDescription = affordance?.contentDescription
+            }
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
new file mode 100644
index 0000000..e832cc2
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.binder
+
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
+import com.android.wallpaper.R
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+object KeyguardQuickAffordanceSectionViewBinder {
+    fun bind(
+        view: View,
+        viewModel: KeyguardQuickAffordancePickerViewModel,
+        lifecycleOwner: LifecycleOwner,
+        onClicked: () -> Unit,
+    ) {
+        view.setOnClickListener { onClicked() }
+
+        val descriptionView: TextView =
+            view.requireViewById(R.id.keyguard_quick_affordance_description)
+        val icon1: ImageView = view.requireViewById(R.id.icon_1)
+        val icon2: ImageView = view.requireViewById(R.id.icon_2)
+        val iconSpacer: View = view.requireViewById(R.id.icon_spacer)
+
+        lifecycleOwner.lifecycleScope.launch {
+            viewModel.summary
+                .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.RESUMED)
+                .collectLatest { summary ->
+                    descriptionView.text = summary.description
+
+                    icon1.setImageDrawable(summary.icon1)
+                    icon1.isVisible = summary.icon1 != null
+
+                    icon2.setImageDrawable(summary.icon2)
+                    icon2.isVisible = summary.icon2 != null
+
+                    iconSpacer.isVisible = summary.isIconSpacingVisible
+                }
+        }
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt b/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt
new file mode 100644
index 0000000..89235d7
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.get
+import com.android.customization.module.ThemePickerInjector
+import com.android.customization.picker.quickaffordance.ui.binder.KeyguardQuickAffordancePickerBinder
+import com.android.wallpaper.R
+import com.android.wallpaper.module.InjectorProvider
+import com.android.wallpaper.picker.AppbarFragment
+
+class KeyguardQuickAffordancePickerFragment : AppbarFragment() {
+    companion object {
+        fun newInstance(): KeyguardQuickAffordancePickerFragment {
+            return KeyguardQuickAffordancePickerFragment()
+        }
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        val view =
+            inflater.inflate(
+                R.layout.fragment_lock_screen_quick_affordances,
+                container,
+                false,
+            )
+        setUpToolbar(view)
+        val injector = InjectorProvider.getInjector() as ThemePickerInjector
+        KeyguardQuickAffordancePickerBinder.bind(
+            view = view,
+            viewModel =
+                ViewModelProvider(
+                        requireActivity(),
+                        injector.getKeyguardQuickAffordancePickerViewModelFactory(requireContext()),
+                    )
+                    .get(),
+            lifecycleOwner = this,
+        )
+        return view
+    }
+
+    override fun getDefaultTitle(): CharSequence {
+        return requireContext().getString(R.string.keyguard_quick_affordance_title)
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt b/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt
new file mode 100644
index 0000000..6b35d7c
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.section
+
+import android.content.Context
+import android.view.LayoutInflater
+import androidx.lifecycle.LifecycleOwner
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+import com.android.customization.picker.quickaffordance.ui.binder.KeyguardQuickAffordanceSectionViewBinder
+import com.android.customization.picker.quickaffordance.ui.fragment.KeyguardQuickAffordancePickerFragment
+import com.android.customization.picker.quickaffordance.ui.view.KeyguardQuickAffordanceSectionView
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
+import com.android.wallpaper.R
+import com.android.wallpaper.model.CustomizationSectionController
+import com.android.wallpaper.model.CustomizationSectionController.CustomizationSectionNavigationController as NavigationController
+import kotlinx.coroutines.runBlocking
+
+class KeyguardQuickAffordanceSectionController(
+    private val navigationController: NavigationController,
+    private val interactor: KeyguardQuickAffordancePickerInteractor,
+    private val viewModel: KeyguardQuickAffordancePickerViewModel,
+    private val lifecycleOwner: LifecycleOwner,
+) : CustomizationSectionController<KeyguardQuickAffordanceSectionView> {
+
+    private val isFeatureEnabled: Boolean = runBlocking { interactor.isFeatureEnabled() }
+
+    override fun isAvailable(context: Context?): Boolean {
+        return isFeatureEnabled
+    }
+
+    override fun createView(context: Context?): KeyguardQuickAffordanceSectionView {
+        val view =
+            LayoutInflater.from(context)
+                .inflate(
+                    R.layout.keyguard_quick_affordance_section_view,
+                    null,
+                ) as KeyguardQuickAffordanceSectionView
+        KeyguardQuickAffordanceSectionViewBinder.bind(
+            view = view,
+            viewModel = viewModel,
+            lifecycleOwner = lifecycleOwner,
+        ) {
+            navigationController.navigateTo(KeyguardQuickAffordancePickerFragment.newInstance())
+        }
+        return view
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/view/KeyguardQuickAffordanceSectionView.kt b/src/com/android/customization/picker/quickaffordance/ui/view/KeyguardQuickAffordanceSectionView.kt
new file mode 100644
index 0000000..daace7d
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/view/KeyguardQuickAffordanceSectionView.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.view
+
+import android.content.Context
+import android.util.AttributeSet
+import com.android.wallpaper.picker.SectionView
+
+class KeyguardQuickAffordanceSectionView(
+    context: Context?,
+    attrs: AttributeSet?,
+) :
+    SectionView(
+        context,
+        attrs,
+    )
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
new file mode 100644
index 0000000..e69c639
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.viewmodel
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import androidx.annotation.DrawableRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderContract as Contract
+import com.android.wallpaper.R
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+/** Models UI state for a lock screen quick affordance picker experience. */
+@OptIn(ExperimentalCoroutinesApi::class)
+class KeyguardQuickAffordancePickerViewModel
+private constructor(
+    context: Context,
+    private val interactor: KeyguardQuickAffordancePickerInteractor,
+) : ViewModel() {
+
+    @SuppressLint("StaticFieldLeak") private val applicationContext = context.applicationContext
+
+    private val selectedSlotId = MutableStateFlow<String?>(null)
+
+    /** View-models for each slot, keyed by slot ID. */
+    val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
+        combine(
+            interactor.slots,
+            interactor.affordances,
+            interactor.selections,
+            selectedSlotId,
+        ) { slots, affordances, selections, selectedSlotIdOrNull ->
+            slots
+                .mapIndexed { index, 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 =
+                        (selectedSlotIdOrNull == null && index == 0) ||
+                            selectedSlotIdOrNull == slot.id
+                    slot.id to
+                        KeyguardQuickAffordanceSlotViewModel(
+                            name = getSlotName(slot.id),
+                            isSelected = isSelected,
+                            selectedQuickAffordances =
+                                selectedAffordances.map { affordanceModel ->
+                                    KeyguardQuickAffordanceViewModel(
+                                        icon = getAffordanceIcon(affordanceModel.iconResourceId),
+                                        contentDescription = affordanceModel.name,
+                                        isSelected = true,
+                                        onClicked = null,
+                                        isEnabled = affordanceModel.isEnabled,
+                                    )
+                                },
+                            maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
+                            onClicked =
+                                if (isSelected) {
+                                    null
+                                } else {
+                                    { this.selectedSlotId.value = slot.id }
+                                },
+                        )
+                }
+                .toMap()
+        }
+
+    /** The list of all available quick affordances for the selected slot. */
+    val quickAffordances: Flow<List<KeyguardQuickAffordanceViewModel>> =
+        combine(
+            interactor.slots,
+            interactor.affordances,
+            interactor.selections,
+            selectedSlotId,
+        ) { slots, affordances, selections, selectedSlotIdOrNull ->
+            val selectedSlot =
+                selectedSlotIdOrNull?.let { slots.find { slot -> slot.id == it } } ?: slots.first()
+            val selectedAffordanceIds =
+                selections
+                    .filter { selection -> selection.slotId == selectedSlot.id }
+                    .map { selection -> selection.affordanceId }
+                    .toSet()
+            listOf(
+                none(
+                    slotId = selectedSlot.id,
+                    isSelected = selectedAffordanceIds.isEmpty(),
+                )
+            ) +
+                affordances.map { affordance ->
+                    val isSelected = selectedAffordanceIds.contains(affordance.id)
+                    KeyguardQuickAffordanceViewModel(
+                        icon = getAffordanceIcon(affordance.iconResourceId),
+                        contentDescription = affordance.name,
+                        isSelected = isSelected,
+                        onClicked =
+                            if (affordance.isEnabled) {
+                                {
+                                    viewModelScope.launch {
+                                        if (isSelected) {
+                                            interactor.unselect(
+                                                slotId = selectedSlot.id,
+                                                affordanceId = affordance.id,
+                                            )
+                                        } else {
+                                            interactor.select(
+                                                slotId = selectedSlot.id,
+                                                affordanceId = affordance.id,
+                                            )
+                                        }
+                                    }
+                                }
+                            } else {
+                                {
+                                    showEnablementDialog(
+                                        instructionHeader =
+                                            affordance.enablementInstructions.first(),
+                                        instructions =
+                                            affordance.enablementInstructions.subList(
+                                                1,
+                                                affordance.enablementInstructions.size
+                                            ),
+                                        actionText = affordance.enablementActionText,
+                                        actionComponentName =
+                                            affordance.enablementActionComponentName,
+                                    )
+                                }
+                            },
+                        isEnabled = affordance.isEnabled,
+                    )
+                }
+        }
+
+    @SuppressLint("UseCompatLoadingForDrawables")
+    val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> =
+        slots.map { slots ->
+            val icon2 =
+                slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
+                    ?.selectedQuickAffordances
+                    ?.firstOrNull()
+                    ?.icon
+            val icon1 =
+                slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
+                    ?.selectedQuickAffordances
+                    ?.firstOrNull()
+                    ?.icon
+
+            val isIconSpacingVisible = icon1 != null && icon2 != null
+            KeyguardQuickAffordanceSummaryViewModel(
+                description = toDescriptionText(context, slots),
+                icon1 = icon1
+                        ?: if (icon2 == null) {
+                            context.getDrawable(R.drawable.link_off)
+                        } else {
+                            null
+                        },
+                icon2 = icon2,
+                isIconSpacingVisible = isIconSpacingVisible,
+            )
+        }
+
+    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()
+
+    /** Notifies that the dialog has been dismissed in the UI. */
+    fun onDialogDismissed() {
+        _dialog.value = null
+    }
+
+    private fun showEnablementDialog(
+        instructionHeader: String,
+        instructions: List<String>,
+        actionText: String?,
+        actionComponentName: String?,
+    ) {
+        _dialog.value =
+            DialogViewModel(
+                instructionHeader = instructionHeader,
+                instructions = instructions,
+                actionText = actionText
+                        ?: applicationContext.getString(
+                            R.string.keyguard_affordance_enablement_dialog_dismiss_button
+                        ),
+                intent = actionComponentName.toIntent(),
+            )
+    }
+
+    @SuppressLint("UseCompatLoadingForDrawables")
+    private fun none(
+        slotId: String,
+        isSelected: Boolean,
+    ): KeyguardQuickAffordanceViewModel {
+        return KeyguardQuickAffordanceViewModel.none(
+            context = applicationContext,
+            isSelected = isSelected,
+            onSelected = { viewModelScope.launch { interactor.unselectAll(slotId) } },
+        )
+    }
+
+    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 suspend fun getAffordanceIcon(@DrawableRes iconResourceId: Int): Drawable {
+        return interactor.getAffordanceIcon(iconResourceId)
+    }
+
+    private fun String?.toIntent(): Intent? {
+        if (isNullOrEmpty()) {
+            return null
+        }
+
+        val splitUp = split(Contract.AffordanceTable.COMPONENT_NAME_SEPARATOR)
+        check(splitUp.size == 1 || splitUp.size == 2) {
+            "Illegal component name \"$this\". Must be either just an action or a package and an" +
+                " action separated by a" +
+                " \"${Contract.AffordanceTable.COMPONENT_NAME_SEPARATOR}\"!"
+        }
+
+        return Intent(splitUp.last()).apply {
+            if (splitUp.size > 1) {
+                setPackage(splitUp[0])
+            }
+        }
+    }
+
+    /** Encapsulates a request to show a dialog. */
+    data class DialogViewModel(
+        /** The header for the instructions section. */
+        val instructionHeader: String,
+
+        /** The set of instructions to show below the header. */
+        val instructions: List<String>,
+
+        /** Label for the dialog button. */
+        val actionText: String,
+
+        /**
+         * Optional [Intent] to use to start an activity when the dialog button is clicked. If
+         * `null`, the dialog should be dismissed.
+         */
+        val intent: Intent?,
+    )
+
+    private fun toDescriptionText(
+        context: Context,
+        slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
+    ): String {
+        val bottomStartAffordanceName =
+            slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
+                ?.selectedQuickAffordances
+                ?.firstOrNull()
+                ?.contentDescription
+        val bottomEndAffordanceName =
+            slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
+                ?.selectedQuickAffordances
+                ?.firstOrNull()
+                ?.contentDescription
+
+        return when {
+            !bottomStartAffordanceName.isNullOrEmpty() &&
+                !bottomEndAffordanceName.isNullOrEmpty() -> {
+                context.getString(
+                    R.string.keyguard_quick_affordance_two_selected_template,
+                    bottomStartAffordanceName,
+                    bottomEndAffordanceName,
+                )
+            }
+            !bottomStartAffordanceName.isNullOrEmpty() -> bottomStartAffordanceName
+            !bottomEndAffordanceName.isNullOrEmpty() -> bottomEndAffordanceName
+            else -> context.getString(R.string.keyguard_quick_affordance_none_selected)
+        }
+    }
+
+    class Factory(
+        private val context: Context,
+        private val interactor: KeyguardQuickAffordancePickerInteractor,
+    ) : ViewModelProvider.Factory {
+        override fun <T : ViewModel> create(modelClass: Class<T>): T {
+            @Suppress("UNCHECKED_CAST")
+            return KeyguardQuickAffordancePickerViewModel(
+                context = context,
+                interactor = interactor,
+            )
+                as T
+        }
+    }
+}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
new file mode 100644
index 0000000..bb9b29b
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.viewmodel
+
+/** Models UI state for a single lock screen quick affordance slot in a picker experience. */
+data class KeyguardQuickAffordanceSlotViewModel(
+    /** User-visible name for the slot. */
+    val name: String,
+
+    /** Whether this is the currently-selected slot in the picker. */
+    val isSelected: Boolean,
+
+    /**
+     * The list of quick affordances selected for this slot.
+     *
+     * Useful for preview.
+     */
+    val selectedQuickAffordances: List<KeyguardQuickAffordanceViewModel>,
+
+    /**
+     * The maximum number of quick affordances that can be selected for this slot.
+     *
+     * Useful for picker and preview.
+     */
+    val maxSelectedQuickAffordances: Int,
+
+    /** Notifies that the slot has been clicked by the user. */
+    val onClicked: (() -> Unit)?,
+)
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
new file mode 100644
index 0000000..3860a9f
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+
+data class KeyguardQuickAffordanceSummaryViewModel(
+    val description: String,
+    val icon1: Drawable?,
+    val icon2: Drawable?,
+    val isIconSpacingVisible: Boolean,
+)
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
new file mode 100644
index 0000000..d720b0c
--- /dev/null
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2022 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.quickaffordance.ui.viewmodel
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.android.wallpaper.R
+
+/** Models UI state for a single lock screen quick affordance in a picker experience. */
+data class KeyguardQuickAffordanceViewModel(
+    /** An icon for the quick affordance. */
+    val icon: Drawable,
+
+    /** A content description for the icon. */
+    val contentDescription: String,
+
+    /** Whether this quick affordance is selected in its slot. */
+    val isSelected: Boolean,
+
+    /** Whether this quick affordance is enabled. */
+    val isEnabled: Boolean,
+
+    /** Notifies that the quick affordance has been clicked by the user. */
+    val onClicked: (() -> Unit)?,
+) {
+    companion object {
+        @SuppressLint("UseCompatLoadingForDrawables")
+        fun none(
+            context: Context,
+            isSelected: Boolean,
+            onSelected: () -> Unit,
+        ): KeyguardQuickAffordanceViewModel {
+            return KeyguardQuickAffordanceViewModel(
+                icon = checkNotNull(context.getDrawable(R.drawable.link_off)),
+                contentDescription = context.getString(R.string.keyguard_affordance_none),
+                isSelected = isSelected,
+                onClicked =
+                    if (isSelected) {
+                        null
+                    } else {
+                        onSelected
+                    },
+                isEnabled = true,
+            )
+        }
+    }
+}
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepositoryTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepositoryTest.kt
new file mode 100644
index 0000000..4a88f3b
--- /dev/null
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepositoryTest.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2022 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.model.picker.quickaffordance.data.repository
+
+import androidx.test.filters.SmallTest
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.systemui.shared.quickaffordance.data.content.FakeKeyguardQuickAffordanceProviderClient
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class KeyguardQuickAffordancePickerRepositoryTest {
+
+    private lateinit var underTest: KeyguardQuickAffordancePickerRepository
+
+    private lateinit var testScope: TestScope
+    private lateinit var client: FakeKeyguardQuickAffordanceProviderClient
+
+    @Before
+    fun setUp() {
+        client = FakeKeyguardQuickAffordanceProviderClient()
+        val coroutineDispatcher = UnconfinedTestDispatcher()
+        testScope = TestScope(coroutineDispatcher)
+        Dispatchers.setMain(coroutineDispatcher)
+
+        underTest =
+            KeyguardQuickAffordancePickerRepository(
+                client = client,
+                backgroundDispatcher = coroutineDispatcher,
+            )
+    }
+
+    @After
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun `isFeatureEnabled - enabled`() =
+        testScope.runTest {
+            client.setFlag(
+                com.android.systemui.shared.quickaffordance.data.content
+                    .KeyguardQuickAffordanceProviderContract
+                    .FlagsTable
+                    .FLAG_NAME_FEATURE_ENABLED,
+                true,
+            )
+            val values = mutableListOf<Boolean>()
+            val job = launch { underTest.isFeatureEnabled.toList(values) }
+
+            assertThat(values.last()).isTrue()
+
+            job.cancel()
+        }
+
+    @Test
+    fun `isFeatureEnabled - not enabled`() =
+        testScope.runTest {
+            client.setFlag(
+                com.android.systemui.shared.quickaffordance.data.content
+                    .KeyguardQuickAffordanceProviderContract
+                    .FlagsTable
+                    .FLAG_NAME_FEATURE_ENABLED,
+                false,
+            )
+            val values = mutableListOf<Boolean>()
+            val job = launch { underTest.isFeatureEnabled.toList(values) }
+
+            assertThat(values.last()).isFalse()
+
+            job.cancel()
+        }
+}
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
new file mode 100644
index 0000000..d8a136d
--- /dev/null
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2022 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.model.picker.quickaffordance.domain.interactor
+
+import androidx.test.filters.SmallTest
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+import com.android.customization.picker.quickaffordance.shared.model.KeyguardQuickAffordancePickerSelectionModel
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.systemui.shared.quickaffordance.data.content.FakeKeyguardQuickAffordanceProviderClient
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class KeyguardQuickAffordancePickerInteractorTest {
+
+    private lateinit var underTest: KeyguardQuickAffordancePickerInteractor
+
+    private lateinit var testScope: TestScope
+    private lateinit var client: FakeKeyguardQuickAffordanceProviderClient
+
+    @Before
+    fun setUp() {
+        val coroutineDispatcher = UnconfinedTestDispatcher()
+        testScope = TestScope(coroutineDispatcher)
+        Dispatchers.setMain(coroutineDispatcher)
+        client = FakeKeyguardQuickAffordanceProviderClient()
+        underTest =
+            KeyguardQuickAffordancePickerInteractor(
+                repository =
+                    KeyguardQuickAffordancePickerRepository(
+                        client = client,
+                        backgroundDispatcher = coroutineDispatcher,
+                    ),
+                client = client,
+            )
+    }
+
+    @After
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun select() =
+        testScope.runTest {
+            val selections = mutableListOf<List<KeyguardQuickAffordancePickerSelectionModel>>()
+            val job = launch { underTest.selections.toList(selections) }
+
+            underTest.select(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+            )
+            assertThat(selections.last())
+                .isEqualTo(
+                    listOf(
+                        KeyguardQuickAffordancePickerSelectionModel(
+                            slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                            affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+                        ),
+                    )
+                )
+
+            underTest.select(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_2,
+            )
+            assertThat(selections.last())
+                .isEqualTo(
+                    listOf(
+                        KeyguardQuickAffordancePickerSelectionModel(
+                            slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                            affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_2,
+                        ),
+                    )
+                )
+
+            job.cancel()
+        }
+
+    @Test
+    fun unselect() =
+        testScope.runTest {
+            val selections = mutableListOf<List<KeyguardQuickAffordancePickerSelectionModel>>()
+            val job = launch { underTest.selections.toList(selections) }
+            underTest.select(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+            )
+
+            underTest.unselect(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+            )
+
+            assertThat(selections.last()).isEmpty()
+
+            job.cancel()
+        }
+
+    @Test
+    fun unselectAll() =
+        testScope.runTest {
+            client.setSlotCapacity(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, 3)
+            val selections = mutableListOf<List<KeyguardQuickAffordancePickerSelectionModel>>()
+            val job = launch { underTest.selections.toList(selections) }
+            underTest.select(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+            )
+            underTest.select(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_2,
+            )
+            underTest.select(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+                affordanceId = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_3,
+            )
+
+            underTest.unselectAll(
+                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+            )
+
+            assertThat(selections.last()).isEmpty()
+
+            job.cancel()
+        }
+}
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
new file mode 100644
index 0000000..756ffb4
--- /dev/null
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
@@ -0,0 +1,461 @@
+/*
+ * Copyright (C) 2022 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.model.picker.quickaffordance.ui.viewmodel
+
+import android.content.Context
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel
+import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.systemui.shared.quickaffordance.data.content.FakeKeyguardQuickAffordanceProviderClient
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClient
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class KeyguardQuickAffordancePickerViewModelTest {
+
+    private lateinit var underTest: KeyguardQuickAffordancePickerViewModel
+
+    private lateinit var context: Context
+    private lateinit var testScope: TestScope
+    private lateinit var client: FakeKeyguardQuickAffordanceProviderClient
+
+    @Before
+    fun setUp() {
+        context = InstrumentationRegistry.getInstrumentation().targetContext
+        val coroutineDispatcher = UnconfinedTestDispatcher()
+        testScope = TestScope(coroutineDispatcher)
+        Dispatchers.setMain(coroutineDispatcher)
+        client = FakeKeyguardQuickAffordanceProviderClient()
+
+        underTest =
+            KeyguardQuickAffordancePickerViewModel.Factory(
+                    context = context,
+                    interactor =
+                        KeyguardQuickAffordancePickerInteractor(
+                            repository =
+                                KeyguardQuickAffordancePickerRepository(
+                                    client = client,
+                                    backgroundDispatcher = coroutineDispatcher,
+                                ),
+                            client = client,
+                        ),
+                )
+                .create(KeyguardQuickAffordancePickerViewModel::class.java)
+    }
+
+    @After
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun `Select an affordance for each side`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+            }
+
+            // Initially, the first slot is selected with the "none" affordance selected.
+            assertPickerUiState(
+                slots = slots.last(),
+                affordances = quickAffordances.last(),
+                selectedSlotText = "Left button",
+                selectedAffordanceText = "None",
+            )
+            assertPreviewUiState(
+                slots = slots.last(),
+                expectedAffordanceNameBySlotId =
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to null,
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to null,
+                    ),
+            )
+
+            // Select "affordance 1" for the first slot.
+            quickAffordances.last()[1].onClicked?.invoke()
+            assertPickerUiState(
+                slots = slots.last(),
+                affordances = quickAffordances.last(),
+                selectedSlotText = "Left button",
+                selectedAffordanceText = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+            )
+            assertPreviewUiState(
+                slots = slots.last(),
+                expectedAffordanceNameBySlotId =
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to
+                            FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to null,
+                    ),
+            )
+
+            // Select an affordance for the second slot.
+            // First, switch to the second slot:
+            slots.last()[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]?.onClicked?.invoke()
+            // Second, select the "affordance 3" affordance:
+            quickAffordances.last()[3].onClicked?.invoke()
+            assertPickerUiState(
+                slots = slots.last(),
+                affordances = quickAffordances.last(),
+                selectedSlotText = "Right button",
+                selectedAffordanceText = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_3,
+            )
+            assertPreviewUiState(
+                slots = slots.last(),
+                expectedAffordanceNameBySlotId =
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to
+                            FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to
+                            FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_3,
+                    ),
+            )
+
+            // Select a different affordance for the second slot.
+            quickAffordances.last()[2].onClicked?.invoke()
+            assertPickerUiState(
+                slots = slots.last(),
+                affordances = quickAffordances.last(),
+                selectedSlotText = "Right button",
+                selectedAffordanceText = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_2,
+            )
+            assertPreviewUiState(
+                slots = slots.last(),
+                expectedAffordanceNameBySlotId =
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to
+                            FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to
+                            FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_2,
+                    ),
+            )
+
+            jobs.forEach { it.cancel() }
+        }
+
+    @Test
+    fun `Unselect - AKA selecting the none affordance - on one side`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+            }
+
+            // Select "affordance 1" for the first slot.
+            quickAffordances.last()[1].onClicked?.invoke()
+            // Select an affordance for the second slot.
+            // First, switch to the second slot:
+            slots.last()[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]?.onClicked?.invoke()
+            // Second, select the "affordance 3" affordance:
+            quickAffordances.last()[3].onClicked?.invoke()
+
+            // Switch back to the first slot:
+            slots.last()[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]?.onClicked?.invoke()
+            // Select the "none" affordance, which is always in position 0:
+            quickAffordances.last()[0].onClicked?.invoke()
+
+            assertPickerUiState(
+                slots = slots.last(),
+                affordances = quickAffordances.last(),
+                selectedSlotText = "Left button",
+                selectedAffordanceText = "None",
+            )
+            assertPreviewUiState(
+                slots = slots.last(),
+                expectedAffordanceNameBySlotId =
+                    mapOf(
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to null,
+                        KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to
+                            FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_3,
+                    ),
+            )
+
+            jobs.forEach { it.cancel() }
+        }
+
+    @Test
+    fun `Show enablement dialog when selecting a disabled affordance`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+            val dialog = mutableListOf<KeyguardQuickAffordancePickerViewModel.DialogViewModel?>()
+
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+                add(launch { underTest.dialog.toList(dialog) })
+            }
+            val enablementInstructions = listOf("header", "enablementInstructions")
+            val enablementActionText = "enablementActionText"
+            val packageName = "packageName"
+            val action = "action"
+            val enablementActionComponentName = "$packageName/$action"
+            // Lets add a disabled affordance to the picker:
+            val affordanceIndex =
+                client.addAffordance(
+                    KeyguardQuickAffordanceProviderClient.Affordance(
+                        id = "disabled",
+                        name = "disabled",
+                        iconResourceId = 0,
+                        isEnabled = false,
+                        enablementInstructions = enablementInstructions,
+                        enablementActionText = enablementActionText,
+                        enablementActionComponentName = enablementActionComponentName,
+                    )
+                )
+
+            // Lets try to select that disabled affordance:
+            quickAffordances.last()[affordanceIndex + 1].onClicked?.invoke()
+
+            // We expect there to be a dialog that should be shown:
+            assertThat(dialog.last()?.instructionHeader).isEqualTo(enablementInstructions[0])
+            assertThat(dialog.last()?.instructions)
+                .isEqualTo(enablementInstructions.subList(1, enablementInstructions.size))
+            assertThat(dialog.last()?.actionText).isEqualTo(enablementActionText)
+            assertThat(dialog.last()?.intent?.`package`).isEqualTo(packageName)
+            assertThat(dialog.last()?.intent?.action).isEqualTo(action)
+
+            // Once we report that the dialog has been dismissed by the user, we expect there to be
+            // no
+            // dialog to be shown:
+            underTest.onDialogDismissed()
+            assertThat(dialog.last()).isNull()
+
+            jobs.forEach { it.cancel() }
+        }
+
+    @Test
+    fun `summary - affordance selected in both bottom-start and bottom-end`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+            val summary = mutableListOf<KeyguardQuickAffordanceSummaryViewModel>()
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+                add(launch { underTest.summary.toList(summary) })
+            }
+
+            // Select "affordance 1" for the first slot.
+            quickAffordances.last()[1].onClicked?.invoke()
+            // Select an affordance for the second slot.
+            // First, switch to the second slot:
+            slots.last()[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]?.onClicked?.invoke()
+            // Second, select the "affordance 3" affordance:
+            quickAffordances.last()[3].onClicked?.invoke()
+
+            assertThat(summary.last())
+                .isEqualTo(
+                    KeyguardQuickAffordanceSummaryViewModel(
+                        description =
+                            "${FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1}," +
+                                " ${FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_3}",
+                        icon1 = FakeKeyguardQuickAffordanceProviderClient.ICON_1,
+                        icon2 = FakeKeyguardQuickAffordanceProviderClient.ICON_3,
+                        isIconSpacingVisible = true,
+                    )
+                )
+            jobs.forEach { it.cancel() }
+        }
+
+    @Test
+    fun `summary - affordance selected only on bottom-start`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+            val summary = mutableListOf<KeyguardQuickAffordanceSummaryViewModel>()
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+                add(launch { underTest.summary.toList(summary) })
+            }
+
+            // Select "affordance 1" for the first slot.
+            quickAffordances.last()[1].onClicked?.invoke()
+
+            assertThat(summary.last())
+                .isEqualTo(
+                    KeyguardQuickAffordanceSummaryViewModel(
+                        description = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_1,
+                        icon1 = FakeKeyguardQuickAffordanceProviderClient.ICON_1,
+                        icon2 = null,
+                        isIconSpacingVisible = false,
+                    )
+                )
+            jobs.forEach { it.cancel() }
+        }
+
+    @Test
+    fun `summary - affordance selected only on bottom-end`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+            val summary = mutableListOf<KeyguardQuickAffordanceSummaryViewModel>()
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+                add(launch { underTest.summary.toList(summary) })
+            }
+
+            // Select an affordance for the second slot.
+            // First, switch to the second slot:
+            slots.last()[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]?.onClicked?.invoke()
+            // Second, select the "affordance 3" affordance:
+            quickAffordances.last()[3].onClicked?.invoke()
+
+            assertThat(summary.last())
+                .isEqualTo(
+                    KeyguardQuickAffordanceSummaryViewModel(
+                        description = FakeKeyguardQuickAffordanceProviderClient.AFFORDANCE_3,
+                        icon1 = null,
+                        icon2 = FakeKeyguardQuickAffordanceProviderClient.ICON_3,
+                        isIconSpacingVisible = false,
+                    )
+                )
+            jobs.forEach { it.cancel() }
+        }
+
+    @Test
+    fun `summary - no affordances selected`() =
+        testScope.runTest {
+            val slots = mutableListOf<Map<String, KeyguardQuickAffordanceSlotViewModel>>()
+            val quickAffordances = mutableListOf<List<KeyguardQuickAffordanceViewModel>>()
+            val summary = mutableListOf<KeyguardQuickAffordanceSummaryViewModel>()
+            val jobs = buildList {
+                add(launch { underTest.slots.toList(slots) })
+                add(launch { underTest.quickAffordances.toList(quickAffordances) })
+                add(launch { underTest.summary.toList(summary) })
+            }
+
+            assertThat(summary.last().description).isEqualTo("None")
+            assertThat(summary.last().icon1).isNotNull()
+            assertThat(summary.last().icon2).isNull()
+            assertThat(summary.last().isIconSpacingVisible).isFalse()
+            jobs.forEach { it.cancel() }
+        }
+
+    /**
+     * Asserts the entire picker UI state is what is expected. This includes the slot tabs and the
+     * affordance list.
+     *
+     * @param slots The observed slot view-models, keyed by slot ID
+     * @param affordances The observed affordances
+     * @param selectedSlotText The text of the slot that's expected to be selected
+     * @param selectedAffordanceText The text of the affordance that's expected to be selected
+     */
+    private fun assertPickerUiState(
+        slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
+        affordances: List<KeyguardQuickAffordanceViewModel>,
+        selectedSlotText: String,
+        selectedAffordanceText: String,
+    ) {
+        assertSlotTabUiState(
+            slots = slots,
+            slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
+            isSelected = "Left button" == selectedSlotText,
+        )
+        assertSlotTabUiState(
+            slots = slots,
+            slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END,
+            isSelected = "Right button" == selectedSlotText,
+        )
+
+        var foundSelectedAffordance = false
+        affordances.forEach { affordance ->
+            val nameMatchesSelectedName = affordance.contentDescription == selectedAffordanceText
+            assertWithMessage(
+                    "Expected affordance with name \"${affordance.contentDescription}\" to have" +
+                        " isSelected=$nameMatchesSelectedName but it was ${affordance.isSelected}"
+                )
+                .that(affordance.isSelected)
+                .isEqualTo(nameMatchesSelectedName)
+            foundSelectedAffordance = foundSelectedAffordance || nameMatchesSelectedName
+        }
+        assertWithMessage("No affordance is selected!").that(foundSelectedAffordance).isTrue()
+    }
+
+    /**
+     * Asserts that a slot tab has the correct UI state.
+     *
+     * @param slots The observed slot view-models, keyed by slot ID
+     * @param slotId the ID of the slot to assert
+     * @param isSelected Whether that slot should be selected
+     */
+    private fun assertSlotTabUiState(
+        slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
+        slotId: String,
+        isSelected: Boolean,
+    ) {
+        val viewModel = slots[slotId] ?: error("No slot with ID \"$slotId\"!")
+        assertThat(viewModel.isSelected).isEqualTo(isSelected)
+    }
+
+    /**
+     * Asserts the UI state of the preview.
+     *
+     * @param slots The observed slot view-models, keyed by slot ID
+     * @param expectedAffordanceNameBySlotId The expected name of the selected affordance for each
+     * slot ID or `null` if it's expected for there to be no affordance for that slot in the preview
+     */
+    private fun assertPreviewUiState(
+        slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
+        expectedAffordanceNameBySlotId: Map<String, String?>,
+    ) {
+        slots.forEach { (slotId, slotViewModel) ->
+            val expectedAffordanceName = expectedAffordanceNameBySlotId[slotId]
+            val actualAffordanceName =
+                slotViewModel.selectedQuickAffordances.firstOrNull()?.contentDescription
+            assertWithMessage(
+                    "At slotId=\"$slotId\", expected affordance=\"$expectedAffordanceName\" but" +
+                        " was \"$actualAffordanceName\"!"
+                )
+                .that(actualAffordanceName)
+                .isEqualTo(expectedAffordanceName)
+        }
+    }
+}
diff --git a/tests/src/com/android/customization/testing/TestCustomizationInjector.java b/tests/src/com/android/customization/testing/TestCustomizationInjector.java
index dbbdb74..15898c1 100644
--- a/tests/src/com/android/customization/testing/TestCustomizationInjector.java
+++ b/tests/src/com/android/customization/testing/TestCustomizationInjector.java
@@ -10,11 +10,18 @@
 import com.android.customization.module.CustomizationInjector;
 import com.android.customization.module.CustomizationPreferences;
 import com.android.customization.module.ThemesUserEventLogger;
+import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository;
+import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor;
+import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel;
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClient;
+import com.android.systemui.shared.quickaffordance.data.content.KeyguardQuickAffordanceProviderClientImpl;
 import com.android.wallpaper.module.DrawableLayerResolver;
 import com.android.wallpaper.module.PackageStatusNotifier;
 import com.android.wallpaper.module.UserEventLogger;
 import com.android.wallpaper.testing.TestInjector;
 
+import kotlinx.coroutines.Dispatchers;
+
 /**
  * Test implementation of the dependency injector.
  */
@@ -24,6 +31,9 @@
     private PackageStatusNotifier mPackageStatusNotifier;
     private DrawableLayerResolver mDrawableLayerResolver;
     private UserEventLogger mUserEventLogger;
+    private KeyguardQuickAffordancePickerInteractor mKeyguardQuickAffordancePickerInteractor;
+    private KeyguardQuickAffordancePickerViewModel.Factory
+            mKeyguardQuickAffordancePickerViewModelFactory;
 
     @Override
     public CustomizationPreferences getCustomizationPreferences(Context context) {
@@ -68,4 +78,17 @@
         }
         return mUserEventLogger;
     }
+
+    @Override
+    public KeyguardQuickAffordancePickerInteractor getKeyguardQuickAffordancePickerInteractor(
+            Context context) {
+        if (mKeyguardQuickAffordancePickerInteractor == null) {
+            final KeyguardQuickAffordanceProviderClient client =
+                    new KeyguardQuickAffordanceProviderClientImpl(context, Dispatchers.getIO());
+            mKeyguardQuickAffordancePickerInteractor = new KeyguardQuickAffordancePickerInteractor(
+                    new KeyguardQuickAffordancePickerRepository(client, Dispatchers.getIO()),
+                    client);
+        }
+        return mKeyguardQuickAffordancePickerInteractor;
+    }
 }