Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 0331eb80 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Adds foldable posture support to bouncer.

Utilizes a SceneTransitionLayout to animate between the standard layout
bouncer when it's split and when it's not split across the foldable
hinge.

Fix: 309524547
Flag: ACONFIG com.systemui.scene_container DEVELOPMENT
Test: manually verified on foldable device - all three auth methods were
tested for proper layout on all four possible configs: folded/unfolded x
horizontal/vertical.
Test: manually verified that PIN and pattern animated to the bottom
inner display when entering the tabletop posture and animate back when
returning to the flat posture.
Test: manually verified that password doesn't animate.
Test: see videos attached to the bug in commment #2

Change-Id: I551a1892b32712de68090e4ba730b2b44081616a
parent b4fb60f2
Loading
Loading
Loading
Loading
+168 −81
Original line number Diff line number Diff line
@@ -78,7 +78,10 @@ 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.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.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.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 @@ private fun SceneScope.BouncerScene(

        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,20 +198,89 @@ private fun SceneScope.BouncerScene(
 * 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(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.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 0.dp)
                modifier = Modifier.fillMaxWidth().padding(top = 92.dp),
            ) {
                Crossfade(
                    targetState = message,
@@ -223,13 +296,33 @@ private fun Bouncer(

                Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))

                UserInputArea(
                    viewModel = viewModel,
                    visibility = UserInputAreaVisibility.OUTPUT_ONLY,
                )
            }
        }

        // 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,
                            visibility = UserInputAreaVisibility.INPUT_ONLY,
                            modifier = Modifier.align(Alignment.Center),
                        )
                    }
                }

                Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))

@@ -247,6 +340,8 @@ private fun Bouncer(
                }

                Spacer(Modifier.height(48.dp))
            }
        }

        if (dialogMessage != null) {
            if (dialog == null) {
@@ -288,8 +383,8 @@ private fun UserInputArea(
    when (val nonNullViewModel = authMethodViewModel) {
        is PinBouncerViewModel ->
            when (visibility) {
                UserInputAreaVisibility.FULL ->
                    PinBouncer(
                UserInputAreaVisibility.OUTPUT_ONLY ->
                    PinInputDisplay(
                        viewModel = nonNullViewModel,
                        modifier = modifier,
                    )
@@ -298,34 +393,21 @@ private fun UserInputArea(
                        viewModel = nonNullViewModel,
                        modifier = modifier,
                    )
                UserInputAreaVisibility.OUTPUT_ONLY ->
                    PinInputDisplay(
                        viewModel = nonNullViewModel,
                        modifier = modifier,
                    )
                UserInputAreaVisibility.NONE -> {}
            }
        is PasswordBouncerViewModel ->
            when (visibility) {
                UserInputAreaVisibility.FULL,
                UserInputAreaVisibility.INPUT_ONLY ->
            if (visibility == UserInputAreaVisibility.INPUT_ONLY) {
                PasswordBouncer(
                    viewModel = nonNullViewModel,
                    modifier = modifier,
                )
                else -> {}
            }
        is PatternBouncerViewModel ->
            when (visibility) {
                UserInputAreaVisibility.FULL,
                UserInputAreaVisibility.INPUT_ONLY ->
            if (visibility == UserInputAreaVisibility.INPUT_ONLY) {
                PatternBouncer(
                    viewModel = nonNullViewModel,
                    modifier =
                            Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false)
                                .then(modifier)
                        Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false).then(modifier)
                )
                else -> {}
            }
        else -> Unit
    }
@@ -492,17 +574,17 @@ private fun UserSwitcherDropdownMenu(
 * 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 @@ private fun SwappableLayout(
 * 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 @@ private fun SideBySide(
            }
        },
        endContent = { endContentModifier ->
            Bouncer(
            StandardLayout(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                userInputAreaVisibility = UserInputAreaVisibility.FULL,
                modifier = endContentModifier,
            )
        },
@@ -628,7 +709,7 @@ private fun SideBySide(

/** 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 @@ private fun Stacked(
            )
        }

        Bouncer(
        StandardLayout(
            viewModel = viewModel,
            dialogFactory = dialogFactory,
            userInputAreaVisibility = UserInputAreaVisibility.FULL,
            modifier = Modifier.fillMaxWidth().weight(1f),
        )
    }
@@ -707,11 +787,6 @@ private enum class Layout {

/** 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 @@ private enum class UserInputAreaVisibility {
     * input is entered by the user is not shown.
     */
    OUTPUT_ONLY,
    /** The entire user input area is hidden. */
    NONE,
}

/**
@@ -758,3 +831,17 @@ private fun animatedAlpha(
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() }
}
+5 −29
Original line number Diff line number Diff line
@@ -24,14 +24,10 @@ import androidx.compose.animation.core.AnimationVector1D
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.delay
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 @@ private fun PinPadButton(
        contentAlignment = Alignment.Center,
        modifier =
            modifier
                .size(pinButtonSize)
                .sizeIn(maxWidth = pinButtonSize, maxHeight = pinButtonSize)
                .aspectRatio(1f)
                .drawBehind {
                    drawRoundRect(
                        color = containerColor,
+79 −0
Original line number Diff line number Diff line
/*
 * 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\"")
    }
}
+17 −0
Original line number Diff line number Diff line
@@ -180,6 +180,19 @@ class BouncerViewModel(
                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 @@ class BouncerViewModel(
        return isUserSwitcherVisible || authMethod !is PasswordBouncerViewModel
    }

    private fun isFoldSplitRequired(authMethod: AuthMethodBouncerViewModel?): Boolean {
        return authMethod !is PasswordBouncerViewModel
    }

    private fun toMessageViewModel(
        message: String?,
        isThrottled: Boolean,
+17 −0
Original line number Diff line number Diff line
@@ -226,6 +226,23 @@ class BouncerViewModelTest : SysuiTestCase() {
            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,