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>()
+ }
+}