diff options
31 files changed, 2643 insertions, 208 deletions
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 000000000000..5d0e08ffc307 --- /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 000000000000..396e8bb5a60d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/binder/TextViewBinder.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.common.ui.binder + +import android.widget.TextView +import com.android.systemui.common.shared.model.Text + +object TextViewBinder { + fun bind(view: TextView, viewModel: Text) { + view.text = + when (viewModel) { + is Text.Resource -> view.context.getString(viewModel.res) + is Text.Loaded -> viewModel.text + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/power/dagger/PowerModule.java b/packages/SystemUI/src/com/android/systemui/power/dagger/PowerModule.java index 3709a86f2fa5..7184fa0685af 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 000000000000..b2e04bb4f26f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.power.data.repository + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow + +/** Defines interface for classes that act as source of truth for power-related data. */ +interface PowerRepository { + /** Whether the device is interactive. Starts with the current state. */ + val isInteractive: Flow<Boolean> +} + +@SysUISingleton +class PowerRepositoryImpl +@Inject +constructor( + manager: PowerManager, + dispatcher: BroadcastDispatcher, +) : PowerRepository { + + override val isInteractive: Flow<Boolean> = conflatedCallbackFlow { + fun send() { + trySendWithFailureLogging(manager.isInteractive, TAG) + } + + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + send() + } + } + + dispatcher.registerReceiver( + receiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }, + ) + send() + + awaitClose { dispatcher.unregisterReceiver(receiver) } + } + + companion object { + private const val TAG = "PowerRepository" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/power/data/repository/PowerRepositoryModule.kt new file mode 100644 index 000000000000..491da65c0291 --- /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 000000000000..3f799f724fe7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/power/domain/interactor/PowerInteractor.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.power.domain.interactor + +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.power.data.repository.PowerRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +/** Hosts business logic for interacting with the power system. */ +@SysUISingleton +class PowerInteractor +@Inject +constructor( + repository: PowerRepository, +) { + /** Whether the screen is on or off. */ + val isInteractive: Flow<Boolean> = repository.isInteractive +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java index e2d16015235e..3b8ed338db80 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<Boolean> 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<UserInfo> 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. + * + * <p>This will trigger the right user journeys to create a guest user, switch users, and/or + * navigate to the correct destination. + * + * <p>If a user with the given ID is not found, this method is a no-op. + * + * @param userId The ID of the user to switch to. + * @param dialogShower An optional {@link DialogShower} in case we need to show dialogs. + */ + public void onUserSelected(int userId, @Nullable DialogShower dialogShower) { + UserRecord userRecord = mUsers.stream() + .filter(x -> x.resolveId() == userId) + .findFirst() + .orElse(null); + if (userRecord == null) { + return; + } + + onUserListItemClicked(userRecord, dialogShower); + } + + /** Whether it is allowed to add users while the device is locked. */ + public Flow<Boolean> getAddUsersFromLockScreen() { + return mAddUsersFromLockScreen; + } + + /** Returns {@code true} if the guest user is configured to always be present on the device. */ + public boolean isGuestUserAutoCreated() { + return mGuestUserAutoCreated; + } + + /** Returns {@code true} if the guest user is currently being reset. */ + public boolean isGuestUserResetting() { + return mGuestIsResetting.get(); + } + @VisibleForTesting void onUserListItemClicked(UserRecord record, DialogShower dialogShower) { if (record.isGuest && record.info == null) { - // No guest user. Create one. - createGuestAsync(guestId -> { - // guestId may be USER_NULL if we haven't reloaded the user list yet. - if (guestId != UserHandle.USER_NULL) { - mUiEventLogger.log(QSUserSwitcherEvent.QS_USER_GUEST_ADD); - onUserListItemClicked(guestId, record, dialogShower); - } - }); + createAndSwitchToGuestUser(dialogShower); } else if (record.isAddUser) { showAddUserDialog(dialogShower); } else if (record.isAddSupervisedUser) { @@ -604,7 +645,23 @@ 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}. * - * <p>If {@code targetUserId} is {@link UserHandle.USER_NULL}, then create a new guest user in + * <p>If {@code targetUserId} is {@link UserHandle#USER_NULL}, then create a new guest user in * the foreground, and immediately switch to it. This is used for wiping the current guest and * replacing it with a new one. * @@ -782,11 +842,11 @@ public class UserSwitcherController implements Dumpable { * <p>If device is configured with {@link * com.android.internal.R.bool.config_guestUserAutoCreated}, then after guest user is removed, a * new one is created in the background. This has no effect if {@code targetUserId} is {@link - * UserHandle.USER_NULL}. + * UserHandle#USER_NULL}. * * @param guestUserId id of the guest user to remove * @param targetUserId id of the user to switch to after guest is removed. If {@link - * UserHandle.USER_NULL}, then switch immediately to the newly created guest user. + * UserHandle#USER_NULL}, then switch immediately to the newly created guest user. */ public void removeGuestUser(@UserIdInt int guestUserId, @UserIdInt int targetUserId) { UserInfo currentUser = mUserTracker.getUserInfo(); @@ -839,7 +899,7 @@ 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/UserModule.java b/packages/SystemUI/src/com/android/systemui/user/UserModule.java index 469d54ff8ffa..5b522dcc4885 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/UserSwitcherActivity.kt b/packages/SystemUI/src/com/android/systemui/user/UserSwitcherActivity.kt index ff0f0d48a7c5..8a51cd6c7e94 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<UserSwitcherViewModel.Factory>, ) : 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<UserSwitcherRootView>(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/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 000000000000..305b5ee920a1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.data.repository + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.UserManager +import androidx.appcompat.content.res.AppCompatResources +import com.android.internal.util.UserIcons +import com.android.systemui.R +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.common.shared.model.Text +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.source.UserRecord +import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Acts as source of truth for user related data. + * + * Abstracts-away data sources and their schemas so the rest of the app doesn't need to worry about + * upstream changes. + */ +interface UserRepository { + /** List of all users on the device. */ + val users: Flow<List<UserModel>> + + /** The currently-selected user. */ + val selectedUser: Flow<UserModel> + + /** List of available user-related actions. */ + val actions: Flow<List<UserActionModel>> + + /** Whether actions are available even when locked. */ + val isActionableWhenLocked: Flow<Boolean> + + /** Whether the device is configured to always have a guest user available. */ + val isGuestUserAutoCreated: Boolean + + /** Whether the guest user is currently being reset. */ + val isGuestUserResetting: Boolean +} + +@SysUISingleton +class UserRepositoryImpl +@Inject +constructor( + @Application private val appContext: Context, + private val manager: UserManager, + controller: UserSwitcherController, +) : UserRepository { + + private val userRecords: Flow<List<UserRecord>> = conflatedCallbackFlow { + fun send() { + trySendWithFailureLogging( + controller.users, + TAG, + ) + } + + val callback = UserSwitcherController.UserSwitchCallback { send() } + + controller.addUserSwitchCallback(callback) + send() + + awaitClose { controller.removeUserSwitchCallback(callback) } + } + + override val users: Flow<List<UserModel>> = + userRecords.map { records -> records.filter { it.isUser() }.map { it.toUserModel() } } + + override val selectedUser: Flow<UserModel> = + users.map { users -> users.first { user -> user.isSelected } } + + override val actions: Flow<List<UserActionModel>> = + userRecords.map { records -> records.filter { it.isNotUser() }.map { it.toActionModel() } } + + override val isActionableWhenLocked: Flow<Boolean> = controller.addUsersFromLockScreen + + override val isGuestUserAutoCreated: Boolean = controller.isGuestUserAutoCreated + + override val isGuestUserResetting: Boolean = controller.isGuestUserResetting + + private fun UserRecord.isUser(): Boolean { + return when { + isAddUser -> false + isAddSupervisedUser -> false + isGuest -> info != null + else -> true + } + } + + private fun UserRecord.isNotUser(): Boolean { + return !isUser() + } + + private fun UserRecord.toUserModel(): UserModel { + return UserModel( + id = resolveId(), + name = getUserName(this), + image = getUserImage(this), + isSelected = isCurrent, + isSelectable = isSwitchToEnabled || isGuest, + ) + } + + private fun UserRecord.toActionModel(): UserActionModel { + return when { + isAddUser -> UserActionModel.ADD_USER + isAddSupervisedUser -> UserActionModel.ADD_SUPERVISED_USER + isGuest -> UserActionModel.ENTER_GUEST_MODE + else -> error("Don't know how to convert to UserActionModel: $this") + } + } + + private fun getUserName(record: UserRecord): Text { + val resourceId: Int? = LegacyUserUiHelper.getGuestUserRecordNameResourceId(record) + return if (resourceId != null) { + Text.Resource(resourceId) + } else { + Text.Loaded(checkNotNull(record.info).name) + } + } + + private fun getUserImage(record: UserRecord): Drawable { + if (record.isGuest) { + return checkNotNull( + AppCompatResources.getDrawable(appContext, R.drawable.ic_account_circle) + ) + } + + val userId = checkNotNull(record.info?.id) + return manager.getUserIcon(userId)?.let { userSelectedIcon -> + BitmapDrawable(userSelectedIcon) + } + ?: UserIcons.getDefaultUserIcon(appContext.resources, userId, /* light= */ false) + } + + companion object { + private const val TAG = "UserRepository" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt new file mode 100644 index 000000000000..18ae1070e1bb --- /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 6ab6d7d7891a..cf6da9a60d78 100644 --- a/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt +++ b/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt @@ -20,38 +20,29 @@ import android.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?, + @JvmField val info: UserInfo? = null, /** An image representing the user. */ - @JvmField - val picture: Bitmap?, + @JvmField val picture: Bitmap? = null, /** Whether this record represents an option to switch to a guest user. */ - @JvmField - val isGuest: Boolean, + @JvmField val isGuest: Boolean = false, /** Whether this record represents the currently-selected user. */ - @JvmField - val isCurrent: Boolean, + @JvmField val isCurrent: Boolean = false, /** Whether this record represents an option to add another user to the device. */ - @JvmField - val isAddUser: Boolean, - /** If true, the record is only visible to the owner and only when unlocked. */ - @JvmField - val isRestricted: Boolean, + @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, /** Whether it is possible to switch to this user. */ - @JvmField - val isSwitchToEnabled: Boolean, + @JvmField val isSwitchToEnabled: Boolean = false, /** Whether this record represents an option to add another supervised user to the device. */ - @JvmField - val isAddSupervisedUser: Boolean, + @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) } @@ -67,4 +58,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/domain/interactor/UserInteractor.kt b/packages/SystemUI/src/com/android/systemui/user/domain/interactor/UserInteractor.kt new file mode 100644 index 000000000000..3c5b9697c013 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/domain/interactor/UserInteractor.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.domain.interactor + +import android.content.Intent +import android.provider.Settings +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.repository.UserRepository +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** Encapsulates business logic to interact with user data and systems. */ +@SysUISingleton +class UserInteractor +@Inject +constructor( + repository: UserRepository, + private val controller: UserSwitcherController, + private val activityStarter: ActivityStarter, + keyguardInteractor: KeyguardInteractor, +) { + /** List of current on-device users to select from. */ + val users: Flow<List<UserModel>> = repository.users + + /** The currently-selected user. */ + val selectedUser: Flow<UserModel> = repository.selectedUser + + /** List of user-switcher related actions that are available. */ + val actions: Flow<List<UserActionModel>> = + combine( + repository.isActionableWhenLocked, + keyguardInteractor.isKeyguardShowing, + ) { isActionableWhenLocked, isLocked -> + isActionableWhenLocked || !isLocked + } + .flatMapLatest { isActionable -> + if (isActionable) { + repository.actions.map { actions -> + actions + + if (actions.isNotEmpty()) { + // If we have actions, we add NAVIGATE_TO_USER_MANAGEMENT because + // that's a user + // switcher specific action that is not known to the our data source + // or other + // features. + listOf(UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) + } else { + // If no actions, don't add the navigate action. + emptyList() + } + } + } else { + // If not actionable it means that we're not allowed to show actions when locked + // and we + // are locked. Therefore, we should show no actions. + flowOf(emptyList()) + } + } + + /** Whether the device is configured to always have a guest user available. */ + val isGuestUserAutoCreated: Boolean = repository.isGuestUserAutoCreated + + /** Whether the guest user is currently being reset. */ + val isGuestUserResetting: Boolean = repository.isGuestUserResetting + + /** Switches to the user with the given user ID. */ + fun selectUser( + userId: Int, + ) { + controller.onUserSelected(userId, /* dialogShower= */ null) + } + + /** Executes the given action. */ + fun executeAction(action: UserActionModel) { + when (action) { + UserActionModel.ENTER_GUEST_MODE -> controller.createAndSwitchToGuestUser(null) + UserActionModel.ADD_USER -> controller.showAddUserDialog(null) + UserActionModel.ADD_SUPERVISED_USER -> controller.startSupervisedUserActivity() + UserActionModel.NAVIGATE_TO_USER_MANAGEMENT -> + activityStarter.startActivity( + Intent(Settings.ACTION_USER_SETTINGS), + /* dismissShade= */ false, + ) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt b/packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt new file mode 100644 index 000000000000..18369d9a71d2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/legacyhelper/ui/LegacyUserUiHelper.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.legacyhelper.ui + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.android.systemui.R +import com.android.systemui.user.data.source.UserRecord +import kotlin.math.ceil + +/** + * Defines utility functions for helping with legacy UI code for users. + * + * We need these to avoid code duplication between logic inside the UserSwitcherController and in + * modern architecture classes such as repositories, interactors, and view-models. If we ever + * simplify UserSwitcherController (or delete it), the code here could be moved into its call-sites. + */ +object LegacyUserUiHelper { + + /** Returns the maximum number of columns for user items in the user switcher. */ + fun getMaxUserSwitcherItemColumns(userCount: Int): Int { + // TODO(b/243844097): remove this once we remove the old user switcher implementation. + return if (userCount < 5) { + 4 + } else { + ceil(userCount / 2.0).toInt() + } + } + + @JvmStatic + @DrawableRes + fun getUserSwitcherActionIconResourceId( + isAddUser: Boolean, + isGuest: Boolean, + isAddSupervisedUser: Boolean, + ): Int { + return if (isAddUser) { + R.drawable.ic_add + } else if (isGuest) { + R.drawable.ic_account_circle + } else if (isAddSupervisedUser) { + R.drawable.ic_add_supervised_user + } else { + R.drawable.ic_avatar_user + } + } + + @JvmStatic + fun getUserRecordName( + context: Context, + record: UserRecord, + isGuestUserAutoCreated: Boolean, + isGuestUserResetting: Boolean, + ): String { + val resourceId: Int? = getGuestUserRecordNameResourceId(record) + return when { + resourceId != null -> context.getString(resourceId) + record.info != null -> record.info.name + else -> + context.getString( + getUserSwitcherActionTextResourceId( + isGuest = record.isGuest, + isGuestUserAutoCreated = isGuestUserAutoCreated, + isGuestUserResetting = isGuestUserResetting, + isAddUser = record.isAddUser, + isAddSupervisedUser = record.isAddSupervisedUser, + ) + ) + } + } + + /** + * Returns the resource ID for a string for the name of the guest user. + * + * If the given record is not the guest user, returns `null`. + */ + @StringRes + fun getGuestUserRecordNameResourceId(record: UserRecord): Int? { + return when { + record.isGuest && record.isCurrent -> + com.android.settingslib.R.string.guest_exit_quick_settings_button + record.isGuest && record.info != null -> com.android.internal.R.string.guest_name + else -> null + } + } + + @JvmStatic + @StringRes + fun getUserSwitcherActionTextResourceId( + isGuest: Boolean, + isGuestUserAutoCreated: Boolean, + isGuestUserResetting: Boolean, + isAddUser: Boolean, + isAddSupervisedUser: Boolean, + ): Int { + check(isGuest || isAddUser || isAddSupervisedUser) + + return when { + isGuest && isGuestUserAutoCreated && isGuestUserResetting -> + com.android.settingslib.R.string.guest_resetting + isGuest && isGuestUserAutoCreated -> com.android.internal.R.string.guest_name + isGuest -> com.android.internal.R.string.guest_name + isAddUser -> com.android.settingslib.R.string.user_add_user + isAddSupervisedUser -> R.string.add_user_supervised + else -> error("This should never happen!") + } + } + + /** Alpha value to apply to a user view in the user switcher when it's selectable. */ + const val USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA = 1.0f + + /** Alpha value to apply to a user view in the user switcher when it's not selectable. */ + const val USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA = 0.38f +} diff --git a/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt new file mode 100644 index 000000000000..823bf74dc0f0 --- /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 000000000000..bf7977a600e9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.shared.model + +import android.graphics.drawable.Drawable +import com.android.systemui.common.shared.model.Text + +/** Represents a single user on the device. */ +data class UserModel( + /** ID of the user, unique across all users on this device. */ + val id: Int, + /** Human-facing name for this user. */ + val name: Text, + /** Human-facing image for this user. */ + val image: Drawable, + /** Whether this user is the currently-selected user. */ + val isSelected: Boolean, + /** Whether this use is selectable. A non-selectable user cannot be switched to. */ + val isSelectable: Boolean, +) diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt new file mode 100644 index 000000000000..83a3d0d0457a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.ui.binder + +import android.content.Context +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.helper.widget.Flow as FlowWidget +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.systemui.Gefingerpoken +import com.android.systemui.R +import com.android.systemui.classifier.FalsingCollector +import com.android.systemui.user.UserSwitcherPopupMenu +import com.android.systemui.user.UserSwitcherRootView +import com.android.systemui.user.ui.viewmodel.UserActionViewModel +import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel +import com.android.systemui.util.children +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +/** Binds a user switcher to its view-model. */ +object UserSwitcherViewBinder { + + private const val USER_VIEW_TAG = "user_view" + + /** Binds the given view to the given view-model. */ + fun bind( + view: ViewGroup, + viewModel: UserSwitcherViewModel, + lifecycleOwner: LifecycleOwner, + layoutInflater: LayoutInflater, + falsingCollector: FalsingCollector, + onFinish: () -> Unit, + ) { + val rootView: UserSwitcherRootView = view.requireViewById(R.id.user_switcher_root) + val flowWidget: FlowWidget = view.requireViewById(R.id.flow) + val addButton: View = view.requireViewById(R.id.add) + val cancelButton: View = view.requireViewById(R.id.cancel) + val popupMenuAdapter = MenuAdapter(layoutInflater) + var popupMenu: UserSwitcherPopupMenu? = null + + rootView.touchHandler = + object : Gefingerpoken { + override fun onTouchEvent(ev: MotionEvent?): Boolean { + falsingCollector.onTouchEvent(ev) + return false + } + } + addButton.setOnClickListener { viewModel.onOpenMenuButtonClicked() } + cancelButton.setOnClickListener { viewModel.onCancelButtonClicked() } + + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.isFinishRequested + .filter { it } + .collect { + onFinish() + viewModel.onFinished() + } + } + } + } + + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.isOpenMenuButtonVisible.collect { addButton.isVisible = it } } + + launch { + viewModel.isMenuVisible.collect { isVisible -> + if (isVisible && popupMenu?.isShowing != true) { + popupMenu?.dismiss() + // Use post to make sure we show the popup menu *after* the activity is + // ready to show one to avoid a WindowManager$BadTokenException. + view.post { + popupMenu = + createAndShowPopupMenu( + context = view.context, + anchorView = addButton, + adapter = popupMenuAdapter, + onDismissed = viewModel::onMenuClosed, + ) + } + } else if (!isVisible && popupMenu?.isShowing == true) { + popupMenu?.dismiss() + popupMenu = null + } + } + } + + launch { + viewModel.menu.collect { menuViewModels -> + popupMenuAdapter.setItems(menuViewModels) + } + } + + launch { + viewModel.maximumUserColumns.collect { maximumColumns -> + flowWidget.setMaxElementsWrap(maximumColumns) + } + } + + launch { + viewModel.users.collect { users -> + val viewPool = + view.children.filter { it.tag == USER_VIEW_TAG }.toMutableList() + viewPool.forEach { view.removeView(it) } + users.forEach { userViewModel -> + val userView = + if (viewPool.isNotEmpty()) { + viewPool.removeAt(0) + } else { + val inflatedView = + layoutInflater.inflate( + R.layout.user_switcher_fullscreen_item, + view, + false, + ) + inflatedView.tag = USER_VIEW_TAG + inflatedView + } + userView.id = View.generateViewId() + view.addView(userView) + flowWidget.addView(userView) + UserViewBinder.bind( + view = userView, + viewModel = userViewModel, + ) + } + } + } + } + } + } + + private fun createAndShowPopupMenu( + context: Context, + anchorView: View, + adapter: MenuAdapter, + onDismissed: () -> Unit, + ): UserSwitcherPopupMenu { + return UserSwitcherPopupMenu(context).apply { + this.anchorView = anchorView + setAdapter(adapter) + setOnDismissListener { onDismissed() } + setOnItemClickListener { _, _, position, _ -> + val itemPositionExcludingHeader = position - 1 + adapter.getItem(itemPositionExcludingHeader).onClicked() + } + + show() + } + } + + /** Adapter for the menu that can be opened. */ + private class MenuAdapter( + private val layoutInflater: LayoutInflater, + ) : BaseAdapter() { + + private val items = mutableListOf<UserActionViewModel>() + + override fun getCount(): Int { + return items.size + } + + override fun getItem(position: Int): UserActionViewModel { + return items[position] + } + + override fun getItemId(position: Int): Long { + return getItem(position).viewKey + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = + convertView + ?: layoutInflater.inflate( + R.layout.user_switcher_fullscreen_popup_item, + parent, + false + ) + val viewModel = getItem(position) + view.requireViewById<ImageView>(R.id.icon).setImageResource(viewModel.iconResourceId) + view.requireViewById<TextView>(R.id.text).text = + view.resources.getString(viewModel.textResourceId) + return view + } + + fun setItems(items: List<UserActionViewModel>) { + this.items.clear() + this.items.addAll(items) + notifyDataSetChanged() + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt new file mode 100644 index 000000000000..e78807e675b3 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.ui.binder + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.view.View +import android.widget.ImageView +import androidx.core.content.res.ResourcesCompat +import com.android.settingslib.Utils +import com.android.systemui.R +import com.android.systemui.common.ui.binder.TextViewBinder +import com.android.systemui.user.ui.viewmodel.UserViewModel + +/** Binds a user view to its view-model. */ +object UserViewBinder { + /** Binds the given view to the given view-model. */ + fun bind(view: View, viewModel: UserViewModel) { + TextViewBinder.bind(view.requireViewById(R.id.user_switcher_text), viewModel.name) + view + .requireViewById<ImageView>(R.id.user_switcher_icon) + .setImageDrawable(getSelectableDrawable(view.context, viewModel)) + view.alpha = viewModel.alpha + if (viewModel.onClicked != null) { + view.setOnClickListener { viewModel.onClicked.invoke() } + } else { + view.setOnClickListener(null) + } + } + + private fun getSelectableDrawable(context: Context, viewModel: UserViewModel): Drawable { + val layerDrawable = + checkNotNull( + ResourcesCompat.getDrawable( + context.resources, + R.drawable.user_switcher_icon_large, + context.theme, + ) + ) + .mutate() as LayerDrawable + if (viewModel.isSelectionMarkerVisible) { + (layerDrawable.findDrawableByLayerId(R.id.ring) as GradientDrawable).apply { + val stroke = + context.resources.getDimensionPixelSize( + R.dimen.user_switcher_icon_selected_width + ) + val color = + Utils.getColorAttrDefaultColor( + context, + com.android.internal.R.attr.colorAccentPrimary + ) + + setStroke(stroke, color) + } + } + + layerDrawable.setDrawableByLayerId(R.id.user_avatar, viewModel.image) + return layerDrawable + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt new file mode 100644 index 000000000000..149b1ffdaff0 --- /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 000000000000..66ce01b7a86e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.android.systemui.R +import com.android.systemui.power.domain.interactor.PowerInteractor +import com.android.systemui.user.domain.interactor.UserInteractor +import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** Models UI state for the user switcher feature. */ +class UserSwitcherViewModel +private constructor( + private val userInteractor: UserInteractor, + private val powerInteractor: PowerInteractor, +) : ViewModel() { + + /** On-device users. */ + val users: Flow<List<UserViewModel>> = + userInteractor.users.map { models -> models.map { user -> toViewModel(user) } } + + /** The maximum number of columns that the user selection grid should use. */ + val maximumUserColumns: Flow<Int> = + users.map { LegacyUserUiHelper.getMaxUserSwitcherItemColumns(it.size) } + + /** Whether the button to open the user action menu is visible. */ + val isOpenMenuButtonVisible: Flow<Boolean> = userInteractor.actions.map { it.isNotEmpty() } + + private val _isMenuVisible = MutableStateFlow(false) + /** + * Whether the user action menu should be shown. Once the action menu is dismissed/closed, the + * consumer must invoke [onMenuClosed]. + */ + val isMenuVisible: Flow<Boolean> = _isMenuVisible + /** The user action menu. */ + val menu: Flow<List<UserActionViewModel>> = + userInteractor.actions.map { actions -> actions.map { action -> toViewModel(action) } } + + private val hasCancelButtonBeenClicked = MutableStateFlow(false) + + /** + * Whether the observer should finish the experience. Once consumed, [onFinished] must be called + * by the consumer. + */ + val isFinishRequested: Flow<Boolean> = createFinishRequestedFlow() + + /** Notifies that the user has clicked the cancel button. */ + fun onCancelButtonClicked() { + hasCancelButtonBeenClicked.value = true + } + + /** + * Notifies that the user experience is finished. + * + * Call this after consuming [isFinishRequested] with a `true` value in order to mark it as + * consumed such that the next consumer doesn't immediately finish itself. + */ + fun onFinished() { + hasCancelButtonBeenClicked.value = false + } + + /** Notifies that the user has clicked the "open menu" button. */ + fun onOpenMenuButtonClicked() { + _isMenuVisible.value = true + } + + /** + * Notifies that the user has dismissed or closed the user action menu. + * + * Call this after consuming [isMenuVisible] with a `true` value in order to reset it to `false` + * such that the next consumer doesn't immediately show the menu again. + */ + fun onMenuClosed() { + _isMenuVisible.value = false + } + + private fun createFinishRequestedFlow(): Flow<Boolean> { + var mostRecentSelectedUserId: Int? = null + var mostRecentIsInteractive: Boolean? = null + + return combine( + // When the user is switched, we should finish. + userInteractor.selectedUser + .map { it.id } + .map { + val selectedUserChanged = + mostRecentSelectedUserId != null && mostRecentSelectedUserId != it + mostRecentSelectedUserId = it + selectedUserChanged + }, + // When the screen turns off, we should finish. + powerInteractor.isInteractive.map { + val screenTurnedOff = mostRecentIsInteractive == true && !it + mostRecentIsInteractive = it + screenTurnedOff + }, + // When the cancel button is clicked, we should finish. + hasCancelButtonBeenClicked, + ) { selectedUserChanged, screenTurnedOff, cancelButtonClicked -> + selectedUserChanged || screenTurnedOff || cancelButtonClicked + } + } + + private fun toViewModel( + model: UserModel, + ): UserViewModel { + return UserViewModel( + viewKey = model.id, + name = model.name, + image = model.image, + isSelectionMarkerVisible = model.isSelected, + alpha = + if (model.isSelectable) { + LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA + } else { + LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA + }, + onClicked = createOnSelectedCallback(model), + ) + } + + private fun toViewModel( + model: UserActionModel, + ): UserActionViewModel { + return UserActionViewModel( + viewKey = model.ordinal.toLong(), + iconResourceId = + if (model == UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) { + R.drawable.ic_manage_users + } else { + LegacyUserUiHelper.getUserSwitcherActionIconResourceId( + isAddSupervisedUser = model == UserActionModel.ADD_SUPERVISED_USER, + isAddUser = model == UserActionModel.ADD_USER, + isGuest = model == UserActionModel.ENTER_GUEST_MODE, + ) + }, + textResourceId = + if (model == UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) { + R.string.manage_users + } else { + LegacyUserUiHelper.getUserSwitcherActionTextResourceId( + isGuest = model == UserActionModel.ENTER_GUEST_MODE, + isGuestUserAutoCreated = userInteractor.isGuestUserAutoCreated, + isGuestUserResetting = userInteractor.isGuestUserResetting, + isAddSupervisedUser = model == UserActionModel.ADD_SUPERVISED_USER, + isAddUser = model == UserActionModel.ADD_USER, + ) + }, + onClicked = { userInteractor.executeAction(action = model) }, + ) + } + + private fun createOnSelectedCallback(model: UserModel): (() -> Unit)? { + return if (!model.isSelectable) { + null + } else { + { userInteractor.selectUser(model.id) } + } + } + + class Factory + @Inject + constructor( + private val userInteractor: UserInteractor, + private val powerInteractor: PowerInteractor, + ) : ViewModelProvider.Factory { + override fun <T : ViewModel> create(modelClass: Class<T>): T { + @Suppress("UNCHECKED_CAST") + return UserSwitcherViewModel( + userInteractor = userInteractor, + powerInteractor = powerInteractor, + ) + as T + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt new file mode 100644 index 000000000000..d57bba0fa86a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.ui.viewmodel + +import android.graphics.drawable.Drawable +import com.android.systemui.common.shared.model.Text + +/** Models UI state for representing a single user. */ +data class UserViewModel( + /** + * Key to use with the view or compose system to keep track of the view/composable across + * changes to the collection of [UserViewModel] instances. + */ + val viewKey: Int, + val name: Text, + val image: Drawable, + /** Whether a marker should be shown to highlight that this user is the selected one. */ + val isSelectionMarkerVisible: Boolean, + val alpha: Float, + val onClicked: (() -> Unit)?, +) diff --git a/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt new file mode 100644 index 000000000000..15465f4d40fe --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/FakePowerRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.power.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakePowerRepository( + initialInteractive: Boolean = true, +) : PowerRepository { + + private val _isInteractive = MutableStateFlow(initialInteractive) + override val isInteractive: Flow<Boolean> = _isInteractive.asStateFlow() + + fun setInteractive(value: Boolean) { + _isInteractive.value = value + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt new file mode 100644 index 000000000000..249a91b0982a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/power/data/repository/PowerRepositoryImplTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.power.data.repository + +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.util.mockito.capture +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.isNull +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class PowerRepositoryImplTest : SysuiTestCase() { + + @Mock private lateinit var manager: PowerManager + @Mock private lateinit var dispatcher: BroadcastDispatcher + @Captor private lateinit var receiverCaptor: ArgumentCaptor<BroadcastReceiver> + @Captor private lateinit var filterCaptor: ArgumentCaptor<IntentFilter> + + private lateinit var underTest: PowerRepositoryImpl + + private var isInteractive = true + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + isInteractive = true + whenever(manager.isInteractive).then { isInteractive } + + underTest = PowerRepositoryImpl(manager = manager, dispatcher = dispatcher) + } + + @Test + fun `isInteractive - registers for broadcasts`() = + runBlocking(IMMEDIATE) { + val job = underTest.isInteractive.onEach {}.launchIn(this) + + verifyRegistered() + assertThat(filterCaptor.value.hasAction(Intent.ACTION_SCREEN_ON)).isTrue() + assertThat(filterCaptor.value.hasAction(Intent.ACTION_SCREEN_OFF)).isTrue() + + job.cancel() + } + + @Test + fun `isInteractive - unregisters from broadcasts`() = + runBlocking(IMMEDIATE) { + val job = underTest.isInteractive.onEach {}.launchIn(this) + verifyRegistered() + + job.cancel() + + verify(dispatcher).unregisterReceiver(receiverCaptor.value) + } + + @Test + fun `isInteractive - emits initial true value if screen was on`() = + runBlocking(IMMEDIATE) { + isInteractive = true + var value: Boolean? = null + val job = underTest.isInteractive.onEach { value = it }.launchIn(this) + + verifyRegistered() + + assertThat(value).isTrue() + job.cancel() + } + + @Test + fun `isInteractive - emits initial false value if screen was off`() = + runBlocking(IMMEDIATE) { + isInteractive = false + var value: Boolean? = null + val job = underTest.isInteractive.onEach { value = it }.launchIn(this) + + verifyRegistered() + + assertThat(value).isFalse() + job.cancel() + } + + @Test + fun `isInteractive - emits true when the screen turns on`() = + runBlocking(IMMEDIATE) { + var value: Boolean? = null + val job = underTest.isInteractive.onEach { value = it }.launchIn(this) + verifyRegistered() + + isInteractive = true + receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_ON)) + + assertThat(value).isTrue() + job.cancel() + } + + @Test + fun `isInteractive - emits false when the screen turns off`() = + runBlocking(IMMEDIATE) { + var value: Boolean? = null + val job = underTest.isInteractive.onEach { value = it }.launchIn(this) + verifyRegistered() + + isInteractive = false + receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF)) + + assertThat(value).isFalse() + job.cancel() + } + + @Test + fun `isInteractive - emits correctly over time`() = + runBlocking(IMMEDIATE) { + val values = mutableListOf<Boolean>() + val job = underTest.isInteractive.onEach(values::add).launchIn(this) + verifyRegistered() + + isInteractive = false + receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF)) + isInteractive = true + receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_ON)) + isInteractive = false + receiverCaptor.value.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF)) + + assertThat(values).isEqualTo(listOf(true, false, true, false)) + job.cancel() + } + + private fun verifyRegistered() { + // We must verify with all arguments, even those that are optional because they have default + // values because Mockito is forcing us to. Once we can use mockito-kotlin, we should be + // able to remove this. + verify(dispatcher) + .registerReceiver( + capture(receiverCaptor), + capture(filterCaptor), + isNull(), + isNull(), + anyInt(), + isNull(), + ) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.kt new file mode 100644 index 000000000000..bf6a37ec8eff --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/power/domain/interactor/PowerInteractorTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.power.domain.interactor + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.power.data.repository.FakePowerRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@SmallTest +@RunWith(JUnit4::class) +class PowerInteractorTest : SysuiTestCase() { + + private lateinit var underTest: PowerInteractor + private lateinit var repository: FakePowerRepository + + @Before + fun setUp() { + repository = + FakePowerRepository( + initialInteractive = true, + ) + underTest = PowerInteractor(repository = repository) + } + + @Test + fun `isInteractive - screen turns off`() = + runBlocking(IMMEDIATE) { + repository.setInteractive(true) + var value: Boolean? = null + val job = underTest.isInteractive.onEach { value = it }.launchIn(this) + + repository.setInteractive(false) + + assertThat(value).isFalse() + job.cancel() + } + + @Test + fun `isInteractive - becomes interactive`() = + runBlocking(IMMEDIATE) { + repository.setInteractive(false) + var value: Boolean? = null + val job = underTest.isInteractive.onEach { value = it }.launchIn(this) + + repository.setInteractive(true) + + assertThat(value).isTrue() + job.cancel() + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/UserDetailViewAdapterTest.kt index 5db3b9caa007..da52a9b1a3c2 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 c3805ad36533..8290dab19bdf 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, 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 66367ecfc95c..439beaab6c7e 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<UserSwitcherViewModel.Factory> @Before fun setUp() { @@ -61,11 +67,12 @@ class UserSwitcherActivityTest : SysuiTestCase() { activity = UserSwitcherActivity( userSwitcherController, broadcastDispatcher, - layoutInflater, falsingCollector, falsingManager, userManager, - userTracker + userTracker, + flags, + viewModelFactoryLazy, ) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt new file mode 100644 index 000000000000..20f1e367944f --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.data.repository + +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +class FakeUserRepository : UserRepository { + + private val _users = MutableStateFlow<List<UserModel>>(emptyList()) + override val users: Flow<List<UserModel>> = _users.asStateFlow() + override val selectedUser: Flow<UserModel> = + users.map { models -> models.first { model -> model.isSelected } } + + private val _actions = MutableStateFlow<List<UserActionModel>>(emptyList()) + override val actions: Flow<List<UserActionModel>> = _actions.asStateFlow() + + private val _isActionableWhenLocked = MutableStateFlow(false) + override val isActionableWhenLocked: Flow<Boolean> = _isActionableWhenLocked.asStateFlow() + + private var _isGuestUserAutoCreated: Boolean = false + override val isGuestUserAutoCreated: Boolean + get() = _isGuestUserAutoCreated + private var _isGuestUserResetting: Boolean = false + override val isGuestUserResetting: Boolean + get() = _isGuestUserResetting + + fun setUsers(models: List<UserModel>) { + _users.value = models + } + + fun setSelectedUser(userId: Int) { + check(_users.value.find { it.id == userId } != null) { + "Cannot select a user with ID $userId - no user with that ID found!" + } + + setUsers( + _users.value.map { model -> + when { + model.isSelected && model.id != userId -> model.copy(isSelected = false) + !model.isSelected && model.id == userId -> model.copy(isSelected = true) + else -> model + } + } + ) + } + + fun setActions(models: List<UserActionModel>) { + _actions.value = models + } + + fun setActionableWhenLocked(value: Boolean) { + _isActionableWhenLocked.value = value + } + + fun setGuestUserAutoCreated(value: Boolean) { + _isGuestUserAutoCreated = value + } + + fun setGuestUserResetting(value: Boolean) { + _isGuestUserResetting = value + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 000000000000..6b466e1ac2d8 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.data.repository + +import android.content.pm.UserInfo +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.source.UserRecord +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.capture +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class UserRepositoryImplTest : SysuiTestCase() { + + @Mock private lateinit var manager: UserManager + @Mock private lateinit var controller: UserSwitcherController + @Captor + private lateinit var userSwitchCallbackCaptor: + ArgumentCaptor<UserSwitcherController.UserSwitchCallback> + + private lateinit var underTest: UserRepositoryImpl + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + whenever(controller.addUsersFromLockScreen).thenReturn(MutableStateFlow(false)) + whenever(controller.isGuestUserAutoCreated).thenReturn(false) + whenever(controller.isGuestUserResetting).thenReturn(false) + + underTest = + UserRepositoryImpl( + appContext = context, + manager = manager, + controller = controller, + ) + } + + @Test + fun `users - registers for updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.users.onEach {}.launchIn(this) + + verify(controller).addUserSwitchCallback(any()) + + job.cancel() + } + + @Test + fun `users - unregisters from updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.users.onEach {}.launchIn(this) + verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor)) + + job.cancel() + + verify(controller).removeUserSwitchCallback(userSwitchCallbackCaptor.value) + } + + @Test + fun `users - does not include actions`() = + runBlocking(IMMEDIATE) { + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0, isSelected = true), + createActionRecord(UserActionModel.ADD_USER), + createUserRecord(1), + createUserRecord(2), + createActionRecord(UserActionModel.ADD_SUPERVISED_USER), + createActionRecord(UserActionModel.ENTER_GUEST_MODE), + ) + ) + var models: List<UserModel>? = null + val job = underTest.users.onEach { models = it }.launchIn(this) + + assertThat(models).hasSize(3) + assertThat(models?.get(0)?.id).isEqualTo(0) + assertThat(models?.get(0)?.isSelected).isTrue() + assertThat(models?.get(1)?.id).isEqualTo(1) + assertThat(models?.get(1)?.isSelected).isFalse() + assertThat(models?.get(2)?.id).isEqualTo(2) + assertThat(models?.get(2)?.isSelected).isFalse() + job.cancel() + } + + @Test + fun selectedUser() = + runBlocking(IMMEDIATE) { + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0, isSelected = true), + createUserRecord(1), + createUserRecord(2), + ) + ) + var id: Int? = null + val job = underTest.selectedUser.map { it.id }.onEach { id = it }.launchIn(this) + + assertThat(id).isEqualTo(0) + + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0), + createUserRecord(1), + createUserRecord(2, isSelected = true), + ) + ) + verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor)) + userSwitchCallbackCaptor.value.onUserSwitched() + assertThat(id).isEqualTo(2) + + job.cancel() + } + + @Test + fun `actions - unregisters from updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.actions.onEach {}.launchIn(this) + verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor)) + + job.cancel() + + verify(controller).removeUserSwitchCallback(userSwitchCallbackCaptor.value) + } + + @Test + fun `actions - registers for updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.actions.onEach {}.launchIn(this) + + verify(controller).addUserSwitchCallback(any()) + + job.cancel() + } + + @Test + fun `actopms - does not include users`() = + runBlocking(IMMEDIATE) { + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0, isSelected = true), + createActionRecord(UserActionModel.ADD_USER), + createUserRecord(1), + createUserRecord(2), + createActionRecord(UserActionModel.ADD_SUPERVISED_USER), + createActionRecord(UserActionModel.ENTER_GUEST_MODE), + ) + ) + var models: List<UserActionModel>? = null + val job = underTest.actions.onEach { models = it }.launchIn(this) + + assertThat(models).hasSize(3) + assertThat(models?.get(0)).isEqualTo(UserActionModel.ADD_USER) + assertThat(models?.get(1)).isEqualTo(UserActionModel.ADD_SUPERVISED_USER) + assertThat(models?.get(2)).isEqualTo(UserActionModel.ENTER_GUEST_MODE) + job.cancel() + } + + private fun createUserRecord(id: Int, isSelected: Boolean = false): UserRecord { + return UserRecord( + info = UserInfo(id, "name$id", 0), + isCurrent = isSelected, + ) + } + + private fun createActionRecord(action: UserActionModel): UserRecord { + return UserRecord( + isAddUser = action == UserActionModel.ADD_USER, + isAddSupervisedUser = action == UserActionModel.ADD_SUPERVISED_USER, + isGuest = action == UserActionModel.ENTER_GUEST_MODE, + ) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt new file mode 100644 index 000000000000..e914e2e0a1da --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/UserInteractorTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.domain.interactor + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.repository.FakeUserRepository +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.nullable +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mock +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class UserInteractorTest : SysuiTestCase() { + + @Mock private lateinit var controller: UserSwitcherController + @Mock private lateinit var activityStarter: ActivityStarter + + private lateinit var underTest: UserInteractor + + private lateinit var userRepository: FakeUserRepository + private lateinit var keyguardRepository: FakeKeyguardRepository + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + userRepository = FakeUserRepository() + keyguardRepository = FakeKeyguardRepository() + underTest = + UserInteractor( + repository = userRepository, + controller = controller, + activityStarter = activityStarter, + keyguardInteractor = + KeyguardInteractor( + repository = keyguardRepository, + ), + ) + } + + @Test + fun `actions - not actionable when locked and locked - no actions`() = + runBlocking(IMMEDIATE) { + userRepository.setActions(UserActionModel.values().toList()) + userRepository.setActionableWhenLocked(false) + keyguardRepository.setKeyguardShowing(true) + + var actions: List<UserActionModel>? = null + val job = underTest.actions.onEach { actions = it }.launchIn(this) + + assertThat(actions).isEmpty() + job.cancel() + } + + @Test + fun `actions - not actionable when locked and not locked`() = + runBlocking(IMMEDIATE) { + userRepository.setActions( + listOf( + UserActionModel.ENTER_GUEST_MODE, + UserActionModel.ADD_USER, + UserActionModel.ADD_SUPERVISED_USER, + ) + ) + userRepository.setActionableWhenLocked(false) + keyguardRepository.setKeyguardShowing(false) + + var actions: List<UserActionModel>? = null + val job = underTest.actions.onEach { actions = it }.launchIn(this) + + assertThat(actions) + .isEqualTo( + listOf( + UserActionModel.ENTER_GUEST_MODE, + UserActionModel.ADD_USER, + UserActionModel.ADD_SUPERVISED_USER, + UserActionModel.NAVIGATE_TO_USER_MANAGEMENT, + ) + ) + job.cancel() + } + + @Test + fun `actions - actionable when locked and not locked`() = + runBlocking(IMMEDIATE) { + userRepository.setActions( + listOf( + UserActionModel.ENTER_GUEST_MODE, + UserActionModel.ADD_USER, + UserActionModel.ADD_SUPERVISED_USER, + ) + ) + userRepository.setActionableWhenLocked(true) + keyguardRepository.setKeyguardShowing(false) + + var actions: List<UserActionModel>? = null + val job = underTest.actions.onEach { actions = it }.launchIn(this) + + assertThat(actions) + .isEqualTo( + listOf( + UserActionModel.ENTER_GUEST_MODE, + UserActionModel.ADD_USER, + UserActionModel.ADD_SUPERVISED_USER, + UserActionModel.NAVIGATE_TO_USER_MANAGEMENT, + ) + ) + job.cancel() + } + + @Test + fun `actions - actionable when locked and locked`() = + runBlocking(IMMEDIATE) { + userRepository.setActions( + listOf( + UserActionModel.ENTER_GUEST_MODE, + UserActionModel.ADD_USER, + UserActionModel.ADD_SUPERVISED_USER, + ) + ) + userRepository.setActionableWhenLocked(true) + keyguardRepository.setKeyguardShowing(true) + + var actions: List<UserActionModel>? = null + val job = underTest.actions.onEach { actions = it }.launchIn(this) + + assertThat(actions) + .isEqualTo( + listOf( + UserActionModel.ENTER_GUEST_MODE, + UserActionModel.ADD_USER, + UserActionModel.ADD_SUPERVISED_USER, + UserActionModel.NAVIGATE_TO_USER_MANAGEMENT, + ) + ) + job.cancel() + } + + @Test + fun selectUser() { + val userId = 3 + + underTest.selectUser(userId) + + verify(controller).onUserSelected(eq(userId), nullable()) + } + + @Test + fun `executeAction - guest`() { + underTest.executeAction(UserActionModel.ENTER_GUEST_MODE) + + verify(controller).createAndSwitchToGuestUser(nullable()) + } + + @Test + fun `executeAction - add user`() { + underTest.executeAction(UserActionModel.ADD_USER) + + verify(controller).showAddUserDialog(nullable()) + } + + @Test + fun `executeAction - add supervised user`() { + underTest.executeAction(UserActionModel.ADD_SUPERVISED_USER) + + verify(controller).startSupervisedUserActivity() + } + + @Test + fun `executeAction - manage users`() { + underTest.executeAction(UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) + + verify(activityStarter).startActivity(any(), anyBoolean()) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt new file mode 100644 index 000000000000..ef4500df3600 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.ui.viewmodel + +import android.graphics.drawable.Drawable +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.shared.model.Text +import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.power.data.repository.FakePowerRepository +import com.android.systemui.power.domain.interactor.PowerInteractor +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.repository.FakeUserRepository +import com.android.systemui.user.domain.interactor.UserInteractor +import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import com.android.systemui.util.mockito.mock +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class UserSwitcherViewModelTest : SysuiTestCase() { + + @Mock private lateinit var controller: UserSwitcherController + @Mock private lateinit var activityStarter: ActivityStarter + + private lateinit var underTest: UserSwitcherViewModel + + private lateinit var userRepository: FakeUserRepository + private lateinit var keyguardRepository: FakeKeyguardRepository + private lateinit var powerRepository: FakePowerRepository + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + userRepository = FakeUserRepository() + keyguardRepository = FakeKeyguardRepository() + powerRepository = FakePowerRepository() + underTest = + UserSwitcherViewModel.Factory( + userInteractor = + UserInteractor( + repository = userRepository, + controller = controller, + activityStarter = activityStarter, + keyguardInteractor = + KeyguardInteractor( + repository = keyguardRepository, + ) + ), + powerInteractor = + PowerInteractor( + repository = powerRepository, + ), + ) + .create(UserSwitcherViewModel::class.java) + } + + @Test + fun users() = + runBlocking(IMMEDIATE) { + userRepository.setUsers( + listOf( + UserModel( + id = 0, + name = Text.Loaded("zero"), + image = USER_IMAGE, + isSelected = true, + isSelectable = true, + ), + UserModel( + id = 1, + name = Text.Loaded("one"), + image = USER_IMAGE, + isSelected = false, + isSelectable = true, + ), + UserModel( + id = 2, + name = Text.Loaded("two"), + image = USER_IMAGE, + isSelected = false, + isSelectable = false, + ), + ) + ) + + var userViewModels: List<UserViewModel>? = null + val job = underTest.users.onEach { userViewModels = it }.launchIn(this) + + assertThat(userViewModels).hasSize(3) + assertUserViewModel( + viewModel = userViewModels?.get(0), + viewKey = 0, + name = "zero", + isSelectionMarkerVisible = true, + alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA, + isClickable = true, + ) + assertUserViewModel( + viewModel = userViewModels?.get(1), + viewKey = 1, + name = "one", + isSelectionMarkerVisible = false, + alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA, + isClickable = true, + ) + assertUserViewModel( + viewModel = userViewModels?.get(2), + viewKey = 2, + name = "two", + isSelectionMarkerVisible = false, + alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA, + isClickable = false, + ) + job.cancel() + } + + @Test + fun `maximumUserColumns - few users`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + var value: Int? = null + val job = underTest.maximumUserColumns.onEach { value = it }.launchIn(this) + + assertThat(value).isEqualTo(4) + job.cancel() + } + + @Test + fun `maximumUserColumns - many users`() = + runBlocking(IMMEDIATE) { + setUsers(count = 5) + var value: Int? = null + val job = underTest.maximumUserColumns.onEach { value = it }.launchIn(this) + + assertThat(value).isEqualTo(3) + job.cancel() + } + + @Test + fun `isOpenMenuButtonVisible - has actions - true`() = + runBlocking(IMMEDIATE) { + userRepository.setActions(UserActionModel.values().toList()) + + var isVisible: Boolean? = null + val job = underTest.isOpenMenuButtonVisible.onEach { isVisible = it }.launchIn(this) + + assertThat(isVisible).isTrue() + job.cancel() + } + + @Test + fun `isOpenMenuButtonVisible - no actions - false`() = + runBlocking(IMMEDIATE) { + userRepository.setActions(emptyList()) + + var isVisible: Boolean? = null + val job = underTest.isOpenMenuButtonVisible.onEach { isVisible = it }.launchIn(this) + + assertThat(isVisible).isFalse() + job.cancel() + } + + @Test + fun menu() = + runBlocking(IMMEDIATE) { + userRepository.setActions(UserActionModel.values().toList()) + var isMenuVisible: Boolean? = null + val job = underTest.isMenuVisible.onEach { isMenuVisible = it }.launchIn(this) + assertThat(isMenuVisible).isFalse() + + underTest.onOpenMenuButtonClicked() + assertThat(isMenuVisible).isTrue() + + underTest.onMenuClosed() + assertThat(isMenuVisible).isFalse() + + job.cancel() + } + + @Test + fun `isFinishRequested - finishes when user is switched`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + var isFinishRequested: Boolean? = null + val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this) + assertThat(isFinishRequested).isFalse() + + userRepository.setSelectedUser(1) + yield() + assertThat(isFinishRequested).isTrue() + + job.cancel() + } + + @Test + fun `isFinishRequested - finishes when the screen turns off`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + powerRepository.setInteractive(true) + var isFinishRequested: Boolean? = null + val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this) + assertThat(isFinishRequested).isFalse() + + powerRepository.setInteractive(false) + yield() + assertThat(isFinishRequested).isTrue() + + job.cancel() + } + + @Test + fun `isFinishRequested - finishes when cancel button is clicked`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + powerRepository.setInteractive(true) + var isFinishRequested: Boolean? = null + val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this) + assertThat(isFinishRequested).isFalse() + + underTest.onCancelButtonClicked() + yield() + assertThat(isFinishRequested).isTrue() + + underTest.onFinished() + yield() + assertThat(isFinishRequested).isFalse() + + job.cancel() + } + + private fun setUsers(count: Int) { + userRepository.setUsers( + (0 until count).map { index -> + UserModel( + id = index, + name = Text.Loaded("$index"), + image = USER_IMAGE, + isSelected = index == 0, + isSelectable = true, + ) + } + ) + } + + private fun assertUserViewModel( + viewModel: UserViewModel?, + viewKey: Int, + name: String, + isSelectionMarkerVisible: Boolean, + alpha: Float, + isClickable: Boolean, + ) { + checkNotNull(viewModel) + assertThat(viewModel.viewKey).isEqualTo(viewKey) + assertThat(viewModel.name).isEqualTo(Text.Loaded(name)) + assertThat(viewModel.isSelectionMarkerVisible).isEqualTo(isSelectionMarkerVisible) + assertThat(viewModel.alpha).isEqualTo(alpha) + assertThat(viewModel.onClicked != null).isEqualTo(isClickable) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + private val USER_IMAGE = mock<Drawable>() + } +} |