• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.bouncer.ui.composable
18 
19 import android.app.AlertDialog
20 import android.content.DialogInterface
21 import androidx.annotation.VisibleForTesting
22 import androidx.compose.animation.Crossfade
23 import androidx.compose.animation.core.Animatable
24 import androidx.compose.animation.core.animateFloatAsState
25 import androidx.compose.animation.core.snap
26 import androidx.compose.animation.core.tween
27 import androidx.compose.foundation.Image
28 import androidx.compose.foundation.background
29 import androidx.compose.foundation.combinedClickable
30 import androidx.compose.foundation.gestures.awaitEachGesture
31 import androidx.compose.foundation.gestures.awaitFirstDown
32 import androidx.compose.foundation.gestures.detectTapGestures
33 import androidx.compose.foundation.layout.Arrangement
34 import androidx.compose.foundation.layout.Box
35 import androidx.compose.foundation.layout.BoxScope
36 import androidx.compose.foundation.layout.Column
37 import androidx.compose.foundation.layout.Row
38 import androidx.compose.foundation.layout.Spacer
39 import androidx.compose.foundation.layout.fillMaxHeight
40 import androidx.compose.foundation.layout.fillMaxWidth
41 import androidx.compose.foundation.layout.height
42 import androidx.compose.foundation.layout.imePadding
43 import androidx.compose.foundation.layout.padding
44 import androidx.compose.foundation.layout.size
45 import androidx.compose.foundation.layout.width
46 import androidx.compose.foundation.shape.RoundedCornerShape
47 import androidx.compose.material.icons.Icons
48 import androidx.compose.material.icons.filled.KeyboardArrowDown
49 import androidx.compose.material3.ButtonDefaults
50 import androidx.compose.material3.DropdownMenu
51 import androidx.compose.material3.DropdownMenuItem
52 import androidx.compose.material3.Icon
53 import androidx.compose.material3.MaterialTheme
54 import androidx.compose.material3.Text
55 import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
56 import androidx.compose.runtime.Composable
57 import androidx.compose.runtime.DisposableEffect
58 import androidx.compose.runtime.LaunchedEffect
59 import androidx.compose.runtime.getValue
60 import androidx.compose.runtime.mutableStateOf
61 import androidx.compose.runtime.remember
62 import androidx.compose.runtime.setValue
63 import androidx.compose.ui.Alignment
64 import androidx.compose.ui.Modifier
65 import androidx.compose.ui.draw.clip
66 import androidx.compose.ui.draw.scale
67 import androidx.compose.ui.graphics.asImageBitmap
68 import androidx.compose.ui.graphics.graphicsLayer
69 import androidx.compose.ui.input.key.onKeyEvent
70 import androidx.compose.ui.input.pointer.PointerInputChange
71 import androidx.compose.ui.input.pointer.pointerInput
72 import androidx.compose.ui.platform.LocalContext
73 import androidx.compose.ui.platform.LocalDensity
74 import androidx.compose.ui.platform.LocalLayoutDirection
75 import androidx.compose.ui.platform.testTag
76 import androidx.compose.ui.res.stringResource
77 import androidx.compose.ui.semantics.Role
78 import androidx.compose.ui.semantics.role
79 import androidx.compose.ui.semantics.semantics
80 import androidx.compose.ui.text.style.TextOverflow
81 import androidx.compose.ui.unit.Dp
82 import androidx.compose.ui.unit.DpOffset
83 import androidx.compose.ui.unit.LayoutDirection
84 import androidx.compose.ui.unit.dp
85 import androidx.compose.ui.unit.sp
86 import androidx.compose.ui.unit.times
87 import androidx.lifecycle.compose.collectAsStateWithLifecycle
88 import com.android.compose.PlatformButton
89 import com.android.compose.animation.Easings
90 import com.android.compose.animation.scene.ContentScope
91 import com.android.compose.animation.scene.ElementKey
92 import com.android.compose.animation.scene.SceneKey
93 import com.android.compose.animation.scene.SceneTransitionLayout
94 import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState
95 import com.android.compose.animation.scene.transitions
96 import com.android.compose.windowsizeclass.LocalWindowSizeClass
97 import com.android.systemui.bouncer.shared.model.BouncerActionButtonModel
98 import com.android.systemui.bouncer.ui.BouncerDialogFactory
99 import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
100 import com.android.systemui.bouncer.ui.viewmodel.BouncerMessageViewModel
101 import com.android.systemui.bouncer.ui.viewmodel.BouncerOverlayContentViewModel
102 import com.android.systemui.bouncer.ui.viewmodel.MessageViewModel
103 import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
104 import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
105 import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
106 import com.android.systemui.common.shared.model.Text.Companion.loadText
107 import com.android.systemui.common.ui.compose.Icon
108 import com.android.systemui.compose.modifiers.sysuiResTag
109 import com.android.systemui.fold.ui.composable.foldPosture
110 import com.android.systemui.fold.ui.helper.FoldPosture
111 import com.android.systemui.res.R
112 import kotlin.math.abs
113 import kotlin.math.max
114 import kotlin.math.pow
115 import platform.test.motion.compose.values.MotionTestValueKey
116 import platform.test.motion.compose.values.MotionTestValues
117 import platform.test.motion.compose.values.motionTestValues
118 
119 @Composable
120 fun BouncerContent(
121     viewModel: BouncerOverlayContentViewModel,
122     dialogFactory: BouncerDialogFactory,
123     modifier: Modifier = Modifier,
124 ) {
125     val isOneHandedModeSupported by viewModel.isOneHandedModeSupported.collectAsStateWithLifecycle()
126     val layout = calculateLayout(isOneHandedModeSupported = isOneHandedModeSupported)
127 
128     BouncerContent(layout, viewModel, dialogFactory, modifier)
129 }
130 
131 @Composable
132 @VisibleForTesting
BouncerContentnull133 fun BouncerContent(
134     layout: BouncerOverlayLayout,
135     viewModel: BouncerOverlayContentViewModel,
136     dialogFactory: BouncerDialogFactory,
137     modifier: Modifier,
138 ) {
139     val scale by viewModel.scale.collectAsStateWithLifecycle()
140     Box(
141         // Allows the content within each of the layouts to react to the appearance and
142         // disappearance of the IME, which is also known as the software keyboard.
143         //
144         // Despite the keyboard only being part of the password bouncer, adding it at this level is
145         // both necessary to properly handle the keyboard in all layouts and harmless in cases when
146         // the keyboard isn't used (like the PIN or pattern auth methods).
147         modifier = modifier.imePadding().onKeyEvent(viewModel::onKeyEvent).scale(scale)
148     ) {
149         when (layout) {
150             BouncerOverlayLayout.STANDARD_BOUNCER -> StandardLayout(viewModel = viewModel)
151             BouncerOverlayLayout.BESIDE_USER_SWITCHER ->
152                 BesideUserSwitcherLayout(viewModel = viewModel)
153             BouncerOverlayLayout.BELOW_USER_SWITCHER ->
154                 BelowUserSwitcherLayout(viewModel = viewModel)
155             BouncerOverlayLayout.SPLIT_BOUNCER -> SplitLayout(viewModel = viewModel)
156         }
157 
158         Dialog(bouncerViewModel = viewModel, dialogFactory = dialogFactory)
159     }
160 }
161 
162 /**
163  * Renders the contents of the actual bouncer UI, the area that takes user input to do an
164  * authentication attempt, including all messaging UI (directives, reasoning, errors, etc.).
165  */
166 @Composable
StandardLayoutnull167 private fun StandardLayout(
168     viewModel: BouncerOverlayContentViewModel,
169     modifier: Modifier = Modifier,
170 ) {
171     val isHeightExpanded =
172         LocalWindowSizeClass.current.heightSizeClass == WindowHeightSizeClass.Expanded
173 
174     FoldAware(
175         modifier = modifier.padding(top = 92.dp, bottom = 48.dp),
176         viewModel = viewModel,
177         aboveFold = {
178             Column(
179                 horizontalAlignment = Alignment.CenterHorizontally,
180                 modifier = Modifier.fillMaxWidth(),
181             ) {
182                 StatusMessage(viewModel = viewModel.message, modifier = Modifier)
183 
184                 OutputArea(
185                     viewModel = viewModel,
186                     modifier = Modifier.padding(top = if (isHeightExpanded) 96.dp else 64.dp),
187                 )
188             }
189         },
190         belowFold = {
191             Column(
192                 horizontalAlignment = Alignment.CenterHorizontally,
193                 modifier = Modifier.fillMaxWidth(),
194             ) {
195                 Box(modifier = Modifier.weight(1f)) {
196                     InputArea(
197                         viewModel = viewModel,
198                         pinButtonRowVerticalSpacing = 12.dp,
199                         centerPatternDotsVertically = false,
200                         modifier = Modifier.align(Alignment.BottomCenter),
201                     )
202                 }
203 
204                 ActionArea(viewModel = viewModel, modifier = Modifier.padding(top = 48.dp))
205             }
206         },
207     )
208 }
209 
210 /**
211  * Renders the bouncer UI in split mode, with half on one side and half on the other side, swappable
212  * by double-tapping on the side.
213  */
214 @Composable
SplitLayoutnull215 private fun SplitLayout(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
216     val authMethod by viewModel.authMethodViewModel.collectAsStateWithLifecycle()
217 
218     Row(
219         modifier =
220             modifier
221                 .fillMaxHeight()
222                 .padding(
223                     horizontal = 24.dp,
224                     vertical = if (authMethod is PasswordBouncerViewModel) 24.dp else 48.dp,
225                 )
226     ) {
227         // Left side (in left-to-right locales).
228         Box(modifier = Modifier.fillMaxHeight().weight(1f)) {
229             when (authMethod) {
230                 is PinBouncerViewModel -> {
231                     StatusMessage(
232                         viewModel = viewModel.message,
233                         modifier = Modifier.align(Alignment.TopCenter),
234                     )
235                     OutputArea(
236                         viewModel = viewModel,
237                         modifier =
238                             Modifier.align(Alignment.Center).sysuiResTag("bouncer_text_entry"),
239                     )
240 
241                     ActionArea(
242                         viewModel = viewModel,
243                         modifier = Modifier.align(Alignment.BottomCenter).padding(top = 48.dp),
244                     )
245                 }
246                 is PatternBouncerViewModel -> {
247                     StatusMessage(
248                         viewModel = viewModel.message,
249                         modifier = Modifier.align(Alignment.TopCenter),
250                     )
251 
252                     ActionArea(
253                         viewModel = viewModel,
254                         modifier = Modifier.align(Alignment.BottomCenter).padding(vertical = 48.dp),
255                     )
256                 }
257                 is PasswordBouncerViewModel -> {
258                     ActionArea(
259                         viewModel = viewModel,
260                         modifier = Modifier.align(Alignment.BottomCenter),
261                     )
262                 }
263                 else -> Unit
264             }
265         }
266 
267         // Right side (in right-to-left locales).
268         Box(modifier = Modifier.fillMaxHeight().weight(1f)) {
269             when (authMethod) {
270                 is PinBouncerViewModel,
271                 is PatternBouncerViewModel -> {
272                     InputArea(
273                         viewModel = viewModel,
274                         pinButtonRowVerticalSpacing = 8.dp,
275                         centerPatternDotsVertically = true,
276                         modifier = Modifier.align(Alignment.Center),
277                     )
278                 }
279                 is PasswordBouncerViewModel -> {
280                     Column(
281                         horizontalAlignment = Alignment.CenterHorizontally,
282                         modifier = Modifier.fillMaxWidth().align(Alignment.Center),
283                     ) {
284                         StatusMessage(viewModel = viewModel.message)
285                         OutputArea(
286                             viewModel = viewModel,
287                             modifier =
288                                 Modifier.padding(top = 24.dp).sysuiResTag("bouncer_text_entry"),
289                         )
290                     }
291                 }
292                 else -> Unit
293             }
294         }
295     }
296 }
297 
298 /**
299  * Arranges the bouncer contents and user switcher contents side-by-side, supporting a double tap
300  * anywhere on the background to flip their positions.
301  */
302 @Composable
BesideUserSwitcherLayoutnull303 private fun BesideUserSwitcherLayout(
304     viewModel: BouncerOverlayContentViewModel,
305     modifier: Modifier = Modifier,
306 ) {
307     val isLeftToRight = LocalLayoutDirection.current == LayoutDirection.Ltr
308     val isInputPreferredOnLeftSide by
309         viewModel.isInputPreferredOnLeftSide.collectAsStateWithLifecycle()
310     // Swaps the order of user switcher and bouncer input area
311     // Default layout is assumed as user switcher followed by bouncer input area in the direction
312     // of layout.
313     val isSwapped = isLeftToRight == isInputPreferredOnLeftSide
314     val isHeightExpanded =
315         LocalWindowSizeClass.current.heightSizeClass == WindowHeightSizeClass.Expanded
316     val authMethod by viewModel.authMethodViewModel.collectAsStateWithLifecycle()
317 
318     var swapAnimationEnd by remember { mutableStateOf(false) }
319 
320     fun wasEventOnNonInputHalfOfScreen(x: Float, totalWidth: Int): Boolean {
321         // Default layout is assumed as user switcher followed by bouncer input area in
322         // the direction of layout. Swapped layout means that bouncer input area is first, followed
323         // by user switcher in the direction of layout.
324         val halfWidth = totalWidth / 2
325         return if (x > halfWidth) {
326             isLeftToRight && isSwapped
327         } else {
328             isLeftToRight && !isSwapped
329         }
330     }
331 
332     Row(
333         modifier =
334             modifier
335                 .pointerInput(isSwapped, isInputPreferredOnLeftSide) {
336                     detectTapGestures(
337                         onDoubleTap = { offset ->
338                             // Depending on where the user double tapped, switch the elements such
339                             // that the non-swapped element is closer to the side that was double
340                             // tapped.
341                             viewModel.onDoubleTap(
342                                 wasEventOnNonInputHalfOfScreen(offset.x, size.width)
343                             )
344                         }
345                     )
346                 }
347                 .pointerInput(isSwapped, isInputPreferredOnLeftSide) {
348                     awaitEachGesture {
349                         val downEvent: PointerInputChange = awaitFirstDown()
350                         viewModel.onDown(
351                             wasEventOnNonInputHalfOfScreen(downEvent.position.x, size.width)
352                         )
353                     }
354                 }
355                 .testTag("BesideUserSwitcherLayout")
356                 .motionTestValues {
357                     swapAnimationEnd exportAs BouncerMotionTestKeys.swapAnimationEnd
358                 }
359                 .padding(
360                     top = if (isHeightExpanded) 128.dp else 96.dp,
361                     bottom = if (isHeightExpanded) 128.dp else 48.dp,
362                 )
363     ) {
364         LaunchedEffect(isSwapped) { swapAnimationEnd = false }
365         val animatedOffset by
366             animateFloatAsState(
367                 targetValue =
368                     if (!isSwapped) {
369                         // A non-swapped element has its natural placement so it's not offset.
370                         0f
371                     } else if (isLeftToRight) {
372                         // A swapped element has its elements offset horizontally. In the case of
373                         // LTR locales, this means pushing the element to the right, hence the
374                         // positive number.
375                         1f
376                     } else {
377                         // A swapped element has its elements offset horizontally. In the case of
378                         // RTL locales, this means pushing the element to the left, hence the
379                         // negative number.
380                         -1f
381                     },
382                 label = "offset",
383             ) {
384                 swapAnimationEnd = true
385             }
386 
387         fun Modifier.swappable(inversed: Boolean = false): Modifier {
388             return graphicsLayer {
389                     translationX =
390                         size.width *
391                             animatedOffset *
392                             if (inversed) {
393                                 // A negative sign is used to make sure this is offset in the
394                                 // direction that's opposite to the direction that the user
395                                 // switcher is pushed in.
396                                 -1
397                             } else {
398                                 1
399                             }
400                     alpha = animatedAlpha(animatedOffset)
401                 }
402                 .motionTestValues { animatedAlpha(animatedOffset) exportAs MotionTestValues.alpha }
403         }
404 
405         UserSwitcher(viewModel = viewModel, modifier = Modifier.weight(1f).swappable())
406 
407         FoldAware(
408             modifier = Modifier.weight(1f).swappable(inversed = true).testTag("FoldAware"),
409             viewModel = viewModel,
410             aboveFold = {
411                 Column(
412                     horizontalAlignment = Alignment.CenterHorizontally,
413                     modifier = Modifier.fillMaxWidth(),
414                 ) {
415                     StatusMessage(viewModel = viewModel.message)
416                     OutputArea(
417                         viewModel = viewModel,
418                         modifier = Modifier.padding(top = 24.dp).sysuiResTag("bouncer_text_entry"),
419                     )
420                 }
421             },
422             belowFold = {
423                 Column(
424                     horizontalAlignment = Alignment.CenterHorizontally,
425                     modifier = Modifier.fillMaxWidth(),
426                 ) {
427                     val isOutputAreaVisible = authMethod !is PatternBouncerViewModel
428                     // If there is an output area and the window is not tall enough, spacing needs
429                     // to be added between the input and the output areas (otherwise the two get
430                     // very squished together).
431                     val addSpacingBetweenOutputAndInput = isOutputAreaVisible && !isHeightExpanded
432 
433                     Box(
434                         modifier =
435                             Modifier.weight(1f)
436                                 .padding(top = (if (addSpacingBetweenOutputAndInput) 24 else 0).dp)
437                     ) {
438                         InputArea(
439                             viewModel = viewModel,
440                             pinButtonRowVerticalSpacing = 12.dp,
441                             centerPatternDotsVertically = true,
442                             modifier = Modifier.align(Alignment.BottomCenter),
443                         )
444                     }
445 
446                     ActionArea(
447                         viewModel = viewModel,
448                         modifier = Modifier.padding(top = 48.dp).testTag("ActionArea"),
449                     )
450                 }
451             },
452         )
453     }
454 }
455 
456 /** Arranges the bouncer contents and user switcher contents one on top of the other, vertically. */
457 @Composable
BelowUserSwitcherLayoutnull458 private fun BelowUserSwitcherLayout(
459     viewModel: BouncerOverlayContentViewModel,
460     modifier: Modifier = Modifier,
461 ) {
462     Column(modifier = modifier.padding(vertical = 128.dp)) {
463         UserSwitcher(viewModel = viewModel, modifier = Modifier.fillMaxWidth())
464 
465         Spacer(Modifier.weight(1f))
466 
467         Box(modifier = Modifier.fillMaxWidth()) {
468             Column(
469                 horizontalAlignment = Alignment.CenterHorizontally,
470                 modifier = Modifier.fillMaxWidth(),
471             ) {
472                 StatusMessage(viewModel = viewModel.message)
473                 OutputArea(viewModel = viewModel, modifier = Modifier.padding(top = 24.dp))
474 
475                 InputArea(
476                     viewModel = viewModel,
477                     pinButtonRowVerticalSpacing = 12.dp,
478                     centerPatternDotsVertically = true,
479                     modifier = Modifier.padding(top = 128.dp),
480                 )
481 
482                 ActionArea(viewModel = viewModel, modifier = Modifier.padding(top = 48.dp))
483             }
484         }
485     }
486 }
487 
488 @Composable
FoldAwarenull489 private fun FoldAware(
490     viewModel: BouncerOverlayContentViewModel,
491     aboveFold: @Composable BoxScope.() -> Unit,
492     belowFold: @Composable BoxScope.() -> Unit,
493     modifier: Modifier = Modifier,
494 ) {
495     val foldPosture: FoldPosture by foldPosture()
496     val isSplitAroundTheFoldRequired by viewModel.isFoldSplitRequired.collectAsStateWithLifecycle()
497     val isSplitAroundTheFold = foldPosture == FoldPosture.Tabletop && isSplitAroundTheFoldRequired
498     val currentSceneKey =
499         if (isSplitAroundTheFold) SceneKeys.SplitSceneKey else SceneKeys.ContiguousSceneKey
500 
501     val state = rememberMutableSceneTransitionLayoutState(currentSceneKey, SceneTransitions)
502 
503     // Update state whenever currentSceneKey has changed.
504     LaunchedEffect(state, currentSceneKey) {
505         if (currentSceneKey != state.transitionState.currentScene) {
506             state.setTargetScene(currentSceneKey, animationScope = this)
507         }
508     }
509 
510     SceneTransitionLayout(state, modifier = modifier) {
511         scene(SceneKeys.ContiguousSceneKey) {
512             FoldableScene(aboveFold = aboveFold, belowFold = belowFold, isSplit = false)
513         }
514 
515         scene(SceneKeys.SplitSceneKey) {
516             FoldableScene(aboveFold = aboveFold, belowFold = belowFold, isSplit = true)
517         }
518     }
519 }
520 
521 @Composable
FoldableScenenull522 private fun ContentScope.FoldableScene(
523     aboveFold: @Composable BoxScope.() -> Unit,
524     belowFold: @Composable BoxScope.() -> Unit,
525     isSplit: Boolean,
526     modifier: Modifier = Modifier,
527 ) {
528     val splitRatio =
529         LocalContext.current.resources.getFloat(
530             R.dimen.motion_layout_half_fold_bouncer_height_ratio
531         )
532 
533     Column(modifier = modifier.fillMaxHeight()) {
534         // Content above the fold, when split on a foldable device in a "table top" posture:
535         Box(
536             modifier =
537                 Modifier.element(SceneElements.AboveFold)
538                     .then(
539                         if (isSplit) {
540                             Modifier.weight(splitRatio)
541                         } else {
542                             Modifier
543                         }
544                     )
545         ) {
546             aboveFold()
547         }
548 
549         // Content below the fold, when split on a foldable device in a "table top" posture:
550         Box(
551             modifier =
552                 Modifier.element(SceneElements.BelowFold)
553                     .weight(
554                         if (isSplit) {
555                             1 - splitRatio
556                         } else {
557                             1f
558                         }
559                     )
560         ) {
561             belowFold()
562         }
563     }
564 }
565 
566 @Composable
StatusMessagenull567 private fun StatusMessage(viewModel: BouncerMessageViewModel, modifier: Modifier = Modifier) {
568     val message: MessageViewModel? by viewModel.message.collectAsStateWithLifecycle()
569 
570     DisposableEffect(Unit) {
571         viewModel.onShown()
572         onDispose {}
573     }
574 
575     Crossfade(
576         targetState = message,
577         label = "Bouncer message",
578         animationSpec = if (message?.isUpdateAnimated == true) tween() else snap(),
579         modifier = modifier.fillMaxWidth(),
580     ) { msg ->
581         Column(
582             horizontalAlignment = Alignment.CenterHorizontally,
583             modifier = Modifier.fillMaxWidth(),
584         ) {
585             msg?.let {
586                 Text(
587                     text = it.text,
588                     color = MaterialTheme.colorScheme.onSurface,
589                     fontSize = 18.sp,
590                     lineHeight = 24.sp,
591                     overflow = TextOverflow.Ellipsis,
592                 )
593                 Spacer(modifier = Modifier.size(10.dp))
594                 Text(
595                     text = it.secondaryText ?: "",
596                     color = MaterialTheme.colorScheme.onSurface,
597                     fontSize = 14.sp,
598                     lineHeight = 20.sp,
599                     overflow = TextOverflow.Ellipsis,
600                     maxLines = 2,
601                 )
602             }
603         }
604     }
605 }
606 
607 /**
608  * Renders the user output area, where the user sees what they entered.
609  *
610  * For example, this can be the PIN shapes or password text field.
611  */
612 @Composable
OutputAreanull613 private fun OutputArea(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
614     val authMethodViewModel: AuthMethodBouncerViewModel? by
615         viewModel.authMethodViewModel.collectAsStateWithLifecycle()
616     when (val nonNullViewModel = authMethodViewModel) {
617         is PinBouncerViewModel ->
618             PinInputDisplay(
619                 viewModel = nonNullViewModel,
620                 modifier = modifier.sysuiResTag("bouncer_text_entry"),
621             )
622         is PasswordBouncerViewModel ->
623             PasswordBouncer(
624                 viewModel = nonNullViewModel,
625                 modifier = modifier.sysuiResTag("bouncer_text_entry"),
626             )
627         else -> Unit
628     }
629 }
630 
631 /**
632  * Renders the user input area, where the user enters their credentials.
633  *
634  * For example, this can be the pattern input area or the PIN pad.
635  */
636 @Composable
InputAreanull637 private fun InputArea(
638     viewModel: BouncerOverlayContentViewModel,
639     pinButtonRowVerticalSpacing: Dp,
640     centerPatternDotsVertically: Boolean,
641     modifier: Modifier = Modifier,
642 ) {
643     val authMethodViewModel: AuthMethodBouncerViewModel? by
644         viewModel.authMethodViewModel.collectAsStateWithLifecycle()
645 
646     when (val nonNullViewModel = authMethodViewModel) {
647         is PinBouncerViewModel -> {
648             PinPad(
649                 viewModel = nonNullViewModel,
650                 verticalSpacing = pinButtonRowVerticalSpacing,
651                 modifier = modifier,
652             )
653         }
654         is PatternBouncerViewModel -> {
655             PatternBouncer(
656                 viewModel = nonNullViewModel,
657                 centerDotsVertically = centerPatternDotsVertically,
658                 modifier = modifier,
659             )
660         }
661         else -> Unit
662     }
663 }
664 
665 @Composable
ActionAreanull666 private fun ActionArea(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
667     val actionButton: BouncerActionButtonModel? by
668         viewModel.actionButton.collectAsStateWithLifecycle()
669     val appearFadeInAnimatable = remember { Animatable(0f) }
670     val appearMoveAnimatable = remember { Animatable(0f) }
671     val appearAnimationInitialOffset = with(LocalDensity.current) { 80.dp.toPx() }
672 
673     actionButton?.let { actionButtonModel ->
674         LaunchedEffect(Unit) {
675             appearFadeInAnimatable.animateTo(
676                 targetValue = 1f,
677                 animationSpec =
678                     tween(
679                         durationMillis = 450,
680                         delayMillis = 133,
681                         easing = Easings.LegacyDecelerate,
682                     ),
683             )
684         }
685         LaunchedEffect(Unit) {
686             appearMoveAnimatable.animateTo(
687                 targetValue = 1f,
688                 animationSpec =
689                     tween(
690                         durationMillis = 450,
691                         delayMillis = 133,
692                         easing = Easings.StandardDecelerate,
693                     ),
694             )
695         }
696 
697         Box(
698             modifier =
699                 modifier
700                     .graphicsLayer {
701                         // Translate the button up from an initially pushed-down position:
702                         translationY =
703                             (1 - appearMoveAnimatable.value) * appearAnimationInitialOffset
704                         // Fade the button in:
705                         alpha = appearFadeInAnimatable.value
706                     }
707                     .height(56.dp)
708                     .clip(ButtonDefaults.shape)
709                     .background(color = MaterialTheme.colorScheme.tertiaryContainer)
710                     .semantics { role = Role.Button }
711                     .combinedClickable(
712                         onClick = { actionButton?.let { viewModel.onActionButtonClicked(it) } },
713                         onLongClick = {
714                             actionButton?.let { viewModel.onActionButtonLongClicked(it) }
715                         },
716                     )
717         ) {
718             Text(
719                 text = stringResource(id = actionButtonModel.labelResId),
720                 style = MaterialTheme.typography.bodyMedium,
721                 color = MaterialTheme.colorScheme.onTertiaryContainer,
722                 modifier = Modifier.align(Alignment.Center).padding(ButtonDefaults.ContentPadding),
723             )
724         }
725     }
726 }
727 
728 @Composable
Dialognull729 private fun Dialog(
730     bouncerViewModel: BouncerOverlayContentViewModel,
731     dialogFactory: BouncerDialogFactory,
732 ) {
733     val dialogViewModel by bouncerViewModel.dialogViewModel.collectAsStateWithLifecycle()
734     var dialog: AlertDialog? by remember { mutableStateOf(null) }
735 
736     dialogViewModel?.let { viewModel ->
737         if (dialog == null) {
738             dialog = dialogFactory()
739         }
740         dialog?.apply {
741             setMessage(viewModel.text)
742             setButton(DialogInterface.BUTTON_NEUTRAL, context.getString(R.string.ok)) { _, _ ->
743                 viewModel.onDismiss()
744             }
745             setCancelable(false)
746             setCanceledOnTouchOutside(false)
747             show()
748         }
749     }
750         ?: {
751             dialog?.dismiss()
752             dialog = null
753         }
754 }
755 
756 /** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */
757 @Composable
UserSwitchernull758 private fun UserSwitcher(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
759     val isUserSwitcherVisible by viewModel.isUserSwitcherVisible.collectAsStateWithLifecycle()
760     if (!isUserSwitcherVisible) {
761         // Take up the same space as the user switcher normally would, but with nothing inside it.
762         Box(modifier = modifier)
763         return
764     }
765 
766     val selectedUserImage by viewModel.selectedUserImage.collectAsStateWithLifecycle(null)
767     val dropdownItems by viewModel.userSwitcherDropdown.collectAsStateWithLifecycle(emptyList())
768 
769     Column(
770         horizontalAlignment = Alignment.CenterHorizontally,
771         verticalArrangement = Arrangement.Center,
772         modifier = modifier.sysuiResTag("UserSwitcher"),
773     ) {
774         selectedUserImage?.let {
775             Image(
776                 bitmap = it.asImageBitmap(),
777                 contentDescription = null,
778                 modifier = Modifier.size(SelectedUserImageSize).sysuiResTag("user_icon"),
779             )
780         }
781 
782         val (isDropdownExpanded, setDropdownExpanded) = remember { mutableStateOf(false) }
783 
784         dropdownItems.firstOrNull()?.let { firstDropdownItem ->
785             Spacer(modifier = Modifier.height(40.dp))
786 
787             Box {
788                 PlatformButton(
789                     modifier =
790                         Modifier
791                             // Remove the built-in padding applied inside PlatformButton:
792                             .padding(vertical = 0.dp)
793                             .width(UserSwitcherDropdownWidth)
794                             .height(UserSwitcherDropdownHeight),
795                     colors =
796                         ButtonDefaults.buttonColors(
797                             containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
798                             contentColor = MaterialTheme.colorScheme.onSurface,
799                         ),
800                     onClick = { setDropdownExpanded(!isDropdownExpanded) },
801                 ) {
802                     val context = LocalContext.current
803                     Text(
804                         text = checkNotNull(firstDropdownItem.text.loadText(context)),
805                         style = MaterialTheme.typography.headlineSmall,
806                         maxLines = 1,
807                         overflow = TextOverflow.Ellipsis,
808                     )
809 
810                     Spacer(modifier = Modifier.weight(1f))
811 
812                     Icon(
813                         imageVector = Icons.Default.KeyboardArrowDown,
814                         contentDescription = null,
815                         modifier = Modifier.size(32.dp).sysuiResTag("user_switcher_anchor"),
816                     )
817                 }
818 
819                 UserSwitcherDropdownMenu(
820                     isExpanded = isDropdownExpanded,
821                     items = dropdownItems,
822                     onDismissed = { setDropdownExpanded(false) },
823                 )
824             }
825         }
826     }
827 }
828 
829 /**
830  * Renders the dropdown menu that displays the actual users and/or user actions that can be
831  * selected.
832  */
833 @Composable
UserSwitcherDropdownMenunull834 private fun UserSwitcherDropdownMenu(
835     isExpanded: Boolean,
836     items: List<BouncerOverlayContentViewModel.UserSwitcherDropdownItemViewModel>,
837     onDismissed: () -> Unit,
838 ) {
839     val context = LocalContext.current
840 
841     // TODO(b/303071855): once the FR is fixed, remove this composition local override.
842     MaterialTheme(
843         colorScheme =
844             MaterialTheme.colorScheme.copy(
845                 surface = MaterialTheme.colorScheme.surfaceContainerHighest
846             ),
847         shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(28.dp)),
848     ) {
849         DropdownMenu(
850             expanded = isExpanded,
851             onDismissRequest = onDismissed,
852             offset = DpOffset(x = 0.dp, y = -UserSwitcherDropdownHeight),
853             modifier = Modifier.width(UserSwitcherDropdownWidth).sysuiResTag("user_list_dropdown"),
854         ) {
855             items.forEach { userSwitcherDropdownItem ->
856                 DropdownMenuItem(
857                     modifier = Modifier.sysuiResTag("user_switcher_item"),
858                     leadingIcon = {
859                         Icon(
860                             icon = userSwitcherDropdownItem.icon,
861                             tint = MaterialTheme.colorScheme.primary,
862                             modifier = Modifier.size(28.dp),
863                         )
864                     },
865                     text = {
866                         Text(
867                             text = checkNotNull(userSwitcherDropdownItem.text.loadText(context)),
868                             style = MaterialTheme.typography.bodyLarge,
869                             color = MaterialTheme.colorScheme.onSurface,
870                         )
871                     },
872                     onClick = {
873                         onDismissed()
874                         userSwitcherDropdownItem.onClick()
875                     },
876                 )
877             }
878         }
879     }
880 }
881 
882 /**
883  * Calculates an alpha for the user switcher and bouncer such that it's at `1` when the offset of
884  * the two reaches a stopping point but `0` in the middle of the transition.
885  */
animatedAlphanull886 private fun animatedAlpha(offset: Float): Float {
887     // Describes a curve that is made of two parabolic U-shaped curves mirrored horizontally around
888     // the y-axis. The U on the left runs between x = -1 and x = 0 while the U on the right runs
889     // between x = 0 and x = 1.
890     //
891     // The minimum values of the curves are at -0.5 and +0.5.
892     //
893     // Both U curves are vertically scaled such that they reach the points (-1, 1) and (1, 1).
894     //
895     // Breaking it down, it's y = a×(|x|-m)²+b, where:
896     // x: the offset
897     // y: the alpha
898     // m: x-axis center of the parabolic curves, where the minima are.
899     // b: y-axis offset to apply to the entire curve so the animation spends more time with alpha =
900     // 0.
901     // a: amplitude to scale the parabolic curves to reach y = 1 at x = -1, x = 0, and x = +1.
902     val m = 0.5f
903     val b = -0.25
904     val a = (1 - b) / m.pow(2)
905 
906     return max(0f, (a * (abs(offset) - m).pow(2) + b).toFloat())
907 }
908 
909 private val SelectedUserImageSize = 190.dp
910 private val UserSwitcherDropdownWidth = SelectedUserImageSize + 2 * 29.dp
911 private val UserSwitcherDropdownHeight = 60.dp
912 
913 private object SceneKeys {
914     val ContiguousSceneKey = SceneKey("default")
915     val SplitSceneKey = SceneKey("split")
916 }
917 
918 private object SceneElements {
919     val AboveFold = ElementKey("above_fold")
920     val BelowFold = ElementKey("below_fold")
921 }
922 
<lambda>null923 private val SceneTransitions = transitions {
924     from(SceneKeys.ContiguousSceneKey, to = SceneKeys.SplitSceneKey) { spec = tween() }
925 }
926 
927 @VisibleForTesting
928 object BouncerMotionTestKeys {
929     val swapAnimationEnd = MotionTestValueKey<Boolean>("swapAnimationEnd")
930 }
931