[flexiglass] Adds user switcher to the landscape bouncer scene.

Bug: 299343639
Test: scene screenshots and video on the attached bug.
Change-Id: If138bfb045103139bf1b4eb1783eba51ee519150
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt b/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
index 44c4105..bb2fbf7 100644
--- a/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
+++ b/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
@@ -26,6 +26,7 @@
 import androidx.compose.material3.ButtonDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import com.android.compose.theme.LocalAndroidColorScheme
 
@@ -34,11 +35,13 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
+    colors: ButtonColors = filledButtonColors(),
+    verticalPadding: Dp = DefaultPlatformButtonVerticalPadding,
     content: @Composable RowScope.() -> Unit,
 ) {
     androidx.compose.material3.Button(
-        modifier = modifier.padding(vertical = 6.dp).height(36.dp),
-        colors = filledButtonColors(),
+        modifier = modifier.padding(vertical = verticalPadding).height(36.dp),
+        colors = colors,
         contentPadding = ButtonPaddings,
         onClick = onClick,
         enabled = enabled,
@@ -52,13 +55,16 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
+    colors: ButtonColors = outlineButtonColors(),
+    border: BorderStroke? = outlineButtonBorder(),
+    verticalPadding: Dp = DefaultPlatformButtonVerticalPadding,
     content: @Composable RowScope.() -> Unit,
 ) {
     androidx.compose.material3.OutlinedButton(
-        modifier = modifier.padding(vertical = 6.dp).height(36.dp),
+        modifier = modifier.padding(vertical = verticalPadding).height(36.dp),
         enabled = enabled,
-        colors = outlineButtonColors(),
-        border = outlineButtonBorder(),
+        colors = colors,
+        border = border,
         contentPadding = ButtonPaddings,
         onClick = onClick,
     ) {
@@ -71,6 +77,7 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
+    colors: ButtonColors = textButtonColors(),
     content: @Composable RowScope.() -> Unit,
 ) {
     androidx.compose.material3.TextButton(
@@ -78,10 +85,11 @@
         modifier = modifier,
         enabled = enabled,
         content = content,
-        colors = textButtonColors(),
+        colors = colors,
     )
 }
 
+private val DefaultPlatformButtonVerticalPadding = 6.dp
 private val ButtonPaddings = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
 
 @Composable
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt
index a61e959..a9944f7 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt
@@ -24,18 +24,30 @@
 import androidx.compose.animation.core.snap
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
 import androidx.compose.material3.Button
 import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
@@ -48,12 +60,18 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import com.android.compose.PlatformButton
 import com.android.compose.animation.scene.ElementKey
 import com.android.compose.animation.scene.SceneScope
 import com.android.compose.windowsizeclass.LocalWindowSizeClass
@@ -62,6 +80,8 @@
 import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
 import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
 import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
+import com.android.systemui.common.shared.model.Text.Companion.loadText
+import com.android.systemui.common.ui.compose.Icon
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.res.R
 import com.android.systemui.scene.shared.model.Direction
@@ -70,6 +90,9 @@
 import com.android.systemui.scene.shared.model.UserAction
 import com.android.systemui.scene.ui.composable.ComposableScene
 import javax.inject.Inject
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.pow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -164,7 +187,7 @@
     Column(
         horizontalAlignment = Alignment.CenterHorizontally,
         verticalArrangement = Arrangement.spacedBy(60.dp),
-        modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp)
+        modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 92.dp)
     ) {
         Crossfade(
             targetState = message,
@@ -201,18 +224,20 @@
             }
         }
 
