Merge "Quick affordance picker in ThemePicker." into tm-qpr-dev am: ad413fcfdd

Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/apps/ThemePicker/+/20561260

Change-Id: Ie59fb04c4a058908a092950e3b080a39c156cb46
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
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/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/keyguard_quick_affordance.xml b/res/layout/keyguard_quick_affordance.xml
new file mode 100644
index 0000000..b8efc73
--- /dev/null
+++ b/res/layout/keyguard_quick_affordance.xml
@@ -0,0 +1,44 @@
+<?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="@dimen/keyguard_quick_affordance_picker_item_width"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:gravity="center_horizontal">
+
+    <ImageView
+        android:id="@+id/icon"
+        android:layout_width="48dp"
+        android:layout_height="48dp"
+        android:background="@drawable/selectable" />
+
+    <View
+        android:layout_width="0dp"
+        android:layout_height="16dp" />
+
+    <TextView
+        android:id="@+id/name"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textColor="@android:color/white"
+        android:singleLine="true"
+        android:ellipsize="end"/>
+
+</LinearLayout>
\ 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..c2560cb
--- /dev/null
+++ b/res/layout/keyguard_quick_affordance_slot_tab.xml
@@ -0,0 +1,28 @@
+<?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="#fff"
+    android:padding="8dp"
+    android:minWidth="48dp"
+    android:minHeight="48dp"
+    android:gravity="center"
+    android:background="@drawable/selectable" />
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 0ad221e..9766887 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -135,4 +135,10 @@
     <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 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">64dp</dimen>
 </resources>
diff --git a/res/values/ids.xml b/res/values/ids.xml
new file mode 100644
index 0000000..bb94c03
--- /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>
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 7aef401..f645dc4 100755
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -267,4 +267,50 @@
     <!-- 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>
 </resources>
diff --git a/robolectric_tests/Android.mk b/robolectric_tests/Android.mk
index f5ab901..5ad6458 100644
--- a/robolectric_tests/Android.mk
+++ b/robolectric_tests/Android.mk
@@ -28,7 +28,9 @@
     androidx.test.runner \
     androidx.test.rules \
     mockito-robolectric-prebuilt \
-    truth-prebuilt
+    truth-prebuilt \
+    kotlinx_coroutines_test
+
 LOCAL_JAVA_LIBRARIES := \
     platform-robolectric-4.8.2-prebuilt
 
diff --git a/robolectric_tests/src/com/android/customization/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepositoryTest.kt b/robolectric_tests/src/com/android/customization/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepositoryTest.kt
new file mode 100644
index 0000000..771fd3b
--- /dev/null
+++ b/robolectric_tests/src/com/android/customization/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepositoryTest.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.quickaffordance.data.repository
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.shared.quickaffordance.data.content.FakeKeyguardQuickAffordanceProviderClient
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+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 client: FakeKeyguardQuickAffordanceProviderClient
+
+    @Before
+    fun setUp() {
+        client = FakeKeyguardQuickAffordanceProviderClient()
+
+        underTest =
+            KeyguardQuickAffordancePickerRepository(
+                client = client,
+            )
+    }
+
+    @Test
+    fun `isFeatureEnabled - enabled`() = runTest {
+        client.setFlag(
+            com.android.systemui.shared.quickaffordance.data.content
+                .KeyguardQuickAffordanceProviderContract
+                .FlagsTable
+                .FLAG_NAME_FEATURE_ENABLED,
+            true,
+        )
+        val values = mutableListOf<Boolean>()
+        val job = launch(UnconfinedTestDispatcher()) { underTest.isFeatureEnabled.toList(values) }
+
+        assertThat(values.last()).isTrue()
+
+        job.cancel()
+    }
+
+    @Test
+    fun `isFeatureEnabled - not enabled`() = runTest {
+        client.setFlag(
+            com.android.systemui.shared.quickaffordance.data.content
+                .KeyguardQuickAffordanceProviderContract
+                .FlagsTable
+                .FLAG_NAME_FEATURE_ENABLED,
+            false,
+        )
+        val values = mutableListOf<Boolean>()
+        val job = launch(UnconfinedTestDispatcher()) { underTest.isFeatureEnabled.toList(values) }
+
+        assertThat(values.last()).isFalse()
+
+        job.cancel()
+    }
+}
diff --git a/robolectric_tests/src/com/android/customization/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt b/robolectric_tests/src/com/android/customization/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
new file mode 100644
index 0000000..227fd6a
--- /dev/null
+++ b/robolectric_tests/src/com/android/customization/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
@@ -0,0 +1,156 @@
+/*
+ * 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.quickaffordance.domain.interactor
+
+import androidx.test.filters.SmallTest
+import com.android.customization.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.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,
+                    ),
+                client = client,
+            )
+    }
+
+    @After
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun select() =
+        testScope.runTest {
+            val selections = mutableListOf<List<KeyguardQuickAffordancePickerSelectionModel>>()
+            val job = launch(UnconfinedTestDispatcher()) { 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(UnconfinedTestDispatcher()) { 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(UnconfinedTestDispatcher()) { 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/robolectric_tests/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt b/robolectric_tests/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
new file mode 100644
index 0000000..126b22b
--- /dev/null
+++ b/robolectric_tests/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
@@ -0,0 +1,356 @@
+/*
+ * 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.quickaffordance.ui.viewmodel
+
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.customization.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
+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 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 testScope: TestScope
+    private lateinit var client: FakeKeyguardQuickAffordanceProviderClient
+
+    @Before
+    fun setUp() {
+        val coroutineDispatcher = UnconfinedTestDispatcher()
+        testScope = TestScope(coroutineDispatcher)
+        Dispatchers.setMain(coroutineDispatcher)
+        client = FakeKeyguardQuickAffordanceProviderClient()
+
+        underTest =
+            KeyguardQuickAffordancePickerViewModel.Factory(
+                    context = InstrumentationRegistry.getInstrumentation().targetContext,
+                    interactor =
+                        KeyguardQuickAffordancePickerInteractor(
+                            repository =
+                                KeyguardQuickAffordancePickerRepository(
+                                    client = client,
+                                ),
+                            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(UnconfinedTestDispatcher()) { underTest.slots.toList(slots) })
+                add(
+                    launch(UnconfinedTestDispatcher()) {
+                        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(UnconfinedTestDispatcher()) { underTest.slots.toList(slots) })
+                add(
+                    launch(UnconfinedTestDispatcher()) {
+                        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(UnconfinedTestDispatcher()) { underTest.slots.toList(slots) })
+                add(
+                    launch(UnconfinedTestDispatcher()) {
+                        underTest.quickAffordances.toList(quickAffordances)
+                    }
+                )
+                add(launch(UnconfinedTestDispatcher()) { 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(
+                    com.android.systemui.shared.quickaffordance.data.content
+                        .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() }
+        }
+
+    /**
+     * 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/src/com/android/customization/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt b/src/com/android/customization/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt
new file mode 100644
index 0000000..480e113
--- /dev/null
+++ b/src/com/android/customization/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.quickaffordance.data.repository
+
+import com.android.customization.quickaffordance.shared.model.KeyguardQuickAffordancePickerAffordanceModel as AffordanceModel
+import com.android.customization.quickaffordance.shared.model.KeyguardQuickAffordancePickerSelectionModel as SelectionModel
+import com.android.customization.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.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * Abstracts access to application state related to functionality for selecting, picking, or setting
+ * lock screen quick affordances.
+ */
+class KeyguardQuickAffordancePickerRepository(
+    client: Client,
+) {
+    /** Whether the feature is enabled. */
+    val isFeatureEnabled: Flow<Boolean> =
+        client.observeFlags().map { flags ->
+            flags
+                .find { flag -> flag.name == Contract.FlagsTable.FLAG_NAME_FEATURE_ENABLED }
+                ?.value == true
+        }
+
+    /** 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() }
+        }
+
+    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/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt b/src/com/android/customization/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
new file mode 100644
index 0000000..f60fc12
--- /dev/null
+++ b/src/com/android/customization/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.quickaffordance.domain.interactor
+
+import android.graphics.drawable.Drawable
+import androidx.annotation.DrawableRes
+import com.android.customization.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository
+import com.android.customization.quickaffordance.shared.model.KeyguardQuickAffordancePickerAffordanceModel as AffordanceModel
+import com.android.customization.quickaffordance.shared.model.KeyguardQuickAffordancePickerSelectionModel as SelectionModel
+import com.android.customization.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(
+    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)
+    }
+}
diff --git a/src/com/android/customization/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt b/src/com/android/customization/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt
new file mode 100644
index 0000000..0a61cc2
--- /dev/null
+++ b/src/com/android/customization/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.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/quickaffordance/shared/model/KeyguardQuickAffordancePickerSelectionModel.kt b/src/com/android/customization/quickaffordance/shared/model/KeyguardQuickAffordancePickerSelectionModel.kt
new file mode 100644
index 0000000..d72bc71
--- /dev/null
+++ b/src/com/android/customization/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.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/quickaffordance/shared/model/KeyguardQuickAffordancePickerSlotModel.kt b/src/com/android/customization/quickaffordance/shared/model/KeyguardQuickAffordancePickerSlotModel.kt
new file mode 100644
index 0000000..7044e06
--- /dev/null
+++ b/src/com/android/customization/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.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/quickaffordance/ui/adapter/AffordancesAdapter.kt b/src/com/android/customization/quickaffordance/ui/adapter/AffordancesAdapter.kt
new file mode 100644
index 0000000..f63fa7a
--- /dev/null
+++ b/src/com/android/customization/quickaffordance/ui/adapter/AffordancesAdapter.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.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.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 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.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/quickaffordance/ui/adapter/SlotTabAdapter.kt b/src/com/android/customization/quickaffordance/ui/adapter/SlotTabAdapter.kt
new file mode 100644
index 0000000..953b632
--- /dev/null
+++ b/src/com/android/customization/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.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.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/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt b/src/com/android/customization/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
new file mode 100644
index 0000000..4486e86
--- /dev/null
+++ b/src/com/android/customization/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.quickaffordance.ui.binder
+
+import android.app.AlertDialog
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.view.View
+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.quickaffordance.ui.adapter.AffordancesAdapter
+import com.android.customization.quickaffordance.ui.adapter.SlotTabAdapter
+import com.android.customization.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)
+        val affordancesAdapter = AffordancesAdapter()
+        affordancesView.adapter = affordancesAdapter
+        affordancesView.layoutManager =
+            LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+
+        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()
+    }
+}
diff --git a/src/com/android/customization/quickaffordance/ui/binder/KeyguardQuickAffordancePickerPreviewBinder.kt b/src/com/android/customization/quickaffordance/ui/binder/KeyguardQuickAffordancePickerPreviewBinder.kt
new file mode 100644
index 0000000..399c033
--- /dev/null
+++ b/src/com/android/customization/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.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.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/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt b/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
new file mode 100644
index 0000000..e947c9a
--- /dev/null
+++ b/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
@@ -0,0 +1,273 @@
+/*
+ * 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.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.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.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,
+                    )
+                }
+        }
+
+    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?,
+    )
+
+    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/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt b/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
new file mode 100644
index 0000000..a0b77fa
--- /dev/null
+++ b/src/com/android/customization/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.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/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt b/src/com/android/customization/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
new file mode 100644
index 0000000..8f24145
--- /dev/null
+++ b/src/com/android/customization/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.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,
+            )
+        }
+    }
+}