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

* changes:
  Wiring into UserSwitcherActivity.
  User UI layer.
  User domain layer.
  User data layer.
  Adds power repository and interactor.
  Adds Text to the common module.
  Prepares UserSwitcherController.
This commit is contained in:
Alejandro Nijamkin
2022-09-08 13:34:24 +00:00
committed by Android (Google) Code Review
31 changed files with 2644 additions and 209 deletions

View File

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

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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,
/** Whether it is possible to switch to this user. */
@JvmField
val isSwitchToEnabled: Boolean,
/** Whether this record represents an option to add another supervised user to the device. */
@JvmField
val isAddSupervisedUser: Boolean,
) {
@JvmField val isAddUser: Boolean = false,
/**
* Returns a new instance of [UserRecord] with its [isCurrent] set to the given value.
* If true, the record is only available if unlocked or if the user has granted permission to
* access this user action whilst on the device is locked.
*/
@JvmField val isRestricted: Boolean = false,
/** Whether it is possible to switch to this user. */
@JvmField val isSwitchToEnabled: Boolean = false,
/** Whether this record represents an option to add another supervised user to the device. */
@JvmField val isAddSupervisedUser: Boolean = false,
) {
/** Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. */
fun copyWithIsCurrent(isCurrent: Boolean): UserRecord {
return copy(isCurrent = isCurrent)
}
@@ -67,4 +58,11 @@ data class UserRecord(
info.id
}
}
companion object {
@JvmStatic
fun createForGuest(): UserRecord {
return UserRecord(isGuest = true)
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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,
)

View File

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

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -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
}
}
}

View File

@@ -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)?,
)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
)
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

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