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

Commit 5f9a9307 authored by Danny Burakov's avatar Danny Burakov Committed by Android (Google) Code Review
Browse files

Merge "[bc25] Create `OverlayShade`, a shared UI container for overlay shades." into main

parents 363c98e3 47642303
Loading
Loading
Loading
Loading
+147 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.shade.ui.composable

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.LowestZIndexScenePicker
import com.android.compose.animation.scene.SceneScope
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.shade.ui.viewmodel.OverlayShadeViewModel

/** The overlay shade renders a lightweight shade UI container on top of a background scene. */
@Composable
fun SceneScope.OverlayShade(
    viewModel: OverlayShadeViewModel,
    horizontalArrangement: Arrangement.Horizontal,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    val backgroundScene by viewModel.backgroundScene.collectAsState()

    Box(modifier) {
        if (backgroundScene == Scenes.Lockscreen) {
            Lockscreen()
        }

        Scrim(onClicked = viewModel::onScrimClicked)

        Row(
            modifier = Modifier.fillMaxSize().padding(OverlayShade.Dimensions.ScrimContentPadding),
            horizontalArrangement = horizontalArrangement,
        ) {
            Panel(content = content)
        }
    }
}

@Composable
private fun Lockscreen(
    modifier: Modifier = Modifier,
) {
    // TODO(b/338025605): This is a placeholder, replace with the actual lockscreen.
    Box(modifier = modifier.fillMaxSize().background(Color.LightGray)) {
        Text(text = "Lockscreen", modifier = Modifier.align(Alignment.Center))
    }
}

@Composable
private fun SceneScope.Scrim(
    onClicked: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Spacer(
        modifier =
            modifier
                .element(OverlayShade.Elements.Scrim)
                .fillMaxSize()
                .background(OverlayShade.Colors.ScrimBackground)
                .clickable(onClick = onClicked, interactionSource = null, indication = null)
    )
}

@Composable
private fun SceneScope.Panel(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Box(
        modifier =
            modifier
                .width(OverlayShade.Dimensions.PanelWidth)
                .clip(OverlayShade.Shapes.RoundedCornerPanel)
    ) {
        Spacer(
            modifier =
                Modifier.element(OverlayShade.Elements.PanelBackground)
                    .matchParentSize()
                    .background(
                        color = OverlayShade.Colors.PanelBackground,
                        shape = OverlayShade.Shapes.RoundedCornerPanel,
                    ),
        )

        // This content is intentionally rendered as a separate element from the background in order
        // to allow for more flexibility when defining transitions.
        content()
    }
}

object OverlayShade {
    object Elements {
        val Scrim = ElementKey("OverlayShadeScrim", scenePicker = LowestZIndexScenePicker)
        val PanelBackground =
            ElementKey("OverlayShadePanelBackground", scenePicker = LowestZIndexScenePicker)
    }

    object Colors {
        val ScrimBackground = Color(0, 0, 0, alpha = 255 / 3)
        val PanelBackground: Color
            @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.surfaceContainer
    }

    object Dimensions {
        val ScrimContentPadding = 16.dp
        val PanelCornerRadius = 46.dp
        // TODO(b/338033836): This width should not be fixed.
        val PanelWidth = 390.dp
    }

    object Shapes {
        val RoundedCornerPanel = RoundedCornerShape(Dimensions.PanelCornerRadius)
    }
}
+163 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.shade.ui.viewmodel

