From ee3de9c2a4a0b9cafb7e5082fc2d169a38bad757 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:38:30 -0700 Subject: [PATCH 1/7] Prepares UserSwitcherController. UserSwitcherActivity refactor: CL 1/7 - Extracts some logic out of UserSwitcherController and into LegacyUserUiHelper. This logic will later be used by classes in the modern architecture refactor of UserSwitcherActivity. The code is being moved to a common place to avoid code duplication. - Exposes some new APIs in UserSwitcherController to facilitate user selection, expose guest-specific and lock-screen specific configuration, and trigger dialog-based user journeys such as adding a user Bug: 243844359 Test: Existing unit tests still pass. Manually verified in CL 7/7. Change-Id: Iab77acb20781b291289a59389db7b542fdfe0a47 --- .../policy/UserSwitcherController.java | 156 ++++++++++-------- .../systemui/user/data/source/UserRecord.kt | 28 +++- .../legacyhelper/ui/LegacyUserUiHelper.kt | 130 +++++++++++++++ .../qs/tiles/UserDetailViewAdapterTest.kt | 3 +- .../policy/KeyguardUserSwitcherAdapterTest.kt | 4 +- 5 files changed, 241 insertions(+), 80 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt 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 a5bcb538cd8c1..62bda2c76e9fe 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.statusbar.phone.SystemUIDialog; 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 java.util.function.Consumer; 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 @@ public class UserSwitcherController implements Dumpable { 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 mAddUsersFromLockScreen = + StateFlowKt.MutableStateFlow(false); private boolean mUserSwitcherEnabled; @VisibleForTesting boolean mPauseRefreshUsers; @@ -258,8 +266,11 @@ public class UserSwitcherController implements Dumpable { @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 @@ public class UserSwitcherController implements Dumpable { } mForcePictureLoadForUserId.clear(); - final boolean addUsersWhenLocked = mAddUsersFromLockScreen; mBgExecutor.execute(() -> { List infos = mUserManager.getAliveUsers(); if (infos == null) { @@ -434,7 +444,7 @@ public class UserSwitcherController implements Dumpable { } boolean anyoneCanCreateUsers() { - return systemCanCreateUsers() && mAddUsersFromLockScreen; + return systemCanCreateUsers() && mAddUsersFromLockScreen.getValue(); } boolean canCreateGuest(boolean hasExistingGuest) { @@ -450,7 +460,7 @@ public class UserSwitcherController implements Dumpable { } boolean createIsRestricted() { - return !mAddUsersFromLockScreen; + return !mAddUsersFromLockScreen.getValue(); } boolean canCreateSupervisedUser() { @@ -516,17 +526,48 @@ public class UserSwitcherController implements Dumpable { return null; } + /** + * Notifies that a user has been selected. + * + *

This will trigger the right user journeys to create a guest user, switch users, and/or + * navigate to the correct destination. + * + *

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 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 @@ public class UserSwitcherController implements Dumpable { } } - 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 @@ public class UserSwitcherController implements Dumpable { } } - 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 @@ public class UserSwitcherController implements Dumpable { * Removes guest user and switches to target user. The guest must be the current user and its id * must be {@code guestUserId}. * - *

If {@code targetUserId} is {@link UserHandle.USER_NULL}, then create a new guest user in + *

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 @@ public class UserSwitcherController implements Dumpable { *

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 @@ public class UserSwitcherController implements Dumpable { * 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 @@ public class UserSwitcherController implements Dumpable { * {@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 class UserSwitcherController implements Dumpable { } 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 @@ public class UserSwitcherController implements Dumpable { } 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/data/source/UserRecord.kt b/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt index 6ab6d7d7891ad..67999f381a0ed 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 @@ -26,28 +26,31 @@ import android.os.UserHandle data class UserRecord( /** Relevant user information. If `null`, this record is not a user but an option item. */ @JvmField - val info: UserInfo?, + val info: UserInfo? = null, /** An image representing the user. */ @JvmField - val picture: Bitmap?, + val picture: Bitmap? = null, /** Whether this record represents an option to switch to a guest user. */ @JvmField - val isGuest: Boolean, + val isGuest: Boolean = false, /** Whether this record represents the currently-selected user. */ @JvmField - val isCurrent: Boolean, + 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. */ + val isAddUser: Boolean = false, + /** + * 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, + val isRestricted: Boolean = false, /** Whether it is possible to switch to this user. */ @JvmField - val isSwitchToEnabled: Boolean, + val isSwitchToEnabled: Boolean = false, /** Whether this record represents an option to add another supervised user to the device. */ @JvmField - val isAddSupervisedUser: Boolean, + val isAddSupervisedUser: Boolean = false, ) { /** * Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. @@ -67,4 +70,11 @@ data class UserRecord( info.id } } + + companion object { + @JvmStatic + fun createForGuest(): UserRecord { + return UserRecord(isGuest = true) + } + } } 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 0000000000000..18369d9a71d22 --- /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/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt index 5db3b9caa007b..da52a9b1a3c29 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 @@ class UserDetailViewAdapterTest : SysuiTestCase() { @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 @@ class UserDetailViewAdapterTest : SysuiTestCase() { 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 c3805ad365334..8290dab19bdfa 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 @@ class KeyguardUserSwitcherAdapterTest : SysuiTestCase() { @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 @@ class KeyguardUserSwitcherAdapterTest : SysuiTestCase() { private fun createUserRecord(isCurrentUser: Boolean, isGuestUser: Boolean) = UserRecord( - userInfo, + UserInfo(0 /* id */, "name", 0 /* flags */), picture, isGuestUser, isCurrentUser, From b120521585b237cb44e9b39d59bc0e3a1c765465 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:45:04 -0700 Subject: [PATCH 2/7] Adds Text to the common module. UserSwitcherActivity refactor: CL 2/7 Following the precedent set by Icon and ContentDescription, Text is a sealed class that can represent a string that is either already loaded into memory or is referenced to using a resource ID. This CL also includes the TextViewBinder. Bug: 243844359 Test: Manually tested as part of CL 7/7 in the chain Change-Id: I85bc6866fff9d37e573d4cdeaa74da8efbb35246 --- .../systemui/common/shared/model/Text.kt | 34 +++++++++++++++++++ .../common/ui/binder/TextViewBinder.kt | 31 +++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/common/shared/model/Text.kt create mode 100644 packages/SystemUI/src/com/android/systemui/common/ui/binder/TextViewBinder.kt 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 0000000000000..5d0e08ffc3078 --- /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 0000000000000..396e8bb5a60da --- /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 + } + } +} From 6a449c62055b3468411e4cbcb85abf3dc2e61ebf Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:49:25 -0700 Subject: [PATCH 3/7] Adds power repository and interactor. UserSwitcherActivity refactor: CL 3/7 Adds a repository and an interactor to expose screen on/off state to the rest of the app. This is needed by the user switcher as it finishes the activity if the screen turns off. Bug: 243844359 Test: Included unit tests. Change-Id: If428cae7be2c25639d43a56717b7e6e9f0b86bc5 --- .../systemui/power/dagger/PowerModule.java | 7 +- .../power/data/repository/PowerRepository.kt | 74 +++++++ .../data/repository/PowerRepositoryModule.kt | 26 +++ .../domain/interactor/PowerInteractor.kt | 34 ++++ .../data/repository/FakePowerRepository.kt | 34 ++++ .../repository/PowerRepositoryImplTest.kt | 181 ++++++++++++++++++ .../domain/interactor/PowerInteractorTest.kt | 78 ++++++++ 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepositoryModule.kt create mode 100644 packages/SystemUI/src/com/android/systemui/power/domain/interactor/PowerInteractor.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.kt 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 3709a86f2fa57..7184fa0685afe 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.EnhancedEstimates; 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 0000000000000..b2e04bb4f26f4 --- /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 +} + +@SysUISingleton +class PowerRepositoryImpl +@Inject +constructor( + manager: PowerManager, + dispatcher: BroadcastDispatcher, +) : PowerRepository { + + override val isInteractive: Flow = 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 0000000000000..491da65c0291c --- /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 0000000000000..3f799f724fe7a --- /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 = repository.isInteractive +} 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 0000000000000..15465f4d40fe1 --- /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 = _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 0000000000000..249a91b0982a0 --- /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 + @Captor private lateinit var filterCaptor: ArgumentCaptor + + 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() + 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 0000000000000..bf6a37ec8eff1 --- /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 + } +} From 2aca8b4dadccea062a296cdc2c2cf6bb7560de25 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:51:35 -0700 Subject: [PATCH 4/7] User data layer. UserSwitcherActivity refactor: CL 4/7 This is the data layer for the UserSwitcherActivity refactor. It includes a repository and a couple of shared models that the repository exposes. Bug: 243844359 Test: Unit tests included. Also tested as part of the big manual test in CL 7/7 in this chain Change-Id: I4cb5b1c4f018def88cda5864de51c644c6d8ce0f --- .../com/android/systemui/user/UserModule.java | 7 +- .../user/data/repository/UserRepository.kt | 166 ++++++++++++++ .../data/repository/UserRepositoryModule.kt | 26 +++ .../systemui/user/data/source/UserRecord.kt | 32 +-- .../user/shared/model/UserActionModel.kt | 25 ++ .../systemui/user/shared/model/UserModel.kt | 35 +++ .../data/repository/FakeUserRepository.kt | 82 +++++++ .../data/repository/UserRepositoryImplTest.kt | 217 ++++++++++++++++++ 8 files changed, 567 insertions(+), 23 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/user/UserModule.java b/packages/SystemUI/src/com/android/systemui/user/UserModule.java index 469d54ff8ffa2..5b522dcc4885e 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 @@ package com.android.systemui.user; 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 @@ import dagger.multibindings.IntoMap; /** * 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/data/repository/UserRepository.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt new file mode 100644 index 0000000000000..305b5ee920a16 --- /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> + + /** The currently-selected user. */ + val selectedUser: Flow + + /** List of available user-related actions. */ + val actions: Flow> + + /** Whether actions are available even when locked. */ + val isActionableWhenLocked: Flow + + /** 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> = conflatedCallbackFlow { + fun send() { + trySendWithFailureLogging( + controller.users, + TAG, + ) + } + + val callback = UserSwitcherController.UserSwitchCallback { send() } + + controller.addUserSwitchCallback(callback) + send() + + awaitClose { controller.removeUserSwitchCallback(callback) } + } + + override val users: Flow> = + userRecords.map { records -> records.filter { it.isUser() }.map { it.toUserModel() } } + + override val selectedUser: Flow = + users.map { users -> users.first { user -> user.isSelected } } + + override val actions: Flow> = + userRecords.map { records -> records.filter { it.isNotUser() }.map { it.toActionModel() } } + + override val isActionableWhenLocked: Flow = 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 0000000000000..18ae1070e1bb3 --- /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 67999f381a0ed..cf6da9a60d78d 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,41 +20,29 @@ import android.content.pm.UserInfo 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? = null, + @JvmField val info: UserInfo? = null, /** An image representing the user. */ - @JvmField - val picture: Bitmap? = null, + @JvmField val picture: Bitmap? = null, /** Whether this record represents an option to switch to a guest user. */ - @JvmField - val isGuest: Boolean = false, + @JvmField val isGuest: Boolean = false, /** Whether this record represents the currently-selected user. */ - @JvmField - val isCurrent: Boolean = false, + @JvmField val isCurrent: Boolean = false, /** Whether this record represents an option to add another user to the device. */ - @JvmField - val isAddUser: Boolean = false, + @JvmField val isAddUser: Boolean = false, /** * 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, + @JvmField val isRestricted: Boolean = false, /** Whether it is possible to switch to this user. */ - @JvmField - val isSwitchToEnabled: Boolean = false, + @JvmField val isSwitchToEnabled: Boolean = false, /** Whether this record represents an option to add another supervised user to the device. */ - @JvmField - val isAddSupervisedUser: Boolean = false, + @JvmField val isAddSupervisedUser: Boolean = false, ) { - /** - * Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. - */ + /** Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. */ fun copyWithIsCurrent(isCurrent: Boolean): UserRecord { return copy(isCurrent = isCurrent) } 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 0000000000000..823bf74dc0f08 --- /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 0000000000000..bf7977a600e99 --- /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/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 0000000000000..20f1e367944fb --- /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>(emptyList()) + override val users: Flow> = _users.asStateFlow() + override val selectedUser: Flow = + users.map { models -> models.first { model -> model.isSelected } } + + private val _actions = MutableStateFlow>(emptyList()) + override val actions: Flow> = _actions.asStateFlow() + + private val _isActionableWhenLocked = MutableStateFlow(false) + override val isActionableWhenLocked: Flow = _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) { + _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) { + _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 0000000000000..6b466e1ac2d87 --- /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 + + 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? = 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? = 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 + } +} From 2ee73627d23d933a9ae4981bb963d0a68cb2d7ea Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:54:11 -0700 Subject: [PATCH 5/7] User domain layer. UserSwitcherActivity refactor: CL 5/7 This CL adds the domain layer, which is just the UserInteractor really. Bug: 243844359 Test: Unit tests included. Also verified manually as part of the testing of CL 7/7 in the chain. Change-Id: I83e45a0060ab746874107c79bc8a1e2aeb07e409 --- .../user/domain/interactor/UserInteractor.kt | 110 +++++++++ .../domain/interactor/UserInteractorTest.kt | 213 ++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/user/domain/interactor/UserInteractor.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt 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 0000000000000..3c5b9697c0137 --- /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> = repository.users + + /** The currently-selected user. */ + val selectedUser: Flow = repository.selectedUser + + /** List of user-switcher related actions that are available. */ + val actions: Flow> = + 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/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 0000000000000..e914e2e0a1dad --- /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? = 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? = 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? = 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? = 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 + } +} From b6508925897fa1ebd3015b45db6b4b0cbedf03cd Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:55:30 -0700 Subject: [PATCH 6/7] User UI layer. UserSwitcherActivity refactor: CL 6/7 Includes view-models and view-binders for the user and user-switcher experiences. Bug: 243844359 Test: Integration tests included for the top-most view-model. Also manually tested with the next CL. Change-Id: Ia05c2f267a37475c409ffed9b9784cdc435a9d76 --- .../user/ui/binder/UserSwitcherViewBinder.kt | 220 +++++++++++++ .../systemui/user/ui/binder/UserViewBinder.kt | 77 +++++ .../user/ui/viewmodel/UserActionViewModel.kt | 33 ++ .../ui/viewmodel/UserSwitcherViewModel.kt | 199 ++++++++++++ .../user/ui/viewmodel/UserViewModel.kt | 36 +++ .../ui/viewmodel/UserSwitcherViewModelTest.kt | 297 ++++++++++++++++++ 6 files changed, 862 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt 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 0000000000000..83a3d0d0457a7 --- /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() + + 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(R.id.icon).setImageResource(viewModel.iconResourceId) + view.requireViewById(R.id.text).text = + view.resources.getString(viewModel.textResourceId) + return view + } + + fun setItems(items: List) { + 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 0000000000000..e78807e675b3d --- /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(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 0000000000000..149b1ffdaff0b --- /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 0000000000000..66ce01b7a86ef --- /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> = + userInteractor.users.map { models -> models.map { user -> toViewModel(user) } } + + /** The maximum number of columns that the user selection grid should use. */ + val maximumUserColumns: Flow = + users.map { LegacyUserUiHelper.getMaxUserSwitcherItemColumns(it.size) } + + /** Whether the button to open the user action menu is visible. */ + val isOpenMenuButtonVisible: Flow = 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 = _isMenuVisible + /** The user action menu. */ + val menu: Flow> = + 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 = 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 { + 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 create(modelClass: Class): 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 0000000000000..d57bba0fa86a2 --- /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/user/ui/viewmodel/UserSwitcherViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt new file mode 100644 index 0000000000000..ef4500df3600f --- /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? = 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() + } +} From 82606f8b2e0fd27aecb025846c10de11a4eec03c Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 31 Aug 2022 15:56:41 -0700 Subject: [PATCH 7/7] Wiring into UserSwitcherActivity. UserSwitcherActivity refactor: CL 7/7 This CL wires in the new modern architecture implementation into the activity that hosts the user switcher experience. It does so behind a feature flag and keeps the old implementation unchanged. Some care has been taken to do minimal changes to the code to keep it from changing too much as part of this CL. At the same time, we needed to make some changes to avoid side-effects and work that this activity would otherwise do (for example, the adapter was moved behind a "lazy" so it doesn't get instantiated in the new implementation as instantiating it registers it for updates with UserSwitcherController which can later cause exceptions if the adapter isn't properly hooked up). Bug: 243844359 Test: extensive manual testing, see the following lines for more. 1. No-harm testing for the old implementation. With the flag off, made sure that all the behaviours detailed below work the same after this CL as they worked before. 2. New implementation matches old implementation. User journeys exercised: a. Starting from the lock-screen or after unlocking and with and without Settings > System > Multiple Users > "Add users from lock screen" set - made sure that the "Add" button only appears when allowed b. Orientation changes. Made sure that the activity keeps its state and there are no crashes. c. Selecting a different user. Made sure that the system switches to the other user and that our activity exits as expected. d. Turning off the screen causes the activity to exit. e. Touching the cancel button causes our activity to exit. f. Clicking Add > Guest properly switches into the guest user and exits the activity. Returning to the activity properly shows the guest use as the current user with the name "Exit Guest" and tapping it properly goes back to the main user. Also, the menu options behind the "add" button are properly restricted to just "add user", "add child user", and "manage users" g. Add user, add child user, and manage users menu options all navigate the user to the correct destinations Change-Id: I874eff89a8a3b81d8d5a632837ce2ccad92c82a4 --- .../systemui/user/UserSwitcherActivity.kt | 261 ++++++++++-------- .../systemui/user/UserSwitcherActivityTest.kt | 11 +- 2 files changed, 160 insertions(+), 112 deletions(-) diff --git a/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt b/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt index ff0f0d48a7c5c..8a51cd6c7e948 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.graphics.drawable.LayerDrawable 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.ImageView 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.Gefingerpoken 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.BaseUserAdap 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 @@ private const val USER_VIEW = "user_view" 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, ) : ComponentActivity() { private lateinit var parent: UserSwitcherRootView @@ -93,119 +101,31 @@ class UserSwitcherActivity @Inject constructor( 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(R.id.user_switcher_root) @@ -346,11 +266,18 @@ class UserSwitcherActivity @Inject constructor( } 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 @@ class UserSwitcherActivity @Inject constructor( 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 @@ class UserSwitcherActivity @Inject constructor( 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/tests/src/com/android/systemui/user/UserSwitcherActivityTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/UserSwitcherActivityTest.kt index 66367ecfc95c7..439beaab6c7ea 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 androidx.test.filters.SmallTest 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 @@ class UserSwitcherActivityTest : SysuiTestCase() { private lateinit var userManager: UserManager @Mock private lateinit var userTracker: UserTracker + @Mock + private lateinit var flags: FeatureFlags + @Mock + private lateinit var viewModelFactoryLazy: dagger.Lazy @Before fun setUp() { @@ -61,11 +67,12 @@ class UserSwitcherActivityTest : SysuiTestCase() { activity = UserSwitcherActivity( userSwitcherController, broadcastDispatcher, - layoutInflater, falsingCollector, falsingManager, userManager, - userTracker + userTracker, + flags, + viewModelFactoryLazy, ) }