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,