Merge changes from topics "power-repo-interactor", "user-switcher-domain-layer" into tm-qpr-dev

* changes:
  Wiring into UserSwitcherActivity.
  User UI layer.
  User domain layer.
  User data layer.
  Adds power repository and interactor.
  Adds Text to the common module.
  Prepares UserSwitcherController.
diff --git a/packages/SystemUI/src/com/android/systemui/common/shared/model/Text.kt b/packages/SystemUI/src/com/android/systemui/common/shared/model/Text.kt
new file mode 100644
index 0000000..5d0e08f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/shared/model/Text.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.systemui.common.shared.model
+
+import android.annotation.StringRes
+
+/**
+ * Models a text, that can either be already [loaded][Text.Loaded] or be a [reference]
+ * [Text.Resource] to a resource.
+ */
+sealed class Text {
+    data class Loaded(
+        val text: String?,
+    ) : Text()
+
+    data class Resource(
+        @StringRes val res: Int,
+    ) : Text()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/binder/TextViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/binder/TextViewBinder.kt
new file mode 100644
index 0000000..396e8bb
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/ui/binder/TextViewBinder.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.systemui.common.ui.binder
+
+import android.widget.TextView
+import com.android.systemui.common.shared.model.Text
+
+object TextViewBinder {
+    fun bind(view: TextView, viewModel: Text) {
+        view.text =
+            when (viewModel) {
+                is Text.Resource -> view.context.getString(viewModel.res)
+                is Text.Loaded -> viewModel.text
+            }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/power/dagger/PowerModule.java b/packages/SystemUI/src/com/android/systemui/power/dagger/PowerModule.java
index 3709a86..7184fa0 100644
--- a/packages/SystemUI/src/com/android/systemui/power/dagger/PowerModule.java
+++ b/packages/SystemUI/src/com/android/systemui/power/dagger/PowerModule.java
@@ -20,13 +20,18 @@
 import com.android.systemui.power.EnhancedEstimatesImpl;
 import com.android.systemui.power.PowerNotificationWarnings;
 import com.android.systemui.power.PowerUI;
+import com.android.systemui.power.data.repository.PowerRepositoryModule;
 
 import dagger.Binds;
 import dagger.Module;
 
 
 /** Dagger Module for code in the power package. */
-@Module
+@Module(
+        includes = {
+                PowerRepositoryModule.class,
+        }
+)
 public interface PowerModule {
     /** */
     @Binds
diff --git a/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepository.kt b/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepository.kt
new file mode 100644
index 0000000..b2e04bb
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepository.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.systemui.power.data.repository
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.PowerManager
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
+import com.android.systemui.dagger.SysUISingleton
+import javax.inject.Inject
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+
+/** Defines interface for classes that act as source of truth for power-related data. */
+interface PowerRepository {
+    /** Whether the device is interactive. Starts with the current state. */
+    val isInteractive: Flow<Boolean>
+}
+
+@SysUISingleton
+class PowerRepositoryImpl
+@Inject
+constructor(
+    manager: PowerManager,
+    dispatcher: BroadcastDispatcher,
+) : PowerRepository {
+
+    override val isInteractive: Flow<Boolean> = conflatedCallbackFlow {
+        fun send() {
+            trySendWithFailureLogging(manager.isInteractive, TAG)
+        }
+
+        val receiver =
+            object : BroadcastReceiver() {
+                override fun onReceive(context: Context?, intent: Intent?) {
+                    send()
+                }
+            }
+
+        dispatcher.registerReceiver(
+            receiver,
+            IntentFilter().apply {
+                addAction(Intent.ACTION_SCREEN_ON)
+                addAction(Intent.ACTION_SCREEN_OFF)
+            },
+        )
+        send()
+
+        awaitClose { dispatcher.unregisterReceiver(receiver) }
+    }
+
+    companion object {
+        private const val TAG = "PowerRepository"
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepositoryModule.kt
new file mode 100644
index 0000000..491da65
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepositoryModule.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.systemui.power.data.repository
+
+import dagger.Binds
+import dagger.Module
+
+@Module
+interface PowerRepositoryModule {
+    @Binds fun bindRepository(impl: PowerRepositoryImpl): PowerRepository
+}
diff --git a/packages/SystemUI/src/com/android/systemui/power/domain/interactor/PowerInteractor.kt b/packages/SystemUI/src/com/android/systemui/power/domain/interactor/PowerInteractor.kt
new file mode 100644
index 0000000..3f799f7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/power/domain/interactor/PowerInteractor.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.systemui.power.domain.interactor
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.power.data.repository.PowerRepository
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+
+/** Hosts business logic for interacting with the power system. */
+@SysUISingleton
+class PowerInteractor
+@Inject
+constructor(
+    repository: PowerRepository,
+) {
+    /** Whether the screen is on or off. */
+    val isInteractive: Flow<Boolean> = repository.isInteractive
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java
index a5bcb53..62bda2c 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java
@@ -86,6 +86,7 @@
 import com.android.systemui.telephony.TelephonyListenerManager;
 import com.android.systemui.user.CreateUserActivity;
 import com.android.systemui.user.data.source.UserRecord;
+import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper;
 import com.android.systemui.util.settings.GlobalSettings;
 import com.android.systemui.util.settings.SecureSettings;
 
@@ -100,14 +101,20 @@
 
 import javax.inject.Inject;
 
+import kotlinx.coroutines.flow.Flow;
+import kotlinx.coroutines.flow.MutableStateFlow;
+import kotlinx.coroutines.flow.StateFlowKt;
+
 /**
  * Keeps a list of all users on the device for user switching.
  */
 @SysUISingleton
 public class UserSwitcherController implements Dumpable {
 
-    public static final float USER_SWITCH_ENABLED_ALPHA = 1.0f;
-    public static final float USER_SWITCH_DISABLED_ALPHA = 0.38f;
+    public static final float USER_SWITCH_ENABLED_ALPHA =
+            LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA;
+    public static final float USER_SWITCH_DISABLED_ALPHA =
+            LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA;
 
     private static final String TAG = "UserSwitcherController";
     private static final boolean DEBUG = false;
@@ -155,7 +162,8 @@
     private boolean mSimpleUserSwitcher;
     // When false, there won't be any visual affordance to add a new user from the keyguard even if
     // the user is unlocked
-    private boolean mAddUsersFromLockScreen;
+    private final MutableStateFlow<Boolean> mAddUsersFromLockScreen =
+            StateFlowKt.MutableStateFlow(false);
     private boolean mUserSwitcherEnabled;
     @VisibleForTesting
     boolean mPauseRefreshUsers;
@@ -258,8 +266,11 @@
             @Override
             public void onChange(boolean selfChange) {
                 mSimpleUserSwitcher = shouldUseSimpleUserSwitcher();
-                mAddUsersFromLockScreen = mGlobalSettings.getIntForUser(
-                        Settings.Global.ADD_USERS_WHEN_LOCKED, 0, UserHandle.USER_SYSTEM) != 0;
+                mAddUsersFromLockScreen.setValue(
+                        mGlobalSettings.getIntForUser(
+                                Settings.Global.ADD_USERS_WHEN_LOCKED,
+                                0,
+                                UserHandle.USER_SYSTEM) != 0);
                 mUserSwitcherEnabled = mGlobalSettings.getIntForUser(
                         Settings.Global.USER_SWITCHER_ENABLED, 0, UserHandle.USER_SYSTEM) != 0;
                 refreshUsers(UserHandle.USER_NULL);
@@ -323,7 +334,6 @@
         }
         mForcePictureLoadForUserId.clear();
 
-        final boolean addUsersWhenLocked = mAddUsersFromLockScreen;
         mBgExecutor.execute(() ->  {
             List<UserInfo> infos = mUserManager.getAliveUsers();
             if (infos == null) {
@@ -434,7 +444,7 @@
     }
 
     boolean anyoneCanCreateUsers() {
-        return systemCanCreateUsers() && mAddUsersFromLockScreen;
+        return systemCanCreateUsers() && mAddUsersFromLockScreen.getValue();
     }
 
     boolean canCreateGuest(boolean hasExistingGuest) {
@@ -450,7 +460,7 @@
     }
 
     boolean createIsRestricted() {
-        return !mAddUsersFromLockScreen;
+        return !mAddUsersFromLockScreen.getValue();
     }
 
     boolean canCreateSupervisedUser() {
@@ -516,17 +526,48 @@
         return null;
     }
 
+    /**
+     * Notifies that a user has been selected.
+     *
+     * <p>This will trigger the right user journeys to create a guest user, switch users, and/or
+     * navigate to the correct destination.
+     *
+     * <p>If a user with the given ID is not found, this method is a no-op.
+     *
+     * @param userId The ID of the user to switch to.
+     * @param dialogShower An optional {@link DialogShower} in case we need to show dialogs.
+     */
+    public void onUserSelected(int userId, @Nullable DialogShower dialogShower) {
+        UserRecord userRecord = mUsers.stream()
+                .filter(x -> x.resolveId() == userId)
+                .findFirst()
+                .orElse(null);
+        if (userRecord == null) {
+            return;
+        }
+
+        onUserListItemClicked(userRecord, dialogShower);
+    }
+
+    /** Whether it is allowed to add users while the device is locked. */
+    public Flow<Boolean> getAddUsersFromLockScreen() {
+        return mAddUsersFromLockScreen;
+    }
+
+    /** Returns {@code true} if the guest user is configured to always be present on the device. */
+    public boolean isGuestUserAutoCreated() {
+        return mGuestUserAutoCreated;
+    }
+
+    /** Returns {@code true} if the guest user is currently being reset. */
+    public boolean isGuestUserResetting() {
+        return mGuestIsResetting.get();
+    }
+
     @VisibleForTesting
     void onUserListItemClicked(UserRecord record, DialogShower dialogShower) {
         if (record.isGuest && record.info == null) {
-            // No guest user. Create one.
-            createGuestAsync(guestId -> {
-                // guestId may be USER_NULL if we haven't reloaded the user list yet.
-                if (guestId != UserHandle.USER_NULL) {
-                    mUiEventLogger.log(QSUserSwitcherEvent.QS_USER_GUEST_ADD);
-                    onUserListItemClicked(guestId, record, dialogShower);
-                }
-            });
+            createAndSwitchToGuestUser(dialogShower);
         } else if (record.isAddUser) {
             showAddUserDialog(dialogShower);
         } else if (record.isAddSupervisedUser) {
@@ -604,7 +645,23 @@
         }
     }
 
-    private void showAddUserDialog(DialogShower dialogShower) {
+    /**
+     * Creates and switches to the guest user.
+     */
+    public void createAndSwitchToGuestUser(@Nullable DialogShower dialogShower) {
+        createGuestAsync(guestId -> {
+            // guestId may be USER_NULL if we haven't reloaded the user list yet.
+            if (guestId != UserHandle.USER_NULL) {
+                mUiEventLogger.log(QSUserSwitcherEvent.QS_USER_GUEST_ADD);
+                onUserListItemClicked(guestId, UserRecord.createForGuest(), dialogShower);
+            }
+        });
+    }
+
+    /**
+     * Shows the add user dialog.
+     */
+    public void showAddUserDialog(@Nullable DialogShower dialogShower) {
         if (mAddUserDialog != null && mAddUserDialog.isShowing()) {
             mAddUserDialog.cancel();
         }
@@ -620,7 +677,10 @@
         }
     }
 
-    private void startSupervisedUserActivity() {
+    /**
+     * Starts an activity to add a supervised user to the device.
+     */
+    public void startSupervisedUserActivity() {
         final Intent intent = new Intent()
                 .setAction(UserManager.ACTION_CREATE_SUPERVISED_USER)
                 .setPackage(mCreateSupervisedUserPackage)
@@ -772,7 +832,7 @@
      * Removes guest user and switches to target user. The guest must be the current user and its id
      * must be {@code guestUserId}.
      *
-     * <p>If {@code targetUserId} is {@link UserHandle.USER_NULL}, then create a new guest user in
+     * <p>If {@code targetUserId} is {@link UserHandle#USER_NULL}, then create a new guest user in
      * the foreground, and immediately switch to it. This is used for wiping the current guest and
      * replacing it with a new one.
      *
@@ -782,11 +842,11 @@
      * <p>If device is configured with {@link
      * com.android.internal.R.bool.config_guestUserAutoCreated}, then after guest user is removed, a
      * new one is created in the background. This has no effect if {@code targetUserId} is {@link
-     * UserHandle.USER_NULL}.
+     * UserHandle#USER_NULL}.
      *
      * @param guestUserId id of the guest user to remove
      * @param targetUserId id of the user to switch to after guest is removed. If {@link
-     * UserHandle.USER_NULL}, then switch immediately to the newly created guest user.
+     * UserHandle#USER_NULL}, then switch immediately to the newly created guest user.
      */
     public void removeGuestUser(@UserIdInt int guestUserId, @UserIdInt int targetUserId) {
         UserInfo currentUser = mUserTracker.getUserInfo();
@@ -839,7 +899,7 @@
      * user.
      *
      * @param guestUserId user id of the guest user to exit
-     * @param targetUserId user id of the guest user to exit, set to UserHandle.USER_NULL when
+     * @param targetUserId user id of the guest user to exit, set to UserHandle#USER_NULL when
      *                       target user id is not known
      * @param forceRemoveGuestOnExit true: remove guest before switching user,
      *                               false: remove guest only if its ephemeral, else keep guest
@@ -952,7 +1012,7 @@
      * {@link UserManager} to create a new one.
      *
      * @return The multi-user user ID of the newly created guest user, or
-     * {@link UserHandle.USER_NULL} if the guest couldn't be created.
+     * {@link UserHandle#USER_NULL} if the guest couldn't be created.
      */
     public @UserIdInt int createGuest() {
         UserInfo guest;
@@ -1062,38 +1122,11 @@
         }
 
         public String getName(Context context, UserRecord item) {
-            if (item.isGuest) {
-                if (item.isCurrent) {
-                    return context.getString(
-                            com.android.settingslib.R.string.guest_exit_quick_settings_button);
-                } else {
-                    if (item.info != null) {
-                        return context.getString(com.android.internal.R.string.guest_name);
-                    } else {
-                        if (mController.mGuestUserAutoCreated) {
-                            // If mGuestIsResetting=true, we expect the guest user to be created
-                            // shortly, so display a "Resetting guest..." as an indicator that we
-                            // are busy. Otherwise, if mGuestIsResetting=false, we probably failed
-                            // to create a guest at some point. In this case, always show guest
-                            // nickname instead of "Add guest" to make it seem as though the device
-                            // always has a guest ready for use.
-                            return context.getString(
-                                    mController.mGuestIsResetting.get()
-                                            ? com.android.settingslib.R.string.guest_resetting
-                                            : com.android.internal.R.string.guest_name);
-                        } else {
-                            // we always show "guest" as string, instead of "add guest"
-                            return context.getString(com.android.internal.R.string.guest_name);
-                        }
-                    }
-                }
-            } else if (item.isAddUser) {
-                return context.getString(com.android.settingslib.R.string.user_add_user);
-            } else if (item.isAddSupervisedUser) {
-                return context.getString(R.string.add_user_supervised);
-            } else {
-                return item.info.name;
-            }
+            return LegacyUserUiHelper.getUserRecordName(
+                    context,
+                    item,
+                    mController.isGuestUserAutoCreated(),
+                    mController.isGuestUserResetting());
         }
 
         protected static ColorFilter getDisabledUserAvatarColorFilter() {
@@ -1103,17 +1136,8 @@
         }
 
         protected static Drawable getIconDrawable(Context context, UserRecord item) {
-            int iconRes;
-            if (item.isAddUser) {
-                iconRes = R.drawable.ic_add;
-            } else if (item.isGuest) {
-                iconRes = R.drawable.ic_account_circle;
-            } else if (item.isAddSupervisedUser) {
-                iconRes = R.drawable.ic_add_supervised_user;
-            } else {
-                iconRes = R.drawable.ic_avatar_user;
-            }
-
+            int iconRes = LegacyUserUiHelper.getUserSwitcherActionIconResourceId(
+                    item.isAddUser, item.isGuest, item.isAddSupervisedUser);
             return context.getDrawable(iconRes);
         }
 
diff --git a/packages/SystemUI/src/com/android/systemui/user/UserModule.java b/packages/SystemUI/src/com/android/systemui/user/UserModule.java
index 469d54f..5b522dc 100644
--- a/packages/SystemUI/src/com/android/systemui/user/UserModule.java
+++ b/packages/SystemUI/src/com/android/systemui/user/UserModule.java
@@ -19,6 +19,7 @@
 import android.app.Activity;
 
 import com.android.settingslib.users.EditUserInfoController;
+import com.android.systemui.user.data.repository.UserRepositoryModule;
 
 import dagger.Binds;
 import dagger.Module;
@@ -29,7 +30,11 @@
 /**
  * Dagger module for User related classes.
  */
-@Module
+@Module(
+        includes = {
+                UserRepositoryModule.class,
+        }
+)
 public abstract class UserModule {
 
     private static final String FILE_PROVIDER_AUTHORITY = "com.android.systemui.fileprovider";
diff --git a/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt b/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt
index ff0f0d4..8a51cd6 100644
--- a/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt
+++ b/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt
@@ -27,6 +27,7 @@
 import android.os.Bundle
 import android.os.UserManager
 import android.provider.Settings
+import android.util.Log
 import android.view.LayoutInflater
 import android.view.MotionEvent
 import android.view.View
@@ -37,6 +38,7 @@
 import android.widget.TextView
 import androidx.activity.ComponentActivity
 import androidx.constraintlayout.helper.widget.Flow
+import androidx.lifecycle.ViewModelProvider
 import com.android.internal.annotations.VisibleForTesting
 import com.android.internal.util.UserIcons
 import com.android.settingslib.Utils
@@ -44,6 +46,8 @@
 import com.android.systemui.R
 import com.android.systemui.broadcast.BroadcastDispatcher
 import com.android.systemui.classifier.FalsingCollector
+import com.android.systemui.flags.FeatureFlags
+import com.android.systemui.flags.Flags
 import com.android.systemui.plugins.FalsingManager
 import com.android.systemui.plugins.FalsingManager.LOW_PENALTY
 import com.android.systemui.settings.UserTracker
@@ -52,6 +56,9 @@
 import com.android.systemui.statusbar.policy.UserSwitcherController.USER_SWITCH_DISABLED_ALPHA
 import com.android.systemui.statusbar.policy.UserSwitcherController.USER_SWITCH_ENABLED_ALPHA
 import com.android.systemui.user.data.source.UserRecord
+import com.android.systemui.user.ui.binder.UserSwitcherViewBinder
+import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
+import dagger.Lazy
 import javax.inject.Inject
 import kotlin.math.ceil
 
@@ -63,11 +70,12 @@
 class UserSwitcherActivity @Inject constructor(
     private val userSwitcherController: UserSwitcherController,
     private val broadcastDispatcher: BroadcastDispatcher,
-    private val layoutInflater: LayoutInflater,
     private val falsingCollector: FalsingCollector,
     private val falsingManager: FalsingManager,
     private val userManager: UserManager,
-    private val userTracker: UserTracker
+    private val userTracker: UserTracker,
+    private val flags: FeatureFlags,
+    private val viewModelFactory: Lazy<UserSwitcherViewModel.Factory>,
 ) : ComponentActivity() {
 
     private lateinit var parent: UserSwitcherRootView
@@ -93,119 +101,31 @@
             false /* isAddSupervisedUser */
         )
 
-    private val adapter = object : BaseUserAdapter(userSwitcherController) {
-        override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
-            val item = getItem(position)
-            var view = convertView as ViewGroup?
-            if (view == null) {
-                view = layoutInflater.inflate(
-                    R.layout.user_switcher_fullscreen_item,
-                    parent,
-                    false
-                ) as ViewGroup
-            }
-            (view.getChildAt(0) as ImageView).apply {
-                setImageDrawable(getDrawable(item))
-            }
-            (view.getChildAt(1) as TextView).apply {
-                setText(getName(getContext(), item))
-            }
-
-            view.setEnabled(item.isSwitchToEnabled)
-            view.setAlpha(
-                if (view.isEnabled()) {
-                    USER_SWITCH_ENABLED_ALPHA
-                } else {
-                    USER_SWITCH_DISABLED_ALPHA
-                }
-            )
-            view.setTag(USER_VIEW)
-            return view
-        }
-
-        override fun getName(context: Context, item: UserRecord): String {
-            return if (item == manageUserRecord) {
-                getString(R.string.manage_users)
-            } else {
-                super.getName(context, item)
-            }
-        }
-
-        fun findUserIcon(item: UserRecord): Drawable {
-            if (item == manageUserRecord) {
-                return getDrawable(R.drawable.ic_manage_users)
-            }
-            if (item.info == null) {
-                return getIconDrawable(this@UserSwitcherActivity, item)
-            }
-            val userIcon = userManager.getUserIcon(item.info.id)
-            if (userIcon != null) {
-                return BitmapDrawable(userIcon)
-            }
-            return UserIcons.getDefaultUserIcon(resources, item.info.id, false)
-        }
-
-        fun getTotalUserViews(): Int {
-            return users.count { item ->
-                !doNotRenderUserView(item)
-            }
-        }
-
-        fun doNotRenderUserView(item: UserRecord): Boolean {
-            return item.isAddUser ||
-                    item.isAddSupervisedUser ||
-                    item.isGuest && item.info == null
-        }
-
-        private fun getDrawable(item: UserRecord): Drawable {
-            var drawable = if (item.isGuest) {
-                getDrawable(R.drawable.ic_account_circle)
-            } else {
-                findUserIcon(item)
-            }
-            drawable.mutate()
-
-            if (!item.isCurrent && !item.isSwitchToEnabled) {
-                drawable.setTint(
-                    resources.getColor(
-                        R.color.kg_user_switcher_restricted_avatar_icon_color,
-                        getTheme()
-                    )
-                )
-            }
-
-            val ld = getDrawable(R.drawable.user_switcher_icon_large).mutate()
-                as LayerDrawable
-            if (item == userSwitcherController.getCurrentUserRecord()) {
-                (ld.findDrawableByLayerId(R.id.ring) as GradientDrawable).apply {
-                    val stroke = resources
-                        .getDimensionPixelSize(R.dimen.user_switcher_icon_selected_width)
-                    val color = Utils.getColorAttrDefaultColor(
-                        this@UserSwitcherActivity,
-                        com.android.internal.R.attr.colorAccentPrimary
-                    )
-
-                    setStroke(stroke, color)
-                }
-            }
-
-            ld.setDrawableByLayerId(R.id.user_avatar, drawable)
-            return ld
-        }
-
-        override fun notifyDataSetChanged() {
-            super.notifyDataSetChanged()
-            buildUserViews()
-        }
-    }
+    private val adapter: UserAdapter by lazy { UserAdapter() }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
         setContentView(R.layout.user_switcher_fullscreen)
-        window.decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
-            or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
-            or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
+        window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+                or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+                or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
+        if (isUsingModernArchitecture()) {
+            Log.d(TAG, "Using modern architecture.")
+            val viewModel = ViewModelProvider(
+                this, viewModelFactory.get())[UserSwitcherViewModel::class.java]
+            UserSwitcherViewBinder.bind(
+                view = requireViewById(R.id.user_switcher_root),
+                viewModel = viewModel,
+                lifecycleOwner = this,
+                layoutInflater = layoutInflater,
+                falsingCollector = falsingCollector,
+                onFinish = this::finish,
+            )
+            return
+        } else {
+            Log.d(TAG, "Not using modern architecture.")
+        }
 
         parent = requireViewById<UserSwitcherRootView>(R.id.user_switcher_root)
 
@@ -346,11 +266,18 @@
     }
 
     override fun onBackPressed() {
+        if (isUsingModernArchitecture()) {
+            return super.onBackPressed()
+        }
+
         finish()
     }
 
     override fun onDestroy() {
         super.onDestroy()
+        if (isUsingModernArchitecture()) {
+            return
+        }
 
         broadcastDispatcher.unregisterReceiver(broadcastReceiver)
         userTracker.removeCallback(userSwitchedCallback)
@@ -376,6 +303,10 @@
         return if (userCount < 5) 4 else ceil(userCount / 2.0).toInt()
     }
 
+    private fun isUsingModernArchitecture(): Boolean {
+        return flags.isEnabled(Flags.MODERN_USER_SWITCHER_ACTIVITY)
+    }
+
     private class ItemAdapter(
         val parentContext: Context,
         val resource: Int,
@@ -398,4 +329,114 @@
             return view
         }
     }
+
+    private inner class UserAdapter : BaseUserAdapter(userSwitcherController) {
+        override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+            val item = getItem(position)
+            var view = convertView as ViewGroup?
+            if (view == null) {
+                view = layoutInflater.inflate(
+                    R.layout.user_switcher_fullscreen_item,
+                    parent,
+                    false
+                ) as ViewGroup
+            }
+            (view.getChildAt(0) as ImageView).apply {
+                setImageDrawable(getDrawable(item))
+            }
+            (view.getChildAt(1) as TextView).apply {
+                setText(getName(getContext(), item))
+            }
+
+            view.setEnabled(item.isSwitchToEnabled)
+            view.setAlpha(
+                if (view.isEnabled()) {
+                    USER_SWITCH_ENABLED_ALPHA
+                } else {
+                    USER_SWITCH_DISABLED_ALPHA
+                }
+            )
+            view.setTag(USER_VIEW)
+            return view
+        }
+
+        override fun getName(context: Context, item: UserRecord): String {
+            return if (item == manageUserRecord) {
+                getString(R.string.manage_users)
+            } else {
+                super.getName(context, item)
+            }
+        }
+
+        fun findUserIcon(item: UserRecord): Drawable {
+            if (item == manageUserRecord) {
+                return getDrawable(R.drawable.ic_manage_users)
+            }
+            if (item.info == null) {
+                return getIconDrawable(this@UserSwitcherActivity, item)
+            }
+            val userIcon = userManager.getUserIcon(item.info.id)
+            if (userIcon != null) {
+                return BitmapDrawable(userIcon)
+            }
+            return UserIcons.getDefaultUserIcon(resources, item.info.id, false)
+        }
+
+        fun getTotalUserViews(): Int {
+            return users.count { item ->
+                !doNotRenderUserView(item)
+            }
+        }
+
+        fun doNotRenderUserView(item: UserRecord): Boolean {
+            return item.isAddUser ||
+                    item.isAddSupervisedUser ||
+                    item.isGuest && item.info == null
+        }
+
+        private fun getDrawable(item: UserRecord): Drawable {
+            var drawable = if (item.isGuest) {
+                getDrawable(R.drawable.ic_account_circle)
+            } else {
+                findUserIcon(item)
+            }
+            drawable.mutate()
+
+            if (!item.isCurrent && !item.isSwitchToEnabled) {
+                drawable.setTint(
+                    resources.getColor(
+                        R.color.kg_user_switcher_restricted_avatar_icon_color,
+                        getTheme()
+                    )
+                )
+            }
+
+            val ld = getDrawable(R.drawable.user_switcher_icon_large).mutate()
+                    as LayerDrawable
+            if (item == userSwitcherController.getCurrentUserRecord()) {
+                (ld.findDrawableByLayerId(R.id.ring) as GradientDrawable).apply {
+                    val stroke = resources
+                        .getDimensionPixelSize(R.dimen.user_switcher_icon_selected_width)
+                    val color = Utils.getColorAttrDefaultColor(
+                        this@UserSwitcherActivity,
+                        com.android.internal.R.attr.colorAccentPrimary
+                    )
+
+                    setStroke(stroke, color)
+                }
+            }
+
+            ld.setDrawableByLayerId(R.id.user_avatar, drawable)
+            return ld
+        }
+
+        override fun notifyDataSetChanged() {
+            super.notifyDataSetChanged()
+            buildUserViews()
+        }
+    }
+
+    companion object {
+        private const val TAG = "UserSwitcherActivity"
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt
new file mode 100644
index 0000000..305b5ee
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt
@@ -0,0 +1,166 @@
+/*
+ * 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.systemui.user.data.repository
+
+import android.content.Context
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.os.UserManager
+import androidx.appcompat.content.res.AppCompatResources
+import com.android.internal.util.UserIcons
+import com.android.systemui.R
+import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
+import com.android.systemui.common.shared.model.Text
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.statusbar.policy.UserSwitcherController
+import com.android.systemui.user.data.source.UserRecord
+import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.user.shared.model.UserModel
+import javax.inject.Inject
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * Acts as source of truth for user related data.
+ *
+ * Abstracts-away data sources and their schemas so the rest of the app doesn't need to worry about
+ * upstream changes.
+ */
+interface UserRepository {
+    /** List of all users on the device. */
+    val users: Flow<List<UserModel>>
+
+    /** The currently-selected user. */
+    val selectedUser: Flow<UserModel>
+
+    /** List of available user-related actions. */
+    val actions: Flow<List<UserActionModel>>
+
+    /** Whether actions are available even when locked. */
+    val isActionableWhenLocked: Flow<Boolean>
+
+    /** Whether the device is configured to always have a guest user available. */
+    val isGuestUserAutoCreated: Boolean
+
+    /** Whether the guest user is currently being reset. */
+    val isGuestUserResetting: Boolean
+}
+
+@SysUISingleton
+class UserRepositoryImpl
+@Inject
+constructor(
+    @Application private val appContext: Context,
+    private val manager: UserManager,
+    controller: UserSwitcherController,
+) : UserRepository {
+
+    private val userRecords: Flow<List<UserRecord>> = conflatedCallbackFlow {
+        fun send() {
+            trySendWithFailureLogging(
+                controller.users,
+                TAG,
+            )
+        }
+
+        val callback = UserSwitcherController.UserSwitchCallback { send() }
+
+        controller.addUserSwitchCallback(callback)
+        send()
+
+        awaitClose { controller.removeUserSwitchCallback(callback) }
+    }
+
+    override val users: Flow<List<UserModel>> =
+        userRecords.map { records -> records.filter { it.isUser() }.map { it.toUserModel() } }
+
+    override val selectedUser: Flow<UserModel> =
+        users.map { users -> users.first { user -> user.isSelected } }
+
+    override val actions: Flow<List<UserActionModel>> =
+        userRecords.map { records -> records.filter { it.isNotUser() }.map { it.toActionModel() } }
+
+    override val isActionableWhenLocked: Flow<Boolean> = controller.addUsersFromLockScreen
+
+    override val isGuestUserAutoCreated: Boolean = controller.isGuestUserAutoCreated
+
+    override val isGuestUserResetting: Boolean = controller.isGuestUserResetting
+
+    private fun UserRecord.isUser(): Boolean {
+        return when {
+            isAddUser -> false
+            isAddSupervisedUser -> false
+            isGuest -> info != null
+            else -> true
+        }
+    }
+
+    private fun UserRecord.isNotUser(): Boolean {
+        return !isUser()
+    }
+
+    private fun UserRecord.toUserModel(): UserModel {
+        return UserModel(
+            id = resolveId(),
+            name = getUserName(this),
+            image = getUserImage(this),
+            isSelected = isCurrent,
+            isSelectable = isSwitchToEnabled || isGuest,
+        )
+    }
+
+    private fun UserRecord.toActionModel(): UserActionModel {
+        return when {
+            isAddUser -> UserActionModel.ADD_USER
+            isAddSupervisedUser -> UserActionModel.ADD_SUPERVISED_USER
+            isGuest -> UserActionModel.ENTER_GUEST_MODE
+            else -> error("Don't know how to convert to UserActionModel: $this")
+        }
+    }
+
+    private fun getUserName(record: UserRecord): Text {
+        val resourceId: Int? = LegacyUserUiHelper.getGuestUserRecordNameResourceId(record)
+        return if (resourceId != null) {
+            Text.Resource(resourceId)
+        } else {
+            Text.Loaded(checkNotNull(record.info).name)
+        }
+    }
+
+    private fun getUserImage(record: UserRecord): Drawable {
+        if (record.isGuest) {
+            return checkNotNull(
+                AppCompatResources.getDrawable(appContext, R.drawable.ic_account_circle)
+            )
+        }
+
+        val userId = checkNotNull(record.info?.id)
+        return manager.getUserIcon(userId)?.let { userSelectedIcon ->
+            BitmapDrawable(userSelectedIcon)
+        }
+            ?: UserIcons.getDefaultUserIcon(appContext.resources, userId, /* light= */ false)
+    }
+
+    companion object {
+        private const val TAG = "UserRepository"
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt
new file mode 100644
index 0000000..18ae107
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.systemui.user.data.repository
+
+import dagger.Binds
+import dagger.Module
+
+@Module
+interface UserRepositoryModule {
+    @Binds fun bindRepository(impl: UserRepositoryImpl): UserRepository
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt b/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt
index 6ab6d7d..cf6da9a 100644
--- a/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt
+++ b/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt
@@ -20,38 +20,29 @@
 import android.graphics.Bitmap
 import android.os.UserHandle
 
-/**
- * Encapsulates raw data for a user or an option item related to managing users on the device.
- */
+/** Encapsulates raw data for a user or an option item related to managing users on the device. */
 data class UserRecord(
     /** Relevant user information. If `null`, this record is not a user but an option item. */
-    @JvmField
-    val info: UserInfo?,
+    @JvmField val info: UserInfo? = null,
     /** An image representing the user. */
-    @JvmField
-    val picture: Bitmap?,
+    @JvmField val picture: Bitmap? = null,
     /** Whether this record represents an option to switch to a guest user. */
-    @JvmField
-    val isGuest: Boolean,
+    @JvmField val isGuest: Boolean = false,
     /** Whether this record represents the currently-selected user. */
-    @JvmField
-    val isCurrent: Boolean,
+    @JvmField val isCurrent: Boolean = false,
     /** Whether this record represents an option to add another user to the device. */
-    @JvmField
-    val isAddUser: Boolean,
-    /** If true, the record is only visible to the owner and only when unlocked.  */
-    @JvmField
-    val isRestricted: Boolean,
-    /** Whether it is possible to switch to this user. */
-    @JvmField
-    val isSwitchToEnabled: Boolean,
-    /** Whether this record represents an option to add another supervised user to the device. */
-    @JvmField
-    val isAddSupervisedUser: Boolean,
-) {
+    @JvmField val isAddUser: Boolean = false,
     /**
-     * Returns a new instance of [UserRecord] with its [isCurrent] set to the given value.
+     * If true, the record is only available if unlocked or if the user has granted permission to
+     * access this user action whilst on the device is locked.
      */
+    @JvmField val isRestricted: Boolean = false,
+    /** Whether it is possible to switch to this user. */
+    @JvmField val isSwitchToEnabled: Boolean = false,
+    /** Whether this record represents an option to add another supervised user to the device. */
+    @JvmField val isAddSupervisedUser: Boolean = false,
+) {
+    /** Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. */
     fun copyWithIsCurrent(isCurrent: Boolean): UserRecord {
         return copy(isCurrent = isCurrent)
     }
@@ -67,4 +58,11 @@
             info.id
         }
     }
+
+    companion object {
+        @JvmStatic
+        fun createForGuest(): UserRecord {
+            return UserRecord(isGuest = true)
+        }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/user/domain/interactor/UserInteractor.kt b/packages/SystemUI/src/com/android/systemui/user/domain/interactor/UserInteractor.kt
new file mode 100644
index 0000000..3c5b969
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/domain/interactor/UserInteractor.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.systemui.user.domain.interactor
+
+import android.content.Intent
+import android.provider.Settings
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.statusbar.policy.UserSwitcherController
+import com.android.systemui.user.data.repository.UserRepository
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.user.shared.model.UserModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+
+/** Encapsulates business logic to interact with user data and systems. */
+@SysUISingleton
+class UserInteractor
+@Inject
+constructor(
+    repository: UserRepository,
+    private val controller: UserSwitcherController,
+    private val activityStarter: ActivityStarter,
+    keyguardInteractor: KeyguardInteractor,
+) {
+    /** List of current on-device users to select from. */
+    val users: Flow<List<UserModel>> = repository.users
+
+    /** The currently-selected user. */
+    val selectedUser: Flow<UserModel> = repository.selectedUser
+
+    /** List of user-switcher related actions that are available. */
+    val actions: Flow<List<UserActionModel>> =
+        combine(
+                repository.isActionableWhenLocked,
+                keyguardInteractor.isKeyguardShowing,
+            ) { isActionableWhenLocked, isLocked ->
+                isActionableWhenLocked || !isLocked
+            }
+            .flatMapLatest { isActionable ->
+                if (isActionable) {
+                    repository.actions.map { actions ->
+                        actions +
+                            if (actions.isNotEmpty()) {
+                                // If we have actions, we add NAVIGATE_TO_USER_MANAGEMENT because
+                                // that's a user
+                                // switcher specific action that is not known to the our data source
+                                // or other
+                                // features.
+                                listOf(UserActionModel.NAVIGATE_TO_USER_MANAGEMENT)
+                            } else {
+                                // If no actions, don't add the navigate action.
+                                emptyList()
+                            }
+                    }
+                } else {
+                    // If not actionable it means that we're not allowed to show actions when locked
+                    // and we
+                    // are locked. Therefore, we should show no actions.
+                    flowOf(emptyList())
+                }
+            }
+
+    /** Whether the device is configured to always have a guest user available. */
+    val isGuestUserAutoCreated: Boolean = repository.isGuestUserAutoCreated
+
+    /** Whether the guest user is currently being reset. */
+    val isGuestUserResetting: Boolean = repository.isGuestUserResetting
+
+    /** Switches to the user with the given user ID. */
+    fun selectUser(
+        userId: Int,
+    ) {
+        controller.onUserSelected(userId, /* dialogShower= */ null)
+    }
+
+    /** Executes the given action. */
+    fun executeAction(action: UserActionModel) {
+        when (action) {
+            UserActionModel.ENTER_GUEST_MODE -> controller.createAndSwitchToGuestUser(null)
+            UserActionModel.ADD_USER -> controller.showAddUserDialog(null)
+            UserActionModel.ADD_SUPERVISED_USER -> controller.startSupervisedUserActivity()
+            UserActionModel.NAVIGATE_TO_USER_MANAGEMENT ->
+                activityStarter.startActivity(
+                    Intent(Settings.ACTION_USER_SETTINGS),
+                    /* dismissShade= */ false,
+                )
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt b/packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt
new file mode 100644
index 0000000..18369d9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.systemui.user.legacyhelper.ui
+
+import android.content.Context
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import com.android.systemui.R
+import com.android.systemui.user.data.source.UserRecord
+import kotlin.math.ceil
+
+/**
+ * Defines utility functions for helping with legacy UI code for users.
+ *
+ * We need these to avoid code duplication between logic inside the UserSwitcherController and in
+ * modern architecture classes such as repositories, interactors, and view-models. If we ever
+ * simplify UserSwitcherController (or delete it), the code here could be moved into its call-sites.
+ */
+object LegacyUserUiHelper {
+
+    /** Returns the maximum number of columns for user items in the user switcher. */
+    fun getMaxUserSwitcherItemColumns(userCount: Int): Int {
+        // TODO(b/243844097): remove this once we remove the old user switcher implementation.
+        return if (userCount < 5) {
+            4
+        } else {
+            ceil(userCount / 2.0).toInt()
+        }
+    }
+
+    @JvmStatic
+    @DrawableRes
+    fun getUserSwitcherActionIconResourceId(
+        isAddUser: Boolean,
+        isGuest: Boolean,
+        isAddSupervisedUser: Boolean,
+    ): Int {
+        return if (isAddUser) {
+            R.drawable.ic_add
+        } else if (isGuest) {
+            R.drawable.ic_account_circle
+        } else if (isAddSupervisedUser) {
+            R.drawable.ic_add_supervised_user
+        } else {
+            R.drawable.ic_avatar_user
+        }
+    }
+
+    @JvmStatic
+    fun getUserRecordName(
+        context: Context,
+        record: UserRecord,
+        isGuestUserAutoCreated: Boolean,
+        isGuestUserResetting: Boolean,
+    ): String {
+        val resourceId: Int? = getGuestUserRecordNameResourceId(record)
+        return when {
+            resourceId != null -> context.getString(resourceId)
+            record.info != null -> record.info.name
+            else ->
+                context.getString(
+                    getUserSwitcherActionTextResourceId(
+                        isGuest = record.isGuest,
+                        isGuestUserAutoCreated = isGuestUserAutoCreated,
+                        isGuestUserResetting = isGuestUserResetting,
+                        isAddUser = record.isAddUser,
+                        isAddSupervisedUser = record.isAddSupervisedUser,
+                    )
+                )
+        }
+    }
+
+    /**
+     * Returns the resource ID for a string for the name of the guest user.
+     *
+     * If the given record is not the guest user, returns `null`.
+     */
+    @StringRes
+    fun getGuestUserRecordNameResourceId(record: UserRecord): Int? {
+        return when {
+            record.isGuest && record.isCurrent ->
+                com.android.settingslib.R.string.guest_exit_quick_settings_button
+            record.isGuest && record.info != null -> com.android.internal.R.string.guest_name
+            else -> null
+        }
+    }
+
+    @JvmStatic
+    @StringRes
+    fun getUserSwitcherActionTextResourceId(
+        isGuest: Boolean,
+        isGuestUserAutoCreated: Boolean,
+        isGuestUserResetting: Boolean,
+        isAddUser: Boolean,
+        isAddSupervisedUser: Boolean,
+    ): Int {
+        check(isGuest || isAddUser || isAddSupervisedUser)
+
+        return when {
+            isGuest && isGuestUserAutoCreated && isGuestUserResetting ->
+                com.android.settingslib.R.string.guest_resetting
+            isGuest && isGuestUserAutoCreated -> com.android.internal.R.string.guest_name
+            isGuest -> com.android.internal.R.string.guest_name
+            isAddUser -> com.android.settingslib.R.string.user_add_user
+            isAddSupervisedUser -> R.string.add_user_supervised
+            else -> error("This should never happen!")
+        }
+    }
+
+    /** Alpha value to apply to a user view in the user switcher when it's selectable. */
+    const val USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA = 1.0f
+
+    /** Alpha value to apply to a user view in the user switcher when it's not selectable. */
+    const val USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA = 0.38f
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt
new file mode 100644
index 0000000..823bf74
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.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.systemui.user.shared.model
+
+enum class UserActionModel {
+    ENTER_GUEST_MODE,
+    ADD_USER,
+    ADD_SUPERVISED_USER,
+    NAVIGATE_TO_USER_MANAGEMENT,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt
new file mode 100644
index 0000000..bf7977a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.systemui.user.shared.model
+
+import android.graphics.drawable.Drawable
+import com.android.systemui.common.shared.model.Text
+
+/** Represents a single user on the device. */
+data class UserModel(
+    /** ID of the user, unique across all users on this device. */
+    val id: Int,
+    /** Human-facing name for this user. */
+    val name: Text,
+    /** Human-facing image for this user. */
+    val image: Drawable,
+    /** Whether this user is the currently-selected user. */
+    val isSelected: Boolean,
+    /** Whether this use is selectable. A non-selectable user cannot be switched to. */
+    val isSelectable: Boolean,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt
new file mode 100644
index 0000000..83a3d0d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.systemui.user.ui.binder
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.helper.widget.Flow as FlowWidget
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.Gefingerpoken
+import com.android.systemui.R
+import com.android.systemui.classifier.FalsingCollector
+import com.android.systemui.user.UserSwitcherPopupMenu
+import com.android.systemui.user.UserSwitcherRootView
+import com.android.systemui.user.ui.viewmodel.UserActionViewModel
+import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
+import com.android.systemui.util.children
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.launch
+
+/** Binds a user switcher to its view-model. */
+object UserSwitcherViewBinder {
+
+    private const val USER_VIEW_TAG = "user_view"
+
+    /** Binds the given view to the given view-model. */
+    fun bind(
+        view: ViewGroup,
+        viewModel: UserSwitcherViewModel,
+        lifecycleOwner: LifecycleOwner,
+        layoutInflater: LayoutInflater,
+        falsingCollector: FalsingCollector,
+        onFinish: () -> Unit,
+    ) {
+        val rootView: UserSwitcherRootView = view.requireViewById(R.id.user_switcher_root)
+        val flowWidget: FlowWidget = view.requireViewById(R.id.flow)
+        val addButton: View = view.requireViewById(R.id.add)
+        val cancelButton: View = view.requireViewById(R.id.cancel)
+        val popupMenuAdapter = MenuAdapter(layoutInflater)
+        var popupMenu: UserSwitcherPopupMenu? = null
+
+        rootView.touchHandler =
+            object : Gefingerpoken {
+                override fun onTouchEvent(ev: MotionEvent?): Boolean {
+                    falsingCollector.onTouchEvent(ev)
+                    return false
+                }
+            }
+        addButton.setOnClickListener { viewModel.onOpenMenuButtonClicked() }
+        cancelButton.setOnClickListener { viewModel.onCancelButtonClicked() }
+
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
+                launch {
+                    viewModel.isFinishRequested
+                        .filter { it }
+                        .collect {
+                            onFinish()
+                            viewModel.onFinished()
+                        }
+                }
+            }
+        }
+
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch { viewModel.isOpenMenuButtonVisible.collect { addButton.isVisible = it } }
+
+                launch {
+                    viewModel.isMenuVisible.collect { isVisible ->
+                        if (isVisible && popupMenu?.isShowing != true) {
+                            popupMenu?.dismiss()
+                            // Use post to make sure we show the popup menu *after* the activity is
+                            // ready to show one to avoid a WindowManager$BadTokenException.
+                            view.post {
+                                popupMenu =
+                                    createAndShowPopupMenu(
+                                        context = view.context,
+                                        anchorView = addButton,
+                                        adapter = popupMenuAdapter,
+                                        onDismissed = viewModel::onMenuClosed,
+                                    )
+                            }
+                        } else if (!isVisible && popupMenu?.isShowing == true) {
+                            popupMenu?.dismiss()
+                            popupMenu = null
+                        }
+                    }
+                }
+
+                launch {
+                    viewModel.menu.collect { menuViewModels ->
+                        popupMenuAdapter.setItems(menuViewModels)
+                    }
+                }
+
+                launch {
+                    viewModel.maximumUserColumns.collect { maximumColumns ->
+                        flowWidget.setMaxElementsWrap(maximumColumns)
+                    }
+                }
+
+                launch {
+                    viewModel.users.collect { users ->
+                        val viewPool =
+                            view.children.filter { it.tag == USER_VIEW_TAG }.toMutableList()
+                        viewPool.forEach { view.removeView(it) }
+                        users.forEach { userViewModel ->
+                            val userView =
+                                if (viewPool.isNotEmpty()) {
+                                    viewPool.removeAt(0)
+                                } else {
+                                    val inflatedView =
+                                        layoutInflater.inflate(
+                                            R.layout.user_switcher_fullscreen_item,
+                                            view,
+                                            false,
+                                        )
+                                    inflatedView.tag = USER_VIEW_TAG
+                                    inflatedView
+                                }
+                            userView.id = View.generateViewId()
+                            view.addView(userView)
+                            flowWidget.addView(userView)
+                            UserViewBinder.bind(
+                                view = userView,
+                                viewModel = userViewModel,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun createAndShowPopupMenu(
+        context: Context,
+        anchorView: View,
+        adapter: MenuAdapter,
+        onDismissed: () -> Unit,
+    ): UserSwitcherPopupMenu {
+        return UserSwitcherPopupMenu(context).apply {
+            this.anchorView = anchorView
+            setAdapter(adapter)
+            setOnDismissListener { onDismissed() }
+            setOnItemClickListener { _, _, position, _ ->
+                val itemPositionExcludingHeader = position - 1
+                adapter.getItem(itemPositionExcludingHeader).onClicked()
+            }
+
+            show()
+        }
+    }
+
+    /** Adapter for the menu that can be opened. */
+    private class MenuAdapter(
+        private val layoutInflater: LayoutInflater,
+    ) : BaseAdapter() {
+
+        private val items = mutableListOf<UserActionViewModel>()
+
+        override fun getCount(): Int {
+            return items.size
+        }
+
+        override fun getItem(position: Int): UserActionViewModel {
+            return items[position]
+        }
+
+        override fun getItemId(position: Int): Long {
+            return getItem(position).viewKey
+        }
+
+        override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+            val view =
+                convertView
+                    ?: layoutInflater.inflate(
+                        R.layout.user_switcher_fullscreen_popup_item,
+                        parent,
+                        false
+                    )
+            val viewModel = getItem(position)
+            view.requireViewById<ImageView>(R.id.icon).setImageResource(viewModel.iconResourceId)
+            view.requireViewById<TextView>(R.id.text).text =
+                view.resources.getString(viewModel.textResourceId)
+            return view
+        }
+
+        fun setItems(items: List<UserActionViewModel>) {
+            this.items.clear()
+            this.items.addAll(items)
+            notifyDataSetChanged()
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt
new file mode 100644
index 0000000..e78807e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.systemui.user.ui.binder
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
+import android.view.View
+import android.widget.ImageView
+import androidx.core.content.res.ResourcesCompat
+import com.android.settingslib.Utils
+import com.android.systemui.R
+import com.android.systemui.common.ui.binder.TextViewBinder
+import com.android.systemui.user.ui.viewmodel.UserViewModel
+
+/** Binds a user view to its view-model. */
+object UserViewBinder {
+    /** Binds the given view to the given view-model. */
+    fun bind(view: View, viewModel: UserViewModel) {
+        TextViewBinder.bind(view.requireViewById(R.id.user_switcher_text), viewModel.name)
+        view
+            .requireViewById<ImageView>(R.id.user_switcher_icon)
+            .setImageDrawable(getSelectableDrawable(view.context, viewModel))
+        view.alpha = viewModel.alpha
+        if (viewModel.onClicked != null) {
+            view.setOnClickListener { viewModel.onClicked.invoke() }
+        } else {
+            view.setOnClickListener(null)
+        }
+    }
+
+    private fun getSelectableDrawable(context: Context, viewModel: UserViewModel): Drawable {
+        val layerDrawable =
+            checkNotNull(
+                    ResourcesCompat.getDrawable(
+                        context.resources,
+                        R.drawable.user_switcher_icon_large,
+                        context.theme,
+                    )
+                )
+                .mutate() as LayerDrawable
+        if (viewModel.isSelectionMarkerVisible) {
+            (layerDrawable.findDrawableByLayerId(R.id.ring) as GradientDrawable).apply {
+                val stroke =
+                    context.resources.getDimensionPixelSize(
+                        R.dimen.user_switcher_icon_selected_width
+                    )
+                val color =
+                    Utils.getColorAttrDefaultColor(
+                        context,
+                        com.android.internal.R.attr.colorAccentPrimary
+                    )
+
+                setStroke(stroke, color)
+            }
+        }
+
+        layerDrawable.setDrawableByLayerId(R.id.user_avatar, viewModel.image)
+        return layerDrawable
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt
new file mode 100644
index 0000000..149b1ff
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.systemui.user.ui.viewmodel
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+
+/** Models UI state for an action that can be performed on a user. */
+data class UserActionViewModel(
+    /**
+     * Key to use with the view or compose system to keep track of the view/composable across
+     * changes to the collection of [UserActionViewModel] instances.
+     */
+    val viewKey: Long,
+    @DrawableRes val iconResourceId: Int,
+    @StringRes val textResourceId: Int,
+    val onClicked: () -> Unit,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt
new file mode 100644
index 0000000..66ce01b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt
@@ -0,0 +1,199 @@
+/*
+ * 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.systemui.user.ui.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import com.android.systemui.R
+import com.android.systemui.power.domain.interactor.PowerInteractor
+import com.android.systemui.user.domain.interactor.UserInteractor
+import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.user.shared.model.UserModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+
+/** Models UI state for the user switcher feature. */
+class UserSwitcherViewModel
+private constructor(
+    private val userInteractor: UserInteractor,
+    private val powerInteractor: PowerInteractor,
+) : ViewModel() {
+
+    /** On-device users. */
+    val users: Flow<List<UserViewModel>> =
+        userInteractor.users.map { models -> models.map { user -> toViewModel(user) } }
+
+    /** The maximum number of columns that the user selection grid should use. */
+    val maximumUserColumns: Flow<Int> =
+        users.map { LegacyUserUiHelper.getMaxUserSwitcherItemColumns(it.size) }
+
+    /** Whether the button to open the user action menu is visible. */
+    val isOpenMenuButtonVisible: Flow<Boolean> = userInteractor.actions.map { it.isNotEmpty() }
+
+    private val _isMenuVisible = MutableStateFlow(false)
+    /**
+     * Whether the user action menu should be shown. Once the action menu is dismissed/closed, the
+     * consumer must invoke [onMenuClosed].
+     */
+    val isMenuVisible: Flow<Boolean> = _isMenuVisible
+    /** The user action menu. */
+    val menu: Flow<List<UserActionViewModel>> =
+        userInteractor.actions.map { actions -> actions.map { action -> toViewModel(action) } }
+
+    private val hasCancelButtonBeenClicked = MutableStateFlow(false)
+
+    /**
+     * Whether the observer should finish the experience. Once consumed, [onFinished] must be called
+     * by the consumer.
+     */
+    val isFinishRequested: Flow<Boolean> = createFinishRequestedFlow()
+
+    /** Notifies that the user has clicked the cancel button. */
+    fun onCancelButtonClicked() {
+        hasCancelButtonBeenClicked.value = true
+    }
+
+    /**
+     * Notifies that the user experience is finished.
+     *
+     * Call this after consuming [isFinishRequested] with a `true` value in order to mark it as
+     * consumed such that the next consumer doesn't immediately finish itself.
+     */
+    fun onFinished() {
+        hasCancelButtonBeenClicked.value = false
+    }
+
+    /** Notifies that the user has clicked the "open menu" button. */
+    fun onOpenMenuButtonClicked() {
+        _isMenuVisible.value = true
+    }
+
+    /**
+     * Notifies that the user has dismissed or closed the user action menu.
+     *
+     * Call this after consuming [isMenuVisible] with a `true` value in order to reset it to `false`
+     * such that the next consumer doesn't immediately show the menu again.
+     */
+    fun onMenuClosed() {
+        _isMenuVisible.value = false
+    }
+
+    private fun createFinishRequestedFlow(): Flow<Boolean> {
+        var mostRecentSelectedUserId: Int? = null
+        var mostRecentIsInteractive: Boolean? = null
+
+        return combine(
+            // When the user is switched, we should finish.
+            userInteractor.selectedUser
+                .map { it.id }
+                .map {
+                    val selectedUserChanged =
+                        mostRecentSelectedUserId != null && mostRecentSelectedUserId != it
+                    mostRecentSelectedUserId = it
+                    selectedUserChanged
+                },
+            // When the screen turns off, we should finish.
+            powerInteractor.isInteractive.map {
+                val screenTurnedOff = mostRecentIsInteractive == true && !it
+                mostRecentIsInteractive = it
+                screenTurnedOff
+            },
+            // When the cancel button is clicked, we should finish.
+            hasCancelButtonBeenClicked,
+        ) { selectedUserChanged, screenTurnedOff, cancelButtonClicked ->
+            selectedUserChanged || screenTurnedOff || cancelButtonClicked
+        }
+    }
+
+    private fun toViewModel(
+        model: UserModel,
+    ): UserViewModel {
+        return UserViewModel(
+            viewKey = model.id,
+            name = model.name,
+            image = model.image,
+            isSelectionMarkerVisible = model.isSelected,
+            alpha =
+                if (model.isSelectable) {
+                    LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA
+                } else {
+                    LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA
+                },
+            onClicked = createOnSelectedCallback(model),
+        )
+    }
+
+    private fun toViewModel(
+        model: UserActionModel,
+    ): UserActionViewModel {
+        return UserActionViewModel(
+            viewKey = model.ordinal.toLong(),
+            iconResourceId =
+                if (model == UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) {
+                    R.drawable.ic_manage_users
+                } else {
+                    LegacyUserUiHelper.getUserSwitcherActionIconResourceId(
+                        isAddSupervisedUser = model == UserActionModel.ADD_SUPERVISED_USER,
+                        isAddUser = model == UserActionModel.ADD_USER,
+                        isGuest = model == UserActionModel.ENTER_GUEST_MODE,
+                    )
+                },
+            textResourceId =
+                if (model == UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) {
+                    R.string.manage_users
+                } else {
+                    LegacyUserUiHelper.getUserSwitcherActionTextResourceId(
+                        isGuest = model == UserActionModel.ENTER_GUEST_MODE,
+                        isGuestUserAutoCreated = userInteractor.isGuestUserAutoCreated,
+                        isGuestUserResetting = userInteractor.isGuestUserResetting,
+                        isAddSupervisedUser = model == UserActionModel.ADD_SUPERVISED_USER,
+                        isAddUser = model == UserActionModel.ADD_USER,
+                    )
+                },
+            onClicked = { userInteractor.executeAction(action = model) },
+        )
+    }
+
+    private fun createOnSelectedCallback(model: UserModel): (() -> Unit)? {
+        return if (!model.isSelectable) {
+            null
+        } else {
+            { userInteractor.selectUser(model.id) }
+        }
+    }
+
+    class Factory
+    @Inject
+    constructor(
+        private val userInteractor: UserInteractor,
+        private val powerInteractor: PowerInteractor,
+    ) : ViewModelProvider.Factory {
+        override fun <T : ViewModel> create(modelClass: Class<T>): T {
+            @Suppress("UNCHECKED_CAST")
+            return UserSwitcherViewModel(
+                userInteractor = userInteractor,
+                powerInteractor = powerInteractor,
+            )
+                as T
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt
new file mode 100644
index 0000000..d57bba0
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.systemui.user.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+import com.android.systemui.common.shared.model.Text
+
+/** Models UI state for representing a single user. */
+data class UserViewModel(
+    /**
+     * Key to use with the view or compose system to keep track of the view/composable across
+     * changes to the collection of [UserViewModel] instances.
+     */
+    val viewKey: Int,
+    val name: Text,
+    val image: Drawable,
+    /** Whether a marker should be shown to highlight that this user is the selected one. */
+    val isSelectionMarkerVisible: Boolean,
+    val alpha: Float,
+    val onClicked: (() -> Unit)?,
+)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt
new file mode 100644
index 0000000..15465f4
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.systemui.power.data.repository
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakePowerRepository(
+    initialInteractive: Boolean = true,
+) : PowerRepository {
+
+    private val _isInteractive = MutableStateFlow(initialInteractive)
+    override val isInteractive: Flow<Boolean> = _isInteractive.asStateFlow()
+
+    fun setInteractive(value: Boolean) {
+        _isInteractive.value = value
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt
new file mode 100644
index 0000000..249a91b
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt
@@ -0,0 +1,181 @@
+/*
+ * 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.systemui.power.data.repository
+
+import android.content.BroadcastReceiver
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.PowerManager
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.util.mockito.capture
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.isNull
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when` as whenever
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(JUnit4::class)
+class PowerRepositoryImplTest : SysuiTestCase() {
+
+    @Mock private lateinit var manager: PowerManager
+    @Mock private lateinit var dispatcher: BroadcastDispatcher
+    @Captor private lateinit var receiverCaptor: ArgumentCaptor<BroadcastReceiver>
+    @Captor private lateinit var filterCaptor: ArgumentCaptor<IntentFilter>
+
+    private lateinit var underTest: PowerRepositoryImpl
+
+    private var isInteractive = true
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        isInteractive = true
+        whenever(manager.isInteractive).then { isInteractive }
+
+        underTest = PowerRepositoryImpl(manager = manager, dispatcher = dispatcher)
+    }
+
+    @Test
+    fun `isInteractive - registers for broadcasts`() =
+        runBlocking(IMMEDIATE) {
+            val job = underTest.isInteractive.onEach {}.launchIn(this)
+
+            verifyRegistered()
+            assertThat(filterCaptor.value.hasAction(Intent.ACTION_SCREEN_ON)).isTrue()
+            assertThat(filterCaptor.value.hasAction(Intent.ACTION_SCREEN_OFF)).isTrue()
+
+            job.cancel()
+        }
+
+    @Test
+    fun `isInteractive - unregisters from broadcasts`() =
+        runBlocking(IMMEDIATE) {
+            val job = underTest.isInteractive.onEach {}.launchIn(this)
+            verifyRegistered()
+
+            job.cancel()
+
+            verify(dispatcher).unregisterReceiver(receiverCaptor.value)
+        }
+
+    @Test
+    fun `isInteractive - emits initial true value if screen was on`() =
+        runBlocking(IMMEDIATE) {
+            isInteractive = true
+            var value: Boolean? = null
+            val job = underTest.isInteractive.onEach { value = it }.launchIn(this)
+
+            verifyRegistered()
+
+            assertThat(value).isTrue()
+            job.cancel()
+        }
+
+    @Test
+    fun `isInteractive - emits initial false value if screen was off`() =
+        runBlocking(IMMEDIATE) {
+            isInteractive = false
+            var value: Boolean? = null
+            val job = underTest.isInteractive.onEach { value = it }.launchIn(this)
+
+            verifyRegistered()
+
+            assertThat(value).isFalse()
+            job.cancel()
+        }
+
+    @Test
+    fun `isInteractive - emits true when the screen turns on`() =
+        runBlocking(IMMEDIATE) {
+            var value: Boolean? = null
+            val job = underTest.isInteractive.onEach { value = it }.launchIn(this)
+            verifyRegistered()
+
+            isInteractive = true
+            receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_ON))
+
+            assertThat(value).isTrue()
+            job.cancel()
+        }
+
+    @Test
+    fun `isInteractive - emits false when the screen turns off`() =
+        runBlocking(IMMEDIATE) {
+            var value: Boolean? = null
+            val job = underTest.isInteractive.onEach { value = it }.launchIn(this)
+            verifyRegistered()
+
+            isInteractive = false
+            receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF))
+
+            assertThat(value).isFalse()
+            job.cancel()
+        }
+
+    @Test
+    fun `isInteractive - emits correctly over time`() =
+        runBlocking(IMMEDIATE) {
+            val values = mutableListOf<Boolean>()
+            val job = underTest.isInteractive.onEach(values::add).launchIn(this)
+            verifyRegistered()
+
+            isInteractive = false
+            receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF))
+            isInteractive = true
+            receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_ON))
+            isInteractive = false
+            receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF))
+
+            assertThat(values).isEqualTo(listOf(true, false, true, false))
+            job.cancel()
+        }
+
+    private fun verifyRegistered() {
+        // We must verify with all arguments, even those that are optional because they have default
+        // values because Mockito is forcing us to. Once we can use mockito-kotlin, we should be
+        // able to remove this.
+        verify(dispatcher)
+            .registerReceiver(
+                capture(receiverCaptor),
+                capture(filterCaptor),
+                isNull(),
+                isNull(),
+                anyInt(),
+                isNull(),
+            )
+    }
+
+    companion object {
+        private val IMMEDIATE = Dispatchers.Main.immediate
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.kt
new file mode 100644
index 0000000..bf6a37e
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.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.systemui.power.domain.interactor
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.power.data.repository.FakePowerRepository
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@SmallTest
+@RunWith(JUnit4::class)
+class PowerInteractorTest : SysuiTestCase() {
+
+    private lateinit var underTest: PowerInteractor
+    private lateinit var repository: FakePowerRepository
+
+    @Before
+    fun setUp() {
+        repository =
+            FakePowerRepository(
+                initialInteractive = true,
+            )
+        underTest = PowerInteractor(repository = repository)
+    }
+
+    @Test
+    fun `isInteractive - screen turns off`() =
+        runBlocking(IMMEDIATE) {
+            repository.setInteractive(true)
+            var value: Boolean? = null
+            val job = underTest.isInteractive.onEach { value = it }.launchIn(this)
+
+            repository.setInteractive(false)
+
+            assertThat(value).isFalse()
+            job.cancel()
+        }
+
+    @Test
+    fun `isInteractive - becomes interactive`() =
+        runBlocking(IMMEDIATE) {
+            repository.setInteractive(false)
+            var value: Boolean? = null
+            val job = underTest.isInteractive.onEach { value = it }.launchIn(this)
+
+            repository.setInteractive(true)
+
+            assertThat(value).isTrue()
+            job.cancel()
+        }
+
+    companion object {
+        private val IMMEDIATE = Dispatchers.Main.immediate
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt
index 5db3b9c..da52a9b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt
@@ -53,7 +53,6 @@
     @Mock private lateinit var mUserDetailItemView: UserDetailItemView
     @Mock private lateinit var mOtherView: View
     @Mock private lateinit var mInflatedUserDetailItemView: UserDetailItemView
-    @Mock private lateinit var mUserInfo: UserInfo
     @Mock private lateinit var mLayoutInflater: LayoutInflater
     private var falsingManagerFake: FalsingManagerFake = FalsingManagerFake()
     private lateinit var adapter: UserDetailView.Adapter
@@ -142,7 +141,7 @@
 
     private fun createUserRecord(current: Boolean, guest: Boolean) =
         UserRecord(
-            mUserInfo,
+            UserInfo(0 /* id */, "name", 0 /* flags */),
             mPicture,
             guest,
             current,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcherAdapterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcherAdapterTest.kt
index c3805ad..8290dab 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcherAdapterTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcherAdapterTest.kt
@@ -57,8 +57,6 @@
     @Mock
     private lateinit var inflatedUserDetailItemView: KeyguardUserDetailItemView
     @Mock
-    private lateinit var userInfo: UserInfo
-    @Mock
     private lateinit var layoutInflater: LayoutInflater
     @Mock
     private lateinit var keyguardUserSwitcherController: KeyguardUserSwitcherController
@@ -188,7 +186,7 @@
 
     private fun createUserRecord(isCurrentUser: Boolean, isGuestUser: Boolean) =
         UserRecord(
-            userInfo,
+            UserInfo(0 /* id */, "name", 0 /* flags */),
             picture,
             isGuestUser,
             isCurrentUser,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/UserSwitcherActivityTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/UserSwitcherActivityTest.kt
index 66367ec..439beaa 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/user/UserSwitcherActivityTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/user/UserSwitcherActivityTest.kt
@@ -24,9 +24,11 @@
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.broadcast.BroadcastDispatcher
 import com.android.systemui.classifier.FalsingCollector
+import com.android.systemui.flags.FeatureFlags
 import com.android.systemui.plugins.FalsingManager
 import com.android.systemui.settings.UserTracker
 import com.android.systemui.statusbar.policy.UserSwitcherController
+import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
 import com.google.common.truth.Truth.assertThat
 import org.junit.Before
 import org.junit.Test
@@ -54,6 +56,10 @@
     private lateinit var userManager: UserManager
     @Mock
     private lateinit var userTracker: UserTracker
+    @Mock
+    private lateinit var flags: FeatureFlags
+    @Mock
+    private lateinit var viewModelFactoryLazy: dagger.Lazy<UserSwitcherViewModel.Factory>
 
     @Before
     fun setUp() {
@@ -61,11 +67,12 @@
         activity = UserSwitcherActivity(
             userSwitcherController,
             broadcastDispatcher,
-            layoutInflater,
             falsingCollector,
             falsingManager,
             userManager,
-            userTracker
+            userTracker,
+            flags,
+            viewModelFactoryLazy,
         )
     }
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt
new file mode 100644
index 0000000..20f1e36
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.systemui.user.data.repository
+
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.user.shared.model.UserModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+
+class FakeUserRepository : UserRepository {
+
+    private val _users = MutableStateFlow<List<UserModel>>(emptyList())
+    override val users: Flow<List<UserModel>> = _users.asStateFlow()
+    override val selectedUser: Flow<UserModel> =
+        users.map { models -> models.first { model -> model.isSelected } }
+
+    private val _actions = MutableStateFlow<List<UserActionModel>>(emptyList())
+    override val actions: Flow<List<UserActionModel>> = _actions.asStateFlow()
+
+    private val _isActionableWhenLocked = MutableStateFlow(false)
+    override val isActionableWhenLocked: Flow<Boolean> = _isActionableWhenLocked.asStateFlow()
+
+    private var _isGuestUserAutoCreated: Boolean = false
+    override val isGuestUserAutoCreated: Boolean
+        get() = _isGuestUserAutoCreated
+    private var _isGuestUserResetting: Boolean = false
+    override val isGuestUserResetting: Boolean
+        get() = _isGuestUserResetting
+
+    fun setUsers(models: List<UserModel>) {
+        _users.value = models
+    }
+
+    fun setSelectedUser(userId: Int) {
+        check(_users.value.find { it.id == userId } != null) {
+            "Cannot select a user with ID $userId - no user with that ID found!"
+        }
+
+        setUsers(
+            _users.value.map { model ->
+                when {
+                    model.isSelected && model.id != userId -> model.copy(isSelected = false)
+                    !model.isSelected && model.id == userId -> model.copy(isSelected = true)
+                    else -> model
+                }
+            }
+        )
+    }
+
+    fun setActions(models: List<UserActionModel>) {
+        _actions.value = models
+    }
+
+    fun setActionableWhenLocked(value: Boolean) {
+        _isActionableWhenLocked.value = value
+    }
+
+    fun setGuestUserAutoCreated(value: Boolean) {
+        _isGuestUserAutoCreated = value
+    }
+
+    fun setGuestUserResetting(value: Boolean) {
+        _isGuestUserResetting = value
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt
new file mode 100644
index 0000000..6b466e1
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt
@@ -0,0 +1,217 @@
+/*
+ * 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.systemui.user.data.repository
+
+import android.content.pm.UserInfo
+import android.os.UserManager
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.statusbar.policy.UserSwitcherController
+import com.android.systemui.user.data.source.UserRecord
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.user.shared.model.UserModel
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.capture
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when` as whenever
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(JUnit4::class)
+class UserRepositoryImplTest : SysuiTestCase() {
+
+    @Mock private lateinit var manager: UserManager
+    @Mock private lateinit var controller: UserSwitcherController
+    @Captor
+    private lateinit var userSwitchCallbackCaptor:
+        ArgumentCaptor<UserSwitcherController.UserSwitchCallback>
+
+    private lateinit var underTest: UserRepositoryImpl
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        whenever(controller.addUsersFromLockScreen).thenReturn(MutableStateFlow(false))
+        whenever(controller.isGuestUserAutoCreated).thenReturn(false)
+        whenever(controller.isGuestUserResetting).thenReturn(false)
+
+        underTest =
+            UserRepositoryImpl(
+                appContext = context,
+                manager = manager,
+                controller = controller,
+            )
+    }
+
+    @Test
+    fun `users - registers for updates`() =
+        runBlocking(IMMEDIATE) {
+            val job = underTest.users.onEach {}.launchIn(this)
+
+            verify(controller).addUserSwitchCallback(any())
+
+            job.cancel()
+        }
+
+    @Test
+    fun `users - unregisters from updates`() =
+        runBlocking(IMMEDIATE) {
+            val job = underTest.users.onEach {}.launchIn(this)
+            verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor))
+
+            job.cancel()
+
+            verify(controller).removeUserSwitchCallback(userSwitchCallbackCaptor.value)
+        }
+
+    @Test
+    fun `users - does not include actions`() =
+        runBlocking(IMMEDIATE) {
+            whenever(controller.users)
+                .thenReturn(
+                    arrayListOf(
+                        createUserRecord(0, isSelected = true),
+                        createActionRecord(UserActionModel.ADD_USER),
+                        createUserRecord(1),
+                        createUserRecord(2),
+                        createActionRecord(UserActionModel.ADD_SUPERVISED_USER),
+                        createActionRecord(UserActionModel.ENTER_GUEST_MODE),
+                    )
+                )
+            var models: List<UserModel>? = null
+            val job = underTest.users.onEach { models = it }.launchIn(this)
+
+            assertThat(models).hasSize(3)
+            assertThat(models?.get(0)?.id).isEqualTo(0)
+            assertThat(models?.get(0)?.isSelected).isTrue()
+            assertThat(models?.get(1)?.id).isEqualTo(1)
+            assertThat(models?.get(1)?.isSelected).isFalse()
+            assertThat(models?.get(2)?.id).isEqualTo(2)
+            assertThat(models?.get(2)?.isSelected).isFalse()
+            job.cancel()
+        }
+
+    @Test
+    fun selectedUser() =
+        runBlocking(IMMEDIATE) {
+            whenever(controller.users)
+                .thenReturn(
+                    arrayListOf(
+                        createUserRecord(0, isSelected = true),
+                        createUserRecord(1),
+                        createUserRecord(2),
+                    )
+                )
+            var id: Int? = null
+            val job = underTest.selectedUser.map { it.id }.onEach { id = it }.launchIn(this)
+
+            assertThat(id).isEqualTo(0)
+
+            whenever(controller.users)
+                .thenReturn(
+                    arrayListOf(
+                        createUserRecord(0),
+                        createUserRecord(1),
+                        createUserRecord(2, isSelected = true),
+                    )
+                )
+            verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor))
+            userSwitchCallbackCaptor.value.onUserSwitched()
+            assertThat(id).isEqualTo(2)
+
+            job.cancel()
+        }
+
+    @Test
+    fun `actions - unregisters from updates`() =
+        runBlocking(IMMEDIATE) {
+            val job = underTest.actions.onEach {}.launchIn(this)
+            verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor))
+
+            job.cancel()
+
+            verify(controller).removeUserSwitchCallback(userSwitchCallbackCaptor.value)
+        }
+
+    @Test
+    fun `actions - registers for updates`() =
+        runBlocking(IMMEDIATE) {
+            val job = underTest.actions.onEach {}.launchIn(this)
+
+            verify(controller).addUserSwitchCallback(any())
+
+            job.cancel()
+        }
+
+    @Test
+    fun `actopms - does not include users`() =
+        runBlocking(IMMEDIATE) {
+            whenever(controller.users)
+                .thenReturn(
+                    arrayListOf(
+                        createUserRecord(0, isSelected = true),
+                        createActionRecord(UserActionModel.ADD_USER),
+                        createUserRecord(1),
+                        createUserRecord(2),
+                        createActionRecord(UserActionModel.ADD_SUPERVISED_USER),
+                        createActionRecord(UserActionModel.ENTER_GUEST_MODE),
+                    )
+                )
+            var models: List<UserActionModel>? = null
+            val job = underTest.actions.onEach { models = it }.launchIn(this)
+
+            assertThat(models).hasSize(3)
+            assertThat(models?.get(0)).isEqualTo(UserActionModel.ADD_USER)
+            assertThat(models?.get(1)).isEqualTo(UserActionModel.ADD_SUPERVISED_USER)
+            assertThat(models?.get(2)).isEqualTo(UserActionModel.ENTER_GUEST_MODE)
+            job.cancel()
+        }
+
+    private fun createUserRecord(id: Int, isSelected: Boolean = false): UserRecord {
+        return UserRecord(
+            info = UserInfo(id, "name$id", 0),
+            isCurrent = isSelected,
+        )
+    }
+
+    private fun createActionRecord(action: UserActionModel): UserRecord {
+        return UserRecord(
+            isAddUser = action == UserActionModel.ADD_USER,
+            isAddSupervisedUser = action == UserActionModel.ADD_SUPERVISED_USER,
+            isGuest = action == UserActionModel.ENTER_GUEST_MODE,
+        )
+    }
+
+    companion object {
+        private val IMMEDIATE = Dispatchers.Main.immediate
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt
new file mode 100644
index 0000000..e914e2e
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt
@@ -0,0 +1,213 @@
+/*
+ * 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.systemui.user.domain.interactor
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository
+import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.statusbar.policy.UserSwitcherController
+import com.android.systemui.user.data.repository.FakeUserRepository
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.nullable
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.Mock
+import org.mockito.Mockito.anyBoolean
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(JUnit4::class)
+class UserInteractorTest : SysuiTestCase() {
+
+    @Mock private lateinit var controller: UserSwitcherController
+    @Mock private lateinit var activityStarter: ActivityStarter
+
+    private lateinit var underTest: UserInteractor
+
+    private lateinit var userRepository: FakeUserRepository
+    private lateinit var keyguardRepository: FakeKeyguardRepository
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        userRepository = FakeUserRepository()
+        keyguardRepository = FakeKeyguardRepository()
+        underTest =
+            UserInteractor(
+                repository = userRepository,
+                controller = controller,
+                activityStarter = activityStarter,
+                keyguardInteractor =
+                    KeyguardInteractor(
+                        repository = keyguardRepository,
+                    ),
+            )
+    }
+
+    @Test
+    fun `actions - not actionable when locked and locked - no actions`() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(UserActionModel.values().toList())
+            userRepository.setActionableWhenLocked(false)
+            keyguardRepository.setKeyguardShowing(true)
+
+            var actions: List<UserActionModel>? = null
+            val job = underTest.actions.onEach { actions = it }.launchIn(this)
+
+            assertThat(actions).isEmpty()
+            job.cancel()
+        }
+
+    @Test
+    fun `actions - not actionable when locked and not locked`() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(
+                listOf(
+                    UserActionModel.ENTER_GUEST_MODE,
+                    UserActionModel.ADD_USER,
+                    UserActionModel.ADD_SUPERVISED_USER,
+                )
+            )
+            userRepository.setActionableWhenLocked(false)
+            keyguardRepository.setKeyguardShowing(false)
+
+            var actions: List<UserActionModel>? = null
+            val job = underTest.actions.onEach { actions = it }.launchIn(this)
+
+            assertThat(actions)
+                .isEqualTo(
+                    listOf(
+                        UserActionModel.ENTER_GUEST_MODE,
+                        UserActionModel.ADD_USER,
+                        UserActionModel.ADD_SUPERVISED_USER,
+                        UserActionModel.NAVIGATE_TO_USER_MANAGEMENT,
+                    )
+                )
+            job.cancel()
+        }
+
+    @Test
+    fun `actions - actionable when locked and not locked`() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(
+                listOf(
+                    UserActionModel.ENTER_GUEST_MODE,
+                    UserActionModel.ADD_USER,
+                    UserActionModel.ADD_SUPERVISED_USER,
+                )
+            )
+            userRepository.setActionableWhenLocked(true)
+            keyguardRepository.setKeyguardShowing(false)
+
+            var actions: List<UserActionModel>? = null
+            val job = underTest.actions.onEach { actions = it }.launchIn(this)
+
+            assertThat(actions)
+                .isEqualTo(
+                    listOf(
+                        UserActionModel.ENTER_GUEST_MODE,
+                        UserActionModel.ADD_USER,
+                        UserActionModel.ADD_SUPERVISED_USER,
+                        UserActionModel.NAVIGATE_TO_USER_MANAGEMENT,
+                    )
+                )
+            job.cancel()
+        }
+
+    @Test
+    fun `actions - actionable when locked and locked`() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(
+                listOf(
+                    UserActionModel.ENTER_GUEST_MODE,
+                    UserActionModel.ADD_USER,
+                    UserActionModel.ADD_SUPERVISED_USER,
+                )
+            )
+            userRepository.setActionableWhenLocked(true)
+            keyguardRepository.setKeyguardShowing(true)
+
+            var actions: List<UserActionModel>? = null
+            val job = underTest.actions.onEach { actions = it }.launchIn(this)
+
+            assertThat(actions)
+                .isEqualTo(
+                    listOf(
+                        UserActionModel.ENTER_GUEST_MODE,
+                        UserActionModel.ADD_USER,
+                        UserActionModel.ADD_SUPERVISED_USER,
+                        UserActionModel.NAVIGATE_TO_USER_MANAGEMENT,
+                    )
+                )
+            job.cancel()
+        }
+
+    @Test
+    fun selectUser() {
+        val userId = 3
+
+        underTest.selectUser(userId)
+
+        verify(controller).onUserSelected(eq(userId), nullable())
+    }
+
+    @Test
+    fun `executeAction - guest`() {
+        underTest.executeAction(UserActionModel.ENTER_GUEST_MODE)
+
+        verify(controller).createAndSwitchToGuestUser(nullable())
+    }
+
+    @Test
+    fun `executeAction - add user`() {
+        underTest.executeAction(UserActionModel.ADD_USER)
+
+        verify(controller).showAddUserDialog(nullable())
+    }
+
+    @Test
+    fun `executeAction - add supervised user`() {
+        underTest.executeAction(UserActionModel.ADD_SUPERVISED_USER)
+
+        verify(controller).startSupervisedUserActivity()
+    }
+
+    @Test
+    fun `executeAction - manage users`() {
+        underTest.executeAction(UserActionModel.NAVIGATE_TO_USER_MANAGEMENT)
+
+        verify(activityStarter).startActivity(any(), anyBoolean())
+    }
+
+    companion object {
+        private val IMMEDIATE = Dispatchers.Main.immediate
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt
new file mode 100644
index 0000000..ef4500d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt
@@ -0,0 +1,297 @@
+/*
+ * 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.systemui.user.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.common.shared.model.Text
+import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository
+import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.power.data.repository.FakePowerRepository
+import com.android.systemui.power.domain.interactor.PowerInteractor
+import com.android.systemui.statusbar.policy.UserSwitcherController
+import com.android.systemui.user.data.repository.FakeUserRepository
+import com.android.systemui.user.domain.interactor.UserInteractor
+import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper
+import com.android.systemui.user.shared.model.UserActionModel
+import com.android.systemui.user.shared.model.UserModel
+import com.android.systemui.util.mockito.mock
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(JUnit4::class)
+class UserSwitcherViewModelTest : SysuiTestCase() {
+
+    @Mock private lateinit var controller: UserSwitcherController
+    @Mock private lateinit var activityStarter: ActivityStarter
+
+    private lateinit var underTest: UserSwitcherViewModel
+
+    private lateinit var userRepository: FakeUserRepository
+    private lateinit var keyguardRepository: FakeKeyguardRepository
+    private lateinit var powerRepository: FakePowerRepository
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        userRepository = FakeUserRepository()
+        keyguardRepository = FakeKeyguardRepository()
+        powerRepository = FakePowerRepository()
+        underTest =
+            UserSwitcherViewModel.Factory(
+                    userInteractor =
+                        UserInteractor(
+                            repository = userRepository,
+                            controller = controller,
+                            activityStarter = activityStarter,
+                            keyguardInteractor =
+                                KeyguardInteractor(
+                                    repository = keyguardRepository,
+                                )
+                        ),
+                    powerInteractor =
+                        PowerInteractor(
+                            repository = powerRepository,
+                        ),
+                )
+                .create(UserSwitcherViewModel::class.java)
+    }
+
+    @Test
+    fun users() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setUsers(
+                listOf(
+                    UserModel(
+                        id = 0,
+                        name = Text.Loaded("zero"),
+                        image = USER_IMAGE,
+                        isSelected = true,
+                        isSelectable = true,
+                    ),
+                    UserModel(
+                        id = 1,
+                        name = Text.Loaded("one"),
+                        image = USER_IMAGE,
+                        isSelected = false,
+                        isSelectable = true,
+                    ),
+                    UserModel(
+                        id = 2,
+                        name = Text.Loaded("two"),
+                        image = USER_IMAGE,
+                        isSelected = false,
+                        isSelectable = false,
+                    ),
+                )
+            )
+
+            var userViewModels: List<UserViewModel>? = null
+            val job = underTest.users.onEach { userViewModels = it }.launchIn(this)
+
+            assertThat(userViewModels).hasSize(3)
+            assertUserViewModel(
+                viewModel = userViewModels?.get(0),
+                viewKey = 0,
+                name = "zero",
+                isSelectionMarkerVisible = true,
+                alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA,
+                isClickable = true,
+            )
+            assertUserViewModel(
+                viewModel = userViewModels?.get(1),
+                viewKey = 1,
+                name = "one",
+                isSelectionMarkerVisible = false,
+                alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA,
+                isClickable = true,
+            )
+            assertUserViewModel(
+                viewModel = userViewModels?.get(2),
+                viewKey = 2,
+                name = "two",
+                isSelectionMarkerVisible = false,
+                alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA,
+                isClickable = false,
+            )
+            job.cancel()
+        }
+
+    @Test
+    fun `maximumUserColumns - few users`() =
+        runBlocking(IMMEDIATE) {
+            setUsers(count = 2)
+            var value: Int? = null
+            val job = underTest.maximumUserColumns.onEach { value = it }.launchIn(this)
+
+            assertThat(value).isEqualTo(4)
+            job.cancel()
+        }
+
+    @Test
+    fun `maximumUserColumns - many users`() =
+        runBlocking(IMMEDIATE) {
+            setUsers(count = 5)
+            var value: Int? = null
+            val job = underTest.maximumUserColumns.onEach { value = it }.launchIn(this)
+
+            assertThat(value).isEqualTo(3)
+            job.cancel()
+        }
+
+    @Test
+    fun `isOpenMenuButtonVisible - has actions - true`() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(UserActionModel.values().toList())
+
+            var isVisible: Boolean? = null
+            val job = underTest.isOpenMenuButtonVisible.onEach { isVisible = it }.launchIn(this)
+
+            assertThat(isVisible).isTrue()
+            job.cancel()
+        }
+
+    @Test
+    fun `isOpenMenuButtonVisible - no actions - false`() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(emptyList())
+
+            var isVisible: Boolean? = null
+            val job = underTest.isOpenMenuButtonVisible.onEach { isVisible = it }.launchIn(this)
+
+            assertThat(isVisible).isFalse()
+            job.cancel()
+        }
+
+    @Test
+    fun menu() =
+        runBlocking(IMMEDIATE) {
+            userRepository.setActions(UserActionModel.values().toList())
+            var isMenuVisible: Boolean? = null
+            val job = underTest.isMenuVisible.onEach { isMenuVisible = it }.launchIn(this)
+            assertThat(isMenuVisible).isFalse()
+
+            underTest.onOpenMenuButtonClicked()
+            assertThat(isMenuVisible).isTrue()
+
+            underTest.onMenuClosed()
+            assertThat(isMenuVisible).isFalse()
+
+            job.cancel()
+        }
+
+    @Test
+    fun `isFinishRequested - finishes when user is switched`() =
+        runBlocking(IMMEDIATE) {
+            setUsers(count = 2)
+            var isFinishRequested: Boolean? = null
+            val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this)
+            assertThat(isFinishRequested).isFalse()
+
+            userRepository.setSelectedUser(1)
+            yield()
+            assertThat(isFinishRequested).isTrue()
+
+            job.cancel()
+        }
+
+    @Test
+    fun `isFinishRequested - finishes when the screen turns off`() =
+        runBlocking(IMMEDIATE) {
+            setUsers(count = 2)
+            powerRepository.setInteractive(true)
+            var isFinishRequested: Boolean? = null
+            val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this)
+            assertThat(isFinishRequested).isFalse()
+
+            powerRepository.setInteractive(false)
+            yield()
+            assertThat(isFinishRequested).isTrue()
+
+            job.cancel()
+        }
+
+    @Test
+    fun `isFinishRequested - finishes when cancel button is clicked`() =
+        runBlocking(IMMEDIATE) {
+            setUsers(count = 2)
+            powerRepository.setInteractive(true)
+            var isFinishRequested: Boolean? = null
+            val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this)
+            assertThat(isFinishRequested).isFalse()
+
+            underTest.onCancelButtonClicked()
+            yield()
+            assertThat(isFinishRequested).isTrue()
+
+            underTest.onFinished()
+            yield()
+            assertThat(isFinishRequested).isFalse()
+
+            job.cancel()
+        }
+
+    private fun setUsers(count: Int) {
+        userRepository.setUsers(
+            (0 until count).map { index ->
+                UserModel(
+                    id = index,
+                    name = Text.Loaded("$index"),
+                    image = USER_IMAGE,
+                    isSelected = index == 0,
+                    isSelectable = true,
+                )
+            }
+        )
+    }
+
+    private fun assertUserViewModel(
+        viewModel: UserViewModel?,
+        viewKey: Int,
+        name: String,
+        isSelectionMarkerVisible: Boolean,
+        alpha: Float,
+        isClickable: Boolean,
+    ) {
+        checkNotNull(viewModel)
+        assertThat(viewModel.viewKey).isEqualTo(viewKey)
+        assertThat(viewModel.name).isEqualTo(Text.Loaded(name))
+        assertThat(viewModel.isSelectionMarkerVisible).isEqualTo(isSelectionMarkerVisible)
+        assertThat(viewModel.alpha).isEqualTo(alpha)
+        assertThat(viewModel.onClicked != null).isEqualTo(isClickable)
+    }
+
+    companion object {
+        private val IMMEDIATE = Dispatchers.Main.immediate
+        private val USER_IMAGE = mock<Drawable>()
+    }
+}