Merge "[flexiglass] Adds foldable posture support to bouncer." into main
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 33c084e..3928767 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
@@ -78,7 +78,10 @@
 import androidx.compose.ui.unit.times
 import com.android.compose.PlatformButton
 import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.SceneKey as SceneTransitionLayoutSceneKey
 import com.android.compose.animation.scene.SceneScope
+import com.android.compose.animation.scene.SceneTransitionLayout
+import com.android.compose.animation.scene.transitions
 import com.android.compose.modifiers.thenIf
 import com.android.compose.windowsizeclass.LocalWindowSizeClass
 import com.android.systemui.bouncer.shared.model.BouncerActionButtonModel
@@ -90,6 +93,8 @@
 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.fold.ui.composable.FoldPosture
+import com.android.systemui.fold.ui.composable.foldPosture
 import com.android.systemui.res.R
 import com.android.systemui.scene.shared.model.Direction
 import com.android.systemui.scene.shared.model.SceneKey
@@ -159,28 +164,27 @@
 
         when (layout) {
             Layout.STANDARD ->
-                Bouncer(
+                StandardLayout(
                     viewModel = viewModel,
                     dialogFactory = dialogFactory,
-                    userInputAreaVisibility = UserInputAreaVisibility.FULL,
                     modifier = childModifier,
                 )
             Layout.SIDE_BY_SIDE ->
-                SideBySide(
+                SideBySideLayout(
                     viewModel = viewModel,
                     dialogFactory = dialogFactory,
                     isUserSwitcherVisible = isFullScreenUserSwitcherEnabled,
                     modifier = childModifier,
                 )
             Layout.STACKED ->
-                Stacked(
+                StackedLayout(
                     viewModel = viewModel,
                     dialogFactory = dialogFactory,
                     isUserSwitcherVisible = isFullScreenUserSwitcherEnabled,
                     modifier = childModifier,
                 )
             Layout.SPLIT ->
-                Split(
+                SplitLayout(
                     viewModel = viewModel,
                     dialogFactory = dialogFactory,
                     modifier = childModifier,
@@ -194,59 +198,150 @@
  * authentication attempt, including all messaging UI (directives, reasoning, errors, etc.).
  */
 @Composable
-private fun Bouncer(
+private fun StandardLayout(
     viewModel: BouncerViewModel,
     dialogFactory: BouncerSceneDialogFactory,
-    userInputAreaVisibility: UserInputAreaVisibility,
+    modifier: Modifier = Modifier,
+    outputOnly: Boolean = false,
+) {
+    val foldPosture: FoldPosture by foldPosture()
+    val isSplitAroundTheFoldRequired by viewModel.isFoldSplitRequired.collectAsState()
+    val isSplitAroundTheFold =
+        foldPosture == FoldPosture.Tabletop && !outputOnly && isSplitAroundTheFoldRequired
+    val currentSceneKey by
+        remember(isSplitAroundTheFold) {
+            mutableStateOf(
+                if (isSplitAroundTheFold) SceneKeys.SplitSceneKey else SceneKeys.ContiguousSceneKey
+            )
+        }
+
+    SceneTransitionLayout(
+        currentScene = currentSceneKey,
+        onChangeScene = {},
+        transitions = SceneTransitions,
+        modifier = modifier,
+    ) {
+        scene(SceneKeys.ContiguousSceneKey) {
+            FoldSplittable(
+                viewModel = viewModel,
+                dialogFactory = dialogFactory,
+                outputOnly = outputOnly,
+                isSplit = false,
+            )
+        }
+
+        scene(SceneKeys.SplitSceneKey) {
+            FoldSplittable(
+                viewModel = viewModel,
+                dialogFactory = dialogFactory,
+                outputOnly = outputOnly,
+                isSplit = true,
+            )
+        }
+    }
+}
+
+/**
+ * Renders the "standard" layout of the bouncer, where the bouncer is rendered on its own (no user
+ * switcher UI) and laid out vertically, centered horizontally.
+ *
+ * If [isSplit] is `true`, the top and bottom parts of the bouncer are split such that they don't
+ * render across the location of the fold hardware when the device is fully or part-way unfolded
+ * with the fold hinge in a horizontal position.
+ *
+ * If [outputOnly] is `true`, only the "output" part of the UI is shown (where the entered PIN
+ * "shapes" appear), if `false`, the entire UI is shown, including the area where the user can enter
+ * their PIN or pattern.
+ */
+@Composable
+private fun SceneScope.FoldSplittable(
+    viewModel: BouncerViewModel,
+    dialogFactory: BouncerSceneDialogFactory,
+    outputOnly: Boolean,
+    isSplit: Boolean,
     modifier: Modifier = Modifier,
 ) {
     val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
     val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
     var dialog: Dialog? by remember { mutableStateOf(null) }
     val actionButton: BouncerActionButtonModel? by viewModel.actionButton.collectAsState()
+    val splitRatio =
+        LocalContext.current.resources.getFloat(
+            R.dimen.motion_layout_half_fold_bouncer_height_ratio
+        )
 
-    Column(
-        horizontalAlignment = Alignment.CenterHorizontally,
-        modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 0.dp)
-    ) {
-        Crossfade(
-            targetState = message,
-            label = "Bouncer message",
-            animationSpec = if (message.isUpdateAnimated) tween() else snap(),
-        ) { message ->
-            Text(
-                text = message.text,
-                color = MaterialTheme.colorScheme.onSurface,
-                style = MaterialTheme.typography.bodyLarge,
-            )
-        }
+    Column(modifier = modifier.padding(horizontal = 32.dp)) {
+        // Content above the fold, when split on a foldable device in a "table top" posture:
+        Box(
+            modifier =
+                Modifier.element(SceneElements.AboveFold).fillMaxWidth().thenIf(isSplit) {
+                    Modifier.weight(splitRatio)
+                },
+        ) {
+            Column(
+                horizontalAlignment = Alignment.CenterHorizontally,
+                modifier = Modifier.fillMaxWidth().padding(top = 92.dp),
+            ) {
+                Crossfade(
+                    targetState = message,
+                    label = "Bouncer message",
+                    animationSpec = if (message.isUpdateAnimated) tween() else snap(),
+                ) { message ->
+                    Text(
+                        text = message.text,
+                        color = MaterialTheme.colorScheme.onSurface,
+                        style = MaterialTheme.typography.bodyLarge,
+                    )
+                }
 
-        Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))
+                Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))
 
-        Box(Modifier.weight(1f)) {
-            UserInputArea(
-                viewModel = viewModel,
-                visibility = userInputAreaVisibility,
-                modifier = Modifier.align(Alignment.Center),
-            )
-        }
-
-        Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))
-
-        val actionButtonModifier = Modifier.height(56.dp)
-
-        actionButton.let { actionButtonViewModel ->
-            if (actionButtonViewModel != null) {
-                BouncerActionButton(
-                    viewModel = actionButtonViewModel,
-                    modifier = actionButtonModifier,
+                UserInputArea(
+                    viewModel = viewModel,
+                    visibility = UserInputAreaVisibility.OUTPUT_ONLY,
                 )
-            } else {
-                Spacer(modifier = actionButtonModifier)
             }
         }
 
-        Spacer(Modifier.height(48.dp))
+        // Content below the fold, when split on a foldable device in a "table top" posture:
+        Box(
+            modifier =
+                Modifier.element(SceneElements.BelowFold).fillMaxWidth().thenIf(isSplit) {
+                    Modifier.weight(1 - splitRatio)
+                },
+        ) {
+            Column(
+                horizontalAlignment = Alignment.CenterHorizontally,
+                modifier = Modifier.fillMaxWidth(),
+            ) {
+                if (!outputOnly) {
+                    Box(Modifier.weight(1f)) {
+                        UserInputArea(
+                            viewModel = viewModel,
+                            visibility = UserInputAreaVisibility.INPUT_ONLY,
+                            modifier = Modifier.align(Alignment.Center),
+                        )
+                    }
+                }
+
+                Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))
+
+                val actionButtonModifier = Modifier.height(56.dp)
+
+                actionButton.let { actionButtonViewModel ->
+                    if (actionButtonViewModel != null) {
+                        BouncerActionButton(
+                            viewModel = actionButtonViewModel,
+                            modifier = actionButtonModifier,
+                        )
+                    } else {
+                        Spacer(modifier = actionButtonModifier)
+                    }
+                }
+
+                Spacer(Modifier.height(48.dp))
+            }
+        }
 
         if (dialogMessage != null) {
             if (dialog == null) {
@@ -288,8 +383,8 @@
     when (val nonNullViewModel = authMethodViewModel) {
         is PinBouncerViewModel ->
             when (visibility) {
-                UserInputAreaVisibility.FULL ->
-                    PinBouncer(
+                UserInputAreaVisibility.OUTPUT_ONLY ->
+                    PinInputDisplay(
                         viewModel = nonNullViewModel,
                         modifier = modifier,
                     )
@@ -298,34 +393,21 @@
                         viewModel = nonNullViewModel,
                         modifier = modifier,
                     )
-                UserInputAreaVisibility.OUTPUT_ONLY ->
-                    PinInputDisplay(
-                        viewModel = nonNullViewModel,
-                        modifier = modifier,
-                    )
-                UserInputAreaVisibility.NONE -> {}
             }
         is PasswordBouncerViewModel ->
-            when (visibility) {
-                UserInputAreaVisibility.FULL,
-                UserInputAreaVisibility.INPUT_ONLY ->
-                    PasswordBouncer(
-                        viewModel = nonNullViewModel,
-                        modifier = modifier,
-                    )
-                else -> {}
+            if (visibility == UserInputAreaVisibility.INPUT_ONLY) {
+                PasswordBouncer(
+                    viewModel = nonNullViewModel,
+                    modifier = modifier,
+                )
             }
         is PatternBouncerViewModel ->
-            when (visibility) {
-                UserInputAreaVisibility.FULL,
-                UserInputAreaVisibility.INPUT_ONLY ->
-                    PatternBouncer(
-                        viewModel = nonNullViewModel,
-                        modifier =
-                            Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false)
-                                .then(modifier)
-                    )
-                else -> {}
+            if (visibility == UserInputAreaVisibility.INPUT_ONLY) {
+                PatternBouncer(
+                    viewModel = nonNullViewModel,
+                    modifier =
+                        Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false).then(modifier)
+                )
             }
         else -> Unit
     }
@@ -492,17 +574,17 @@
  * by double-tapping on the side.
  */
 @Composable
-private fun Split(
+private fun SplitLayout(
     viewModel: BouncerViewModel,
     dialogFactory: BouncerSceneDialogFactory,
     modifier: Modifier = Modifier,
 ) {
     SwappableLayout(
         startContent = { startContentModifier ->
-            Bouncer(
+            StandardLayout(
                 viewModel = viewModel,
                 dialogFactory = dialogFactory,
-                userInputAreaVisibility = UserInputAreaVisibility.OUTPUT_ONLY,
+                outputOnly = true,
                 modifier = startContentModifier,
             )
         },
@@ -595,7 +677,7 @@
  * rendering of the bouncer will be used instead of the side-by-side layout.
  */
 @Composable
-private fun SideBySide(
+private fun SideBySideLayout(
     viewModel: BouncerViewModel,
     dialogFactory: BouncerSceneDialogFactory,
     isUserSwitcherVisible: Boolean,
@@ -615,10 +697,9 @@
             }
         },
         endContent = { endContentModifier ->
-            Bouncer(
+            StandardLayout(
                 viewModel = viewModel,
                 dialogFactory = dialogFactory,
-                userInputAreaVisibility = UserInputAreaVisibility.FULL,
                 modifier = endContentModifier,
             )
         },
@@ -628,7 +709,7 @@
 
 /** Arranges the bouncer contents and user switcher contents one on top of the other, vertically. */
 @Composable
-private fun Stacked(
+private fun StackedLayout(
     viewModel: BouncerViewModel,
     dialogFactory: BouncerSceneDialogFactory,
     isUserSwitcherVisible: Boolean,
@@ -644,10 +725,9 @@
             )
         }
 
-        Bouncer(
+        StandardLayout(
             viewModel = viewModel,
             dialogFactory = dialogFactory,
-            userInputAreaVisibility = UserInputAreaVisibility.FULL,
             modifier = Modifier.fillMaxWidth().weight(1f),
         )
     }
@@ -708,11 +788,6 @@
 /** Enumerates all supported user-input area visibilities. */
 private enum class UserInputAreaVisibility {
     /**
-     * The entire user input area is shown, including where the user enters input and where it's
-     * reflected to the user.
-     */
-    FULL,
-    /**
      * Only the area where the user enters the input is shown; the area where the input is reflected
      * back to the user is not shown.
      */
@@ -722,8 +797,6 @@
      * input is entered by the user is not shown.
      */
     OUTPUT_ONLY,
-    /** The entire user input area is hidden. */
-    NONE,
 }
 
 /**
@@ -758,3 +831,17 @@
 private val SelectedUserImageSize = 190.dp
 private val UserSwitcherDropdownWidth = SelectedUserImageSize + 2 * 29.dp
 private val UserSwitcherDropdownHeight = 60.dp
+
+private object SceneKeys {
+    val ContiguousSceneKey = SceneTransitionLayoutSceneKey("default")
+    val SplitSceneKey = SceneTransitionLayoutSceneKey("split")
+}
+
+private object SceneElements {
+    val AboveFold = ElementKey("above_fold")
+    val BelowFold = ElementKey("below_fold")
+}
+
+private val SceneTransitions = transitions {
+    from(SceneKeys.ContiguousSceneKey, to = SceneKeys.SplitSceneKey) { spec = tween() }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt
index 5b9ad4d..fb50f69 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt
@@ -24,14 +24,10 @@
 import androidx.compose.animation.core.animateDpAsState
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
-import androidx.compose.foundation.gestures.awaitEachGesture
-import androidx.compose.foundation.gestures.awaitFirstDown
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.sizeIn
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
@@ -69,34 +65,13 @@
 import kotlinx.coroutines.launch
 
 @Composable
-internal fun PinBouncer(
+fun PinPad(
     viewModel: PinBouncerViewModel,
     modifier: Modifier = Modifier,
 ) {
     // Report that the UI is shown to let the view-model run some logic.
     LaunchedEffect(Unit) { viewModel.onShown() }
 
-    Column(
-        horizontalAlignment = Alignment.CenterHorizontally,
-        modifier =
-            modifier.pointerInput(Unit) {
-                awaitEachGesture {
-                    awaitFirstDown()
-                    viewModel.onDown()
-                }
-            }
-    ) {
-        PinInputDisplay(viewModel)
-        Spacer(Modifier.heightIn(min = 34.dp, max = 48.dp))
-        PinPad(viewModel)
-    }
-}
-
-@Composable
-fun PinPad(
-    viewModel: PinBouncerViewModel,
-    modifier: Modifier = Modifier,
-) {
     val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
     val backspaceButtonAppearance by viewModel.backspaceButtonAppearance.collectAsState()
     val confirmButtonAppearance by viewModel.confirmButtonAppearance.collectAsState()
@@ -298,7 +273,8 @@
         contentAlignment = Alignment.Center,
         modifier =
             modifier
-                .size(pinButtonSize)
+                .sizeIn(maxWidth = pinButtonSize, maxHeight = pinButtonSize)
+                .aspectRatio(1f)
                 .drawBehind {
                     drawRoundRect(
                         color = containerColor,
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/fold/ui/composable/FoldPosture.kt b/packages/SystemUI/compose/features/src/com/android/systemui/fold/ui/composable/FoldPosture.kt
new file mode 100644
index 0000000..1c993cf
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/fold/ui/composable/FoldPosture.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2023 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.fold.ui.composable
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowInfoTracker
+
+sealed interface FoldPosture {
+    /** A foldable device that's fully closed/folded or a device that doesn't support folding. */
+    data object Folded : FoldPosture
+    /** A foldable that's halfway open with the hinge held vertically. */
+    data object Book : FoldPosture
+    /** A foldable that's halfway open with the hinge held horizontally. */
+    data object Tabletop : FoldPosture
+    /** A foldable that's fully unfolded / flat. */
+    data object FullyUnfolded : FoldPosture
+}
+
+/** Returns the [FoldPosture] of the device currently. */
+@Composable
+fun foldPosture(): State<FoldPosture> {
+    val context = LocalContext.current
+    val infoTracker = remember(context) { WindowInfoTracker.getOrCreate(context) }
+    val layoutInfo by infoTracker.windowLayoutInfo(context).collectAsState(initial = null)
+
+    return produceState<FoldPosture>(
+        initialValue = FoldPosture.Folded,
+        key1 = layoutInfo,
+    ) {
+        value =
+            layoutInfo
+                ?.displayFeatures
+                ?.firstNotNullOfOrNull { it as? FoldingFeature }
+                .let { foldingFeature ->
+                    when (foldingFeature?.state) {
+                        null -> FoldPosture.Folded
+                        FoldingFeature.State.HALF_OPENED ->
+                            foldingFeature.orientation.toHalfwayPosture()
+                        FoldingFeature.State.FLAT ->
+                            if (foldingFeature.isSeparating) {
+                                // Dual screen device.
+                                foldingFeature.orientation.toHalfwayPosture()
+                            } else {
+                                FoldPosture.FullyUnfolded
+                            }
+                        else -> error("Unsupported state \"${foldingFeature.state}\"")
+                    }
+                }
+    }
+}
+
+private fun FoldingFeature.Orientation.toHalfwayPosture(): FoldPosture {
+    return when (this) {
+        FoldingFeature.Orientation.HORIZONTAL -> FoldPosture.Tabletop
+        FoldingFeature.Orientation.VERTICAL -> FoldPosture.Book
+        else -> error("Unsupported orientation \"$this\"")
+    }
+}
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 73d15f0..4767e21 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
@@ -180,6 +180,19 @@
                 initialValue = isSideBySideSupported(authMethodViewModel.value),
             )
 
+    /**
+     * Whether the splitting the UI around the fold seam (where the hinge is on a foldable device)
+     * is required.
+     */
+    val isFoldSplitRequired: StateFlow<Boolean> =
+        authMethodViewModel
+            .map { authMethod -> isFoldSplitRequired(authMethod) }
+            .stateIn(
+                scope = applicationScope,
+                started = SharingStarted.WhileSubscribed(),
+                initialValue = isFoldSplitRequired(authMethodViewModel.value),
+            )
+
     init {
         if (flags.isEnabled()) {
             applicationScope.launch {
@@ -212,6 +225,10 @@
         return isUserSwitcherVisible || authMethod !is PasswordBouncerViewModel
     }
 
+    private fun isFoldSplitRequired(authMethod: AuthMethodBouncerViewModel?): Boolean {
+        return authMethod !is PasswordBouncerViewModel
+    }
+
     private fun toMessageViewModel(
         message: String?,
         isThrottled: Boolean,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
index 6ef518e..6357a1a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
@@ -226,6 +226,23 @@
             assertThat(isSideBySideSupported).isFalse()
         }
 
+    @Test
+    fun isFoldSplitRequired() =
+        testScope.runTest {
+            val isFoldSplitRequired by collectLastValue(underTest.isFoldSplitRequired)
+            utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
+            assertThat(isFoldSplitRequired).isTrue()
+            utils.authenticationRepository.setAuthenticationMethod(
+                AuthenticationMethodModel.Password
+            )
+            assertThat(isFoldSplitRequired).isFalse()
+
+            utils.authenticationRepository.setAuthenticationMethod(
+                AuthenticationMethodModel.Pattern
+            )
+            assertThat(isFoldSplitRequired).isTrue()
+        }
+
     private fun authMethodsToTest(): List<DomainLayerAuthenticationMethodModel> {
         return listOf(
             DomainLayerAuthenticationMethodModel.None,