-        Button(
-            onClick = viewModel::onEmergencyServicesButtonClicked,
-            colors =
-                ButtonDefaults.buttonColors(
-                    containerColor = MaterialTheme.colorScheme.tertiaryContainer,
-                    contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
-                ),
-        ) {
-            Text(
-                text = stringResource(com.android.internal.R.string.lockscreen_emergency_call),
-                style = MaterialTheme.typography.bodyMedium,
-            )
+        if (viewModel.isEmergencyButtonVisible) {
+            Button(
+                onClick = viewModel::onEmergencyServicesButtonClicked,
+                colors =
+                    ButtonDefaults.buttonColors(
+                        containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+                        contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+                    ),
+            ) {
+                Text(
+                    text = stringResource(com.android.internal.R.string.lockscreen_emergency_call),
+                    style = MaterialTheme.typography.bodyMedium,
+                )
+            }
         }
 
         if (dialogMessage != null) {
@@ -241,16 +266,133 @@
 /** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */
 @Composable
 private fun UserSwitcher(
+    viewModel: BouncerViewModel,
     modifier: Modifier = Modifier,
 ) {
-    Box(modifier) {
-        Text(
-            text = "TODO: the user switcher goes here",
-            modifier = Modifier.align(Alignment.Center)
+    val selectedUserImage by viewModel.selectedUserImage.collectAsState(null)
+    val dropdownItems by viewModel.userSwitcherDropdown.collectAsState(emptyList())
+
+    Column(
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
+        modifier = modifier,
+    ) {
+        selectedUserImage?.let {
+            Image(
+                bitmap = it.asImageBitmap(),
+                contentDescription = null,
+                modifier = Modifier.size(SelectedUserImageSize),
+            )
+        }
+
+        UserSwitcherDropdown(
+            items = dropdownItems,
         )
     }
 }
 
+@Composable
+private fun UserSwitcherDropdown(
+    items: List<BouncerViewModel.UserSwitcherDropdownItemViewModel>,
+) {
+    val (isDropdownExpanded, setDropdownExpanded) = remember { mutableStateOf(false) }
+
+    items.firstOrNull()?.let { firstDropdownItem ->
+        Spacer(modifier = Modifier.height(40.dp))
+
+        Box {
+            PlatformButton(
+                modifier =
+                    Modifier
+                        // Remove the built-in padding applied inside PlatformButton:
+                        .padding(vertical = 0.dp)
+                        .width(UserSwitcherDropdownWidth)
+                        .height(UserSwitcherDropdownHeight),
+                colors =
+                    ButtonDefaults.buttonColors(
+                        containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+                        contentColor = MaterialTheme.colorScheme.onSurface,
+                    ),
+                onClick = { setDropdownExpanded(!isDropdownExpanded) },
+            ) {
+                val context = LocalContext.current
+                Text(
+                    text = checkNotNull(firstDropdownItem.text.loadText(context)),
+                    style = MaterialTheme.typography.headlineSmall,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+
+                Spacer(modifier = Modifier.weight(1f))
+
+                Icon(
+                    imageVector = Icons.Default.KeyboardArrowDown,
+                    contentDescription = null,
+                    modifier = Modifier.size(32.dp),
+                )
+            }
+
+            UserSwitcherDropdownMenu(
+                isExpanded = isDropdownExpanded,
+                items = items,
+                onDismissed = { setDropdownExpanded(false) },
+            )
+        }
+    }
+}
+
+@Composable
+private fun UserSwitcherDropdownMenu(
+    isExpanded: Boolean,
+    items: List<BouncerViewModel.UserSwitcherDropdownItemViewModel>,
+    onDismissed: () -> Unit,
+) {
+    val context = LocalContext.current
+
+    // TODO(b/303071855): once the FR is fixed, remove this composition local override.
+    MaterialTheme(
+        colorScheme =
+            MaterialTheme.colorScheme.copy(
+                surface = MaterialTheme.colorScheme.surfaceContainerHighest,
+            ),
+        shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(28.dp)),
+    ) {
+        DropdownMenu(
+            expanded = isExpanded,
+            onDismissRequest = onDismissed,
+            offset =
+                DpOffset(
+                    x = 0.dp,
+                    y = -UserSwitcherDropdownHeight,
+                ),
+            modifier = Modifier.width(UserSwitcherDropdownWidth),
+        ) {
+            items.forEach { userSwitcherDropdownItem ->
+                DropdownMenuItem(
+                    leadingIcon = {
+                        Icon(
+                            icon = userSwitcherDropdownItem.icon,
+                            tint = MaterialTheme.colorScheme.primary,
+                            modifier = Modifier.size(28.dp),
+                        )
+                    },
+                    text = {
+                        Text(
+                            text = checkNotNull(userSwitcherDropdownItem.text.loadText(context)),
+                            style = MaterialTheme.typography.bodyLarge,
+                            color = MaterialTheme.colorScheme.onSurface,
+                        )
+                    },
+                    onClick = {
+                        onDismissed()
+                        userSwitcherDropdownItem.onClick()
+                    },
+                )
+            }
+        }
+    }
+}
+
 /**
  * Arranges the bouncer contents and user switcher contents side-by-side, supporting a double tap
  * anywhere on the background to flip their positions.
@@ -293,7 +435,7 @@
                         1f
                     } else {
                         // Since the user switcher is not first, the elements have to be swapped
-                        // horizontally. In the case of RTL locales, this means pushing the user
+                        // horizontally. In the case of RTL locale, this means pushing the user
                         // switcher to the left, hence the negative number.
                         -1f
                     },
@@ -301,21 +443,28 @@
             )
 
         UserSwitcher(
+            viewModel = viewModel,
             modifier =
                 Modifier.fillMaxHeight().weight(1f).graphicsLayer {
                     translationX = size.width * animatedOffset
+                    alpha = animatedAlpha(animatedOffset)
                 },
         )
-        Bouncer(
-            viewModel = viewModel,
-            dialogFactory = dialogFactory,
+        Box(
             modifier =
                 Modifier.fillMaxHeight().weight(1f).graphicsLayer {
                     // A negative sign is used to make sure this is offset in the direction that's
                     // opposite of the direction that the user switcher is pushed in.
                     translationX = -size.width * animatedOffset
-                },
-        )
+                    alpha = animatedAlpha(animatedOffset)
+                }
+        ) {
+            Bouncer(
+                viewModel = viewModel,
+                dialogFactory = dialogFactory,
+                modifier = Modifier.widthIn(max = 400.dp).align(Alignment.BottomCenter),
+            )
+        }
     }
 }
 
@@ -330,6 +479,7 @@
         modifier = modifier,
     ) {
         UserSwitcher(
+            viewModel = viewModel,
             modifier = Modifier.fillMaxWidth().weight(1f),
         )
         Bouncer(
@@ -343,3 +493,36 @@
 interface BouncerSceneDialogFactory {
     operator fun invoke(): AlertDialog
 }
+
+/**
+ * Calculates an alpha for the user switcher and bouncer such that it's at `1` when the offset of
+ * the two reaches a stopping point but `0` in the middle of the transition.
+ */
+private fun animatedAlpha(
+    offset: Float,
+): Float {
+    // Describes a curve that is made of two parabolic U-shaped curves mirrored horizontally around
+    // the y-axis. The U on the left runs between x = -1 and x = 0 while the U on the right runs
+    // between x = 0 and x = 1.
+    //
+    // The minimum values of the curves are at -0.5 and +0.5.
+    //
+    // Both U curves are vertically scaled such that they reach the points (-1, 1) and (1, 1).
+    //
+    // Breaking it down, it's y = a×(|x|-m)²+b, where:
+    // x: the offset
+    // y: the alpha
+    // m: x-axis center of the parabolic curves, where the minima are.
+    // b: y-axis offset to apply to the entire curve so the animation spends more time with alpha =
+    // 0.
+    // a: amplitude to scale the parabolic curves to reach y = 1 at x = -1, x = 0, and x = +1.
+    val m = 0.5f
+    val b = -0.25
+    val a = (1 - b) / m.pow(2)
+
+    return max(0f, (a * (abs(offset) - m).pow(2) + b).toFloat())
+}
+
+private val SelectedUserImageSize = 190.dp
+private val UserSwitcherDropdownWidth = SelectedUserImageSize + 2 * 29.dp
+private val UserSwitcherDropdownHeight = 60.dp
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt
index 0cbfb68..7f3b794 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt
@@ -16,10 +16,16 @@
 
 package com.android.systemui.bouncer.ui
 
+import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModelModule
 import dagger.Binds
 import dagger.Module
 
-@Module
+@Module(
+    includes =
+        [
+            BouncerViewModelModule::class,
+        ],
+)
 interface BouncerViewModule {
     /** Binds BouncerView to BouncerViewImpl and makes it injectable. */
     @Binds fun bindBouncerView(bouncerViewImpl: BouncerViewImpl): BouncerView
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt
index c98cf31..2cb98d8 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt
@@ -17,19 +17,29 @@
 package com.android.systemui.bouncer.ui.viewmodel
 
 import android.content.Context
+import android.graphics.Bitmap
+import androidx.core.graphics.drawable.toBitmap
 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
 import com.android.systemui.authentication.domain.model.AuthenticationMethodModel
 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
+import com.android.systemui.common.shared.model.Icon
+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.dagger.qualifiers.Main
 import com.android.systemui.scene.shared.flag.SceneContainerFlags
-import javax.inject.Inject
+import com.android.systemui.telephony.domain.interactor.TelephonyInteractor
+import com.android.systemui.user.ui.viewmodel.UserActionViewModel
+import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
+import com.android.systemui.user.ui.viewmodel.UserViewModel
+import dagger.Module
+import dagger.Provides
 import kotlin.math.ceil
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
@@ -42,17 +52,53 @@
 import kotlinx.coroutines.launch
 
 /** Holds UI state and handles user input on bouncer UIs. */
-@SysUISingleton
-class BouncerViewModel
-@Inject
-constructor(
+class BouncerViewModel(
     @Application private val applicationContext: Context,
     @Application private val applicationScope: CoroutineScope,
     @Main private val mainDispatcher: CoroutineDispatcher,
     private val bouncerInteractor: BouncerInteractor,
     authenticationInteractor: AuthenticationInteractor,
     flags: SceneContainerFlags,
+    private val telephonyInteractor: TelephonyInteractor,
+    selectedUser: Flow<UserViewModel>,
+    users: Flow<List<UserViewModel>>,
+    userSwitcherMenu: Flow<List<UserActionViewModel>>,
 ) {
+    val selectedUserImage: StateFlow<Bitmap?> =
+        selectedUser
+            .map { it.image.toBitmap() }
+            .stateIn(
+                scope = applicationScope,
+                started = SharingStarted.WhileSubscribed(),
+                initialValue = null,
+            )
+
+    val userSwitcherDropdown: StateFlow<List<UserSwitcherDropdownItemViewModel>> =
+        combine(
+                users,
+                userSwitcherMenu,
+            ) { users, actions ->
+                users.map { user ->
+                    UserSwitcherDropdownItemViewModel(
+                        icon = Icon.Loaded(user.image, contentDescription = null),
+                        text = user.name,
+                        onClick = user.onClicked ?: {},
+                    )
+                } +
+                    actions.map { action ->
+                        UserSwitcherDropdownItemViewModel(
+                            icon = Icon.Resource(action.iconResourceId, contentDescription = null),
+                            text = Text.Resource(action.textResourceId),
+                            onClick = action.onClicked,
+                        )
+                    }
+            }
+            .stateIn(
+                scope = applicationScope,
+                started = SharingStarted.WhileSubscribed(),
+                initialValue = emptyList(),
+            )
+
     private val isInputEnabled: StateFlow<Boolean> =
         bouncerInteractor.isThrottled
             .map { !it }
@@ -102,6 +148,9 @@
                     ),
             )
 
+    val isEmergencyButtonVisible: Boolean
+        get() = telephonyInteractor.hasTelephonyRadio
+
     init {
         if (flags.isEnabled()) {
             applicationScope.launch {
@@ -200,4 +249,40 @@
          */
         val isUpdateAnimated: Boolean,
     )
+
+    data class UserSwitcherDropdownItemViewModel(
+        val icon: Icon,
+        val text: Text,
+        val onClick: () -> Unit,
+    )
+}
+
+@Module
+object BouncerViewModelModule {
+
+    @Provides
+    @SysUISingleton
+    fun viewModel(
+        @Application applicationContext: Context,
+        @Application applicationScope: CoroutineScope,
+        @Main mainDispatcher: CoroutineDispatcher,
+        bouncerInteractor: BouncerInteractor,
+        authenticationInteractor: AuthenticationInteractor,
+        flags: SceneContainerFlags,
+        telephonyInteractor: TelephonyInteractor,
+        userSwitcherViewModel: UserSwitcherViewModel,
+    ): BouncerViewModel {
+        return BouncerViewModel(
+            applicationContext = applicationContext,
+            applicationScope = applicationScope,
+            mainDispatcher = mainDispatcher,
+            bouncerInteractor = bouncerInteractor,
+            authenticationInteractor = authenticationInteractor,
+            flags = flags,
+            telephonyInteractor = telephonyInteractor,
+            selectedUser = userSwitcherViewModel.selectedUser,
+            users = userSwitcherViewModel.users,
+            userSwitcherMenu = userSwitcherViewModel.menu,
+        )
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/telephony/data/repository/TelephonyRepository.kt b/packages/SystemUI/src/com/android/systemui/telephony/data/repository/TelephonyRepository.kt
index 9c38dc0f..3b300249 100644
--- a/packages/SystemUI/src/com/android/systemui/telephony/data/repository/TelephonyRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/telephony/data/repository/TelephonyRepository.kt
@@ -17,10 +17,13 @@
 
 package com.android.systemui.telephony.data.repository
 
+import android.content.Context
+import android.content.pm.PackageManager
 import android.telephony.Annotation
 import android.telephony.TelephonyCallback
 import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.telephony.TelephonyListenerManager
 import javax.inject.Inject
 import kotlinx.coroutines.channels.awaitClose
@@ -30,6 +33,9 @@
 interface TelephonyRepository {
     /** The state of the current call. */
     @Annotation.CallState val callState: Flow<Int>
+
+    /** Whether the device has a radio that can be used for telephony. */
+    val hasTelephonyRadio: Boolean
 }
 
 /**
@@ -43,6 +49,7 @@
 class TelephonyRepositoryImpl
 @Inject
 constructor(
+    @Application private val applicationContext: Context,
     private val manager: TelephonyListenerManager,
 ) : TelephonyRepository {
     @Annotation.CallState
@@ -53,4 +60,7 @@
 
         awaitClose { manager.removeCallStateListener(listener) }
     }
+
+    override val hasTelephonyRadio: Boolean
+        get() = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
 }
diff --git a/packages/SystemUI/src/com/android/systemui/telephony/domain/interactor/TelephonyInteractor.kt b/packages/SystemUI/src/com/android/systemui/telephony/domain/interactor/TelephonyInteractor.kt
index 86ca33d..4642f55 100644
--- a/packages/SystemUI/src/com/android/systemui/telephony/domain/interactor/TelephonyInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/telephony/domain/interactor/TelephonyInteractor.kt
@@ -28,7 +28,11 @@
 class TelephonyInteractor
 @Inject
 constructor(
-    repository: TelephonyRepository,
+    private val repository: TelephonyRepository,
 ) {
     @Annotation.CallState val callState: Flow<Int> = repository.callState
+
+    /** Whether the device has a radio that can be used for telephony. */
+    val hasTelephonyRadio: Boolean
+        get() = repository.hasTelephonyRadio
 }
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt
index 61952ba..d3f83b1 100644
--- a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt
@@ -17,10 +17,10 @@
 
 package com.android.systemui.user.ui.viewmodel
 
-import com.android.systemui.res.R
 import com.android.systemui.common.shared.model.Text
 import com.android.systemui.common.ui.drawable.CircularDrawable
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.res.R
 import com.android.systemui.user.domain.interactor.GuestUserInteractor
 import com.android.systemui.user.domain.interactor.UserInteractor
 import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper
@@ -42,6 +42,10 @@
     private val guestUserInteractor: GuestUserInteractor,
 ) {
 
+    /** The currently selected user. */
+    val selectedUser: Flow<UserViewModel> =
+        userInteractor.selectedUser.map { user -> toViewModel(user) }
+
     /** On-device users. */
     val users: Flow<List<UserViewModel>> =
         userInteractor.users.map { models -> models.map { user -> toViewModel(user) } }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/telephony/data/repository/TelephonyRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/telephony/data/repository/TelephonyRepositoryImplTest.kt
index 773a0d8..0209030 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/telephony/data/repository/TelephonyRepositoryImplTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/telephony/data/repository/TelephonyRepositoryImplTest.kt
@@ -49,6 +49,7 @@
 
         underTest =
             TelephonyRepositoryImpl(
+                applicationContext = context,
                 manager = manager,
             )
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt
index 179206f..d8a6b1a 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt
@@ -17,6 +17,9 @@
 package com.android.systemui.scene
 
 import android.content.pm.UserInfo
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.util.Log
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.authentication.data.model.AuthenticationMethodModel as DataLayerAuthenticationMethodModel
 import com.android.systemui.authentication.data.repository.AuthenticationRepository
@@ -30,6 +33,7 @@
 import com.android.systemui.classifier.FalsingCollector
 import com.android.systemui.classifier.FalsingCollectorFake
 import com.android.systemui.classifier.domain.interactor.FalsingInteractor
+import com.android.systemui.common.shared.model.Text
 import com.android.systemui.common.ui.data.repository.FakeConfigurationRepository
 import com.android.systemui.communal.data.repository.FakeCommunalRepository
 import com.android.systemui.communal.data.repository.FakeCommunalWidgetRepository
@@ -53,12 +57,17 @@
 import com.android.systemui.scene.shared.model.SceneContainerConfig
 import com.android.systemui.scene.shared.model.SceneKey
 import com.android.systemui.shade.data.repository.FakeShadeRepository
+import com.android.systemui.telephony.data.repository.FakeTelephonyRepository
+import com.android.systemui.telephony.domain.interactor.TelephonyInteractor
 import com.android.systemui.user.data.repository.FakeUserRepository
 import com.android.systemui.user.data.repository.UserRepository
+import com.android.systemui.user.ui.viewmodel.UserActionViewModel
+import com.android.systemui.user.ui.viewmodel.UserViewModel
 import com.android.systemui.util.mockito.mock
 import com.android.systemui.util.mockito.whenever
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.TestScope
@@ -219,7 +228,9 @@
     fun bouncerViewModel(
         bouncerInteractor: BouncerInteractor,
         authenticationInteractor: AuthenticationInteractor,
+        users: List<UserViewModel> = createUsers(),
     ): BouncerViewModel {
+        Log.d("ALE", "users=$users")
         return BouncerViewModel(
             applicationContext = context,
             applicationScope = applicationScope(),
@@ -227,6 +238,13 @@
             bouncerInteractor = bouncerInteractor,
             authenticationInteractor = authenticationInteractor,
             flags = sceneContainerFlags,
+            selectedUser = flowOf(users.first { it.isSelectionMarkerVisible }),
+            users = flowOf(users),
+            userSwitcherMenu = flowOf(createMenuActions()),
+            telephonyInteractor =
+                TelephonyInteractor(
+                    repository = FakeTelephonyRepository(),
+                ),
         )
     }
 
@@ -242,6 +260,43 @@
         return testScope.backgroundScope
     }
 
+    private fun createUsers(
+        count: Int = 3,
+        selectedIndex: Int = 0,
+    ): List<UserViewModel> {
+        check(selectedIndex in 0 until count)
+
+        return buildList {
+            repeat(count) { index ->
+                add(
+                    UserViewModel(
+                        viewKey = index,
+                        name = Text.Loaded("name_$index"),
+                        image = BitmapDrawable(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)),
+                        isSelectionMarkerVisible = index == selectedIndex,
+                        alpha = 1f,
+                        onClicked = {},
+                    )
+                )
+            }
+        }
+    }
+
+    private fun createMenuActions(): List<UserActionViewModel> {
+        return buildList {
+            repeat(3) { index ->
+                add(
+                    UserActionViewModel(
+                        viewKey = index.toLong(),
+                        iconResourceId = 0,
+                        textResourceId = 0,
+                        onClicked = {},
+                    )
+                )
+            }
+        }
+    }
+
     companion object {
         fun DomainLayerAuthenticationMethodModel.toDataLayer(): DataLayerAuthenticationMethodModel {
             return when (this) {
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/telephony/data/repository/FakeTelephonyRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/telephony/data/repository/FakeTelephonyRepository.kt
index 7c70846..992ac62 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/telephony/data/repository/FakeTelephonyRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/telephony/data/repository/FakeTelephonyRepository.kt
@@ -31,9 +31,16 @@
     private val _callState = MutableStateFlow(0)
     override val callState: Flow<Int> = _callState.asStateFlow()
 
+    override var hasTelephonyRadio: Boolean = true
+        private set
+
     fun setCallState(value: Int) {
         _callState.value = value
     }
+
+    fun setHasRadio(hasRadio: Boolean) {
+        this.hasTelephonyRadio = hasRadio
+    }
 }
 
 @Module