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

Commit f6ad1063 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Test for bouncer scene layout calculation.

Extracts the layout calculation into a helper object and adds a test for
it, supporting all known device configurations.

Bug: 309524547
Test: NA
Flag: NA
Change-Id: Iea6bd58e79452f4d76ec2678085c7ebb6f0bc5fb
parent 35a97e75
Loading
Loading
Loading
Loading
+6 −61
Original line number Diff line number Diff line
@@ -19,7 +19,6 @@ package com.android.systemui.bouncer.ui.composable
import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.content.res.Configuration
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
@@ -54,8 +53,6 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -68,7 +65,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
@@ -83,8 +79,8 @@ 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
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout
import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
@@ -149,10 +145,7 @@ private fun SceneScope.BouncerScene(
) {
    val backgroundColor = MaterialTheme.colorScheme.surface
    val isSideBySideSupported by viewModel.isSideBySideSupported.collectAsState()
    val layout =
        calculateLayout(
            isSideBySideSupported = isSideBySideSupported,
        )
    val layout = calculateLayout(isSideBySideSupported = isSideBySideSupported)

    Box(modifier) {
        Canvas(Modifier.element(Bouncer.Elements.Background).fillMaxSize()) {
@@ -163,27 +156,27 @@ private fun SceneScope.BouncerScene(
        val isFullScreenUserSwitcherEnabled = viewModel.isUserSwitcherVisible

        when (layout) {
            Layout.STANDARD ->
            BouncerSceneLayout.STANDARD ->
                StandardLayout(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    modifier = childModifier,
                )
            Layout.SIDE_BY_SIDE ->
            BouncerSceneLayout.SIDE_BY_SIDE ->
                SideBySideLayout(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    isUserSwitcherVisible = isFullScreenUserSwitcherEnabled,
                    modifier = childModifier,
                )
            Layout.STACKED ->
            BouncerSceneLayout.STACKED ->
                StackedLayout(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    isUserSwitcherVisible = isFullScreenUserSwitcherEnabled,
                    modifier = childModifier,
                )
            Layout.SPLIT ->
            BouncerSceneLayout.SPLIT ->
                SplitLayout(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
@@ -728,58 +721,10 @@ private fun StackedLayout(
    }
}

@Composable
private fun calculateLayout(
    isSideBySideSupported: Boolean,
): Layout {
    val windowSizeClass = LocalWindowSizeClass.current
    val width = windowSizeClass.widthSizeClass
    val height = windowSizeClass.heightSizeClass
    val isLarge = width > WindowWidthSizeClass.Compact && height > WindowHeightSizeClass.Compact
    val isTall =
        when (height) {
            WindowHeightSizeClass.Expanded -> width < WindowWidthSizeClass.Expanded
            WindowHeightSizeClass.Medium -> width < WindowWidthSizeClass.Medium
            else -> false
        }
    val isSquare =
        when (width) {
            WindowWidthSizeClass.Compact -> height == WindowHeightSizeClass.Compact
            WindowWidthSizeClass.Medium -> height == WindowHeightSizeClass.Medium
            WindowWidthSizeClass.Expanded -> height == WindowHeightSizeClass.Expanded
            else -> false
        }
    val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE

    return when {
        // Small and tall devices (i.e. phone/folded in portrait) or square device not in landscape
        // mode (unfolded with hinge along horizontal plane).
        (!isLarge && isTall) || (isSquare && !isLandscape) -> Layout.STANDARD
        // Small and wide devices (i.e. phone/folded in landscape).
        !isLarge -> Layout.SPLIT
        // Large and tall devices (i.e. tablet in portrait).
        isTall -> Layout.STACKED
        // Large and wide/square devices (i.e. tablet in landscape, unfolded).
        else -> if (isSideBySideSupported) Layout.SIDE_BY_SIDE else Layout.STANDARD
    }
}

interface BouncerSceneDialogFactory {
    operator fun invoke(): AlertDialog
}

/** Enumerates all known adaptive layout configurations. */
private enum class Layout {
    /** The default UI with the bouncer laid out normally. */
    STANDARD,
    /** The bouncer is displayed vertically stacked with the user switcher. */
    STACKED,
    /** The bouncer is displayed side-by-side with the user switcher or an empty space. */
    SIDE_BY_SIDE,
    /** The bouncer is split in two with both sides shown side-by-side. */
    SPLIT,
}

/** Enumerates all supported user-input area visibilities. */
private enum class UserInputAreaVisibility {
    /**
+61 −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.bouncer.ui.composable

import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import com.android.compose.windowsizeclass.LocalWindowSizeClass
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout
import com.android.systemui.bouncer.ui.helper.SizeClass
import com.android.systemui.bouncer.ui.helper.calculateLayoutInternal

/**
 * Returns the [BouncerSceneLayout] that should be used by the bouncer scene. If
 * [isSideBySideSupported] is `false`, then [BouncerSceneLayout.SIDE_BY_SIDE] is replaced by
 * [BouncerSceneLayout.STANDARD].
 */
@Composable
fun calculateLayout(
    isSideBySideSupported: Boolean,
): BouncerSceneLayout {
    val windowSizeClass = LocalWindowSizeClass.current

    return calculateLayoutInternal(
        width = windowSizeClass.widthSizeClass.toEnum(),
        height = windowSizeClass.heightSizeClass.toEnum(),
        isSideBySideSupported = isSideBySideSupported,
    )
}

private fun WindowWidthSizeClass.toEnum(): SizeClass {
    return when (this) {
        WindowWidthSizeClass.Compact -> SizeClass.COMPACT
        WindowWidthSizeClass.Medium -> SizeClass.MEDIUM
        WindowWidthSizeClass.Expanded -> SizeClass.EXPANDED
        else -> error("Unsupported WindowWidthSizeClass \"$this\"")
    }
}

private fun WindowHeightSizeClass.toEnum(): SizeClass {
    return when (this) {
        WindowHeightSizeClass.Compact -> SizeClass.COMPACT
        WindowHeightSizeClass.Medium -> SizeClass.MEDIUM
        WindowHeightSizeClass.Expanded -> SizeClass.EXPANDED
        else -> error("Unsupported WindowHeightSizeClass \"$this\"")
    }
}
+66 −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.bouncer.ui.helper

import androidx.annotation.VisibleForTesting

/** Enumerates all known adaptive layout configurations. */
enum class BouncerSceneLayout {
    /** The default UI with the bouncer laid out normally. */
    STANDARD,
    /** The bouncer is displayed vertically stacked with the user switcher. */
    STACKED,
    /** The bouncer is displayed side-by-side with the user switcher or an empty space. */
    SIDE_BY_SIDE,
    /** The bouncer is split in two with both sides shown side-by-side. */
    SPLIT,
}

/** Enumerates the supported window size classes. */
enum class SizeClass {
    COMPACT,
    MEDIUM,
    EXPANDED,
}

/**
 * Internal version of `calculateLayout` in the System UI Compose library, extracted here to allow
 * for testing that's not dependent on Compose.
 */
@VisibleForTesting
fun calculateLayoutInternal(
    width: SizeClass,
    height: SizeClass,
    isSideBySideSupported: Boolean,
): BouncerSceneLayout {
    return when (height) {
        SizeClass.COMPACT -> BouncerSceneLayout.SPLIT
        SizeClass.MEDIUM ->
            when (width) {
                SizeClass.COMPACT -> BouncerSceneLayout.STANDARD
                SizeClass.MEDIUM -> BouncerSceneLayout.STANDARD
                SizeClass.EXPANDED -> BouncerSceneLayout.SIDE_BY_SIDE
            }
        SizeClass.EXPANDED ->
            when (width) {
                SizeClass.COMPACT -> BouncerSceneLayout.STANDARD
                SizeClass.MEDIUM -> BouncerSceneLayout.STACKED
                SizeClass.EXPANDED -> BouncerSceneLayout.SIDE_BY_SIDE
            }
    }.takeIf { it != BouncerSceneLayout.SIDE_BY_SIDE || isSideBySideSupported }
        ?: BouncerSceneLayout.STANDARD
}
+253 −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.bouncer.ui.helper

import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout.SIDE_BY_SIDE
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout.SPLIT
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout.STACKED
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout.STANDARD
import com.google.common.truth.Truth.assertThat
import java.util.Locale
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized

@SmallTest
@RunWith(Parameterized::class)
class BouncerSceneLayoutTest : SysuiTestCase() {

    data object Phone :
        Device(
            name = "phone",
            width = SizeClass.COMPACT,
            height = SizeClass.EXPANDED,
            naturallyHeld = Vertically,
        )
    data object Tablet :
        Device(
            name = "tablet",
            width = SizeClass.EXPANDED,
            height = SizeClass.MEDIUM,
            naturallyHeld = Horizontally,
        )
    data object Folded :
        Device(
            name = "folded",
            width = SizeClass.COMPACT,
            height = SizeClass.MEDIUM,
            naturallyHeld = Vertically,
        )
    data object Unfolded :
        Device(
            name = "unfolded",
            width = SizeClass.EXPANDED,
            height = SizeClass.MEDIUM,
            naturallyHeld = Vertically,
            widthWhenUnnaturallyHeld = SizeClass.MEDIUM,
            heightWhenUnnaturallyHeld = SizeClass.MEDIUM,
        )
    data object TallerFolded :
        Device(
            name = "taller folded",
            width = SizeClass.COMPACT,
            height = SizeClass.EXPANDED,
            naturallyHeld = Vertically,
        )
    data object TallerUnfolded :
        Device(
            name = "taller unfolded",
            width = SizeClass.EXPANDED,
            height = SizeClass.EXPANDED,
            naturallyHeld = Vertically,
        )

    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "{0}")
        fun testCases() =
            listOf(
                    Phone to
                        Expected(
                            whenNaturallyHeld = STANDARD,
                            whenUnnaturallyHeld = SPLIT,
                        ),
                    Tablet to
                        Expected(
                            whenNaturallyHeld = SIDE_BY_SIDE,
                            whenUnnaturallyHeld = STACKED,
                        ),
                    Folded to
                        Expected(
                            whenNaturallyHeld = STANDARD,
                            whenUnnaturallyHeld = SPLIT,
                        ),
                    Unfolded to
                        Expected(
                            whenNaturallyHeld = SIDE_BY_SIDE,
                            whenUnnaturallyHeld = STANDARD,
                        ),
                    TallerFolded to
                        Expected(
                            whenNaturallyHeld = STANDARD,
                            whenUnnaturallyHeld = SPLIT,
                        ),
                    TallerUnfolded to
                        Expected(
                            whenNaturallyHeld = SIDE_BY_SIDE,
                            whenUnnaturallyHeld = SIDE_BY_SIDE,
                        ),
                )
                .flatMap { (device, expected) ->
                    buildList {
                        // Holding the device in its natural orientation (vertical or horizontal):
                        add(
                            TestCase(
                                device = device,
                                held = device.naturallyHeld,
                                expected = expected.layout(heldNaturally = true),
                            )
                        )

                        if (expected.whenNaturallyHeld == SIDE_BY_SIDE) {
                            add(
                                TestCase(
                                    device = device,
                                    held = device.naturallyHeld,
                                    isSideBySideSupported = false,
                                    expected = STANDARD,
                                )
                            )
                        }

                        // Holding the device the other way:
                        add(
                            TestCase(
                                device = device,
                                held = device.naturallyHeld.flip(),
                                expected = expected.layout(heldNaturally = false),
                            )
                        )

                        if (expected.whenUnnaturallyHeld == SIDE_BY_SIDE) {
                            add(
                                TestCase(
                                    device = device,
                                    held = device.naturallyHeld.flip(),
                                    isSideBySideSupported = false,
                                    expected = STANDARD,
                                )
                            )
                        }
                    }
                }
    }

    @Parameterized.Parameter @JvmField var testCase: TestCase? = null

    @Test
    fun calculateLayout() {
        testCase?.let { nonNullTestCase ->
            with(nonNullTestCase) {
                assertThat(
                        calculateLayoutInternal(
                            width = device.width(whenHeld = held),
                            height = device.height(whenHeld = held),
                            isSideBySideSupported = isSideBySideSupported,
                        )
                    )
                    .isEqualTo(expected)
            }
        }
    }

    data class TestCase(
        val device: Device,
        val held: Held,
        val expected: BouncerSceneLayout,
        val isSideBySideSupported: Boolean = true,
    ) {
        override fun toString(): String {
            return buildString {
                append(device.name)
                append(" width: ${device.width(held).name.lowercase(Locale.US)}")
                append(" height: ${device.height(held).name.lowercase(Locale.US)}")
                append(" when held $held")
                if (!isSideBySideSupported) {
                    append(" (side-by-side not supported)")
                }
            }
        }
    }

    data class Expected(
        val whenNaturallyHeld: BouncerSceneLayout,
        val whenUnnaturallyHeld: BouncerSceneLayout,
    ) {
        fun layout(heldNaturally: Boolean): BouncerSceneLayout {
            return if (heldNaturally) {
                whenNaturallyHeld
            } else {
                whenUnnaturallyHeld
            }
        }
    }

    sealed class Device(
        val name: String,
        private val width: SizeClass,
        private val height: SizeClass,
        val naturallyHeld: Held,
        private val widthWhenUnnaturallyHeld: SizeClass = height,
        private val heightWhenUnnaturallyHeld: SizeClass = width,
    ) {
        fun width(whenHeld: Held): SizeClass {
            return if (isHeldNaturally(whenHeld)) {
                width
            } else {
                widthWhenUnnaturallyHeld
            }
        }

        fun height(whenHeld: Held): SizeClass {
            return if (isHeldNaturally(whenHeld)) {
                height
            } else {
                heightWhenUnnaturallyHeld
            }
        }

        private fun isHeldNaturally(whenHeld: Held): Boolean {
            return whenHeld == naturallyHeld
        }
    }

    sealed class Held {
        abstract fun flip(): Held
    }
    data object Vertically : Held() {
        override fun flip(): Held {
            return Horizontally
        }
    }
    data object Horizontally : Held() {
        override fun flip(): Held {
            return Vertically
        }
    }
}