import android.testing.TestableLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository
import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor
import com.android.systemui.flags.EnableSceneContainer
import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository
import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus
import com.android.systemui.kosmos.testScope
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
@TestableLooper.RunWithLooper
@EnableSceneContainer
class OverlayShadeViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val sceneInteractor = kosmos.sceneInteractor
    private val deviceUnlockedInteractor by lazy { kosmos.deviceUnlockedInteractor }

    private val underTest = kosmos.overlayShadeViewModel

    @Test
    fun backgroundScene_deviceLocked_lockscreen() =
        testScope.runTest {
            val backgroundScene by collectLastValue(underTest.backgroundScene)

            lockDevice()

            assertThat(backgroundScene).isEqualTo(Scenes.Lockscreen)
        }

    @Test
    fun backgroundScene_deviceUnlocked_gone() =
        testScope.runTest {
            val backgroundScene by collectLastValue(underTest.backgroundScene)

            lockDevice()
            unlockDevice()

            assertThat(backgroundScene).isEqualTo(Scenes.Gone)
        }

    @Test
    fun backgroundScene_authMethodSwipe_lockscreenNotDismissed_goesToLockscreen() =
        testScope.runTest {
            val backgroundScene by collectLastValue(underTest.backgroundScene)
            val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus)

            kosmos.fakeDeviceEntryRepository.setLockscreenEnabled(true)
            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                AuthenticationMethodModel.None
            )
            assertThat(deviceUnlockStatus?.isUnlocked).isTrue()
            sceneInteractor.changeScene(Scenes.Lockscreen, "reason")
            runCurrent()

            assertThat(backgroundScene).isEqualTo(Scenes.Lockscreen)
        }

    @Test
    fun backgroundScene_authMethodSwipe_lockscreenDismissed_goesToGone() =
        testScope.runTest {
            val backgroundScene by collectLastValue(underTest.backgroundScene)
            val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus)

            kosmos.fakeDeviceEntryRepository.setLockscreenEnabled(true)
            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                AuthenticationMethodModel.None
            )
            assertThat(deviceUnlockStatus?.isUnlocked).isTrue()
            sceneInteractor.changeScene(Scenes.Gone, "reason")
            runCurrent()

            assertThat(backgroundScene).isEqualTo(Scenes.Gone)
        }

    @Test
    fun onScrimClicked_onLockscreen_goesToLockscreen() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene)
            lockDevice()
            sceneInteractor.changeScene(Scenes.Bouncer, "reason")
            runCurrent()
            assertThat(currentScene).isNotEqualTo(Scenes.Lockscreen)

            underTest.onScrimClicked()

            assertThat(currentScene).isEqualTo(Scenes.Lockscreen)
        }

    @Test
    fun onScrimClicked_deviceWasEntered_goesToGone() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene)
            val backgroundScene by collectLastValue(underTest.backgroundScene)

            lockDevice()
            unlockDevice()
            sceneInteractor.changeScene(Scenes.QuickSettings, "reason")
            runCurrent()
            assertThat(backgroundScene).isEqualTo(Scenes.Gone)
            assertThat(currentScene).isNotEqualTo(Scenes.Gone)

            underTest.onScrimClicked()

            assertThat(currentScene).isEqualTo(Scenes.Gone)
        }

    private fun TestScope.lockDevice() {
        val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus)

        kosmos.fakeAuthenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
        assertThat(deviceUnlockStatus?.isUnlocked).isFalse()
        sceneInteractor.changeScene(Scenes.Lockscreen, "reason")
        runCurrent()
    }

    private fun TestScope.unlockDevice() {
        val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus)

        kosmos.fakeDeviceEntryFingerprintAuthRepository.setAuthenticationStatus(
            SuccessFingerprintAuthenticationStatus(0, true)
        )
        assertThat(deviceUnlockStatus?.isUnlocked).isTrue()
        sceneInteractor.changeScene(Scenes.Gone, "reason")
        runCurrent()
    }
}
+67 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.shade.ui.viewmodel

import com.android.compose.animation.scene.SceneKey
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
import com.android.systemui.scene.domain.interactor.SceneInteractor
import com.android.systemui.scene.shared.model.Scenes
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/**
 * Models UI state and handles user input for the overlay shade UI, which shows a shade as an
 * overlay on top of another scene UI.
 */
@OptIn(ExperimentalCoroutinesApi::class)
@SysUISingleton
class OverlayShadeViewModel
@Inject
constructor(
    @Application private val applicationScope: CoroutineScope,
    private val sceneInteractor: SceneInteractor,
    deviceEntryInteractor: DeviceEntryInteractor,
) {
    /** The scene to show in the background when the overlay shade is open. */
    val backgroundScene: StateFlow<SceneKey> =
        deviceEntryInteractor.isDeviceEntered
            .map(::backgroundScene)
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = backgroundScene(deviceEntryInteractor.isDeviceEntered.value)
            )

    /** Notifies that the user has clicked the semi-transparent background scrim. */
    fun onScrimClicked() {
        sceneInteractor.changeScene(
            toScene = backgroundScene.value,
            loggingReason = "Shade scrim clicked",
        )
    }

    private fun backgroundScene(isDeviceEntered: Boolean): SceneKey {
        return if (isDeviceEntered) Scenes.Gone else Scenes.Lockscreen
    }
}
+34 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.systemui.shade.ui.viewmodel

import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.scene.domain.interactor.sceneInteractor
import kotlinx.coroutines.ExperimentalCoroutinesApi

val Kosmos.overlayShadeViewModel: OverlayShadeViewModel by
    Kosmos.Fixture {
        OverlayShadeViewModel(
            applicationScope = applicationCoroutineScope,
            sceneInteractor = sceneInteractor,
            deviceEntryInteractor = deviceEntryInteractor,
        )
    }