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

Commit 75584ff6 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Occlusion support.

Adds support for occlusion in Flexiglass.

System UI's window is rendered on top of all other windows. This is by
design because the keyguard (lockscreen, bouncer) must be rendered on
top of any other activity window to "lock" the device and also because
the shade (notification or QS shade) must be expandable above any other
activity window.

Sometimes, however, we can show an activity "on top" of the locked
keyguard; this is a developer-facing API that allows them to mark their
activities as "shown on lockscreen" in their app's AndroidManifest.xml
file (if they have the right permission).

When an activity like that is shown, we simulate "occlusion" of the
System UI window by hiding our window-view, which shows that activity.
That said, when the user expands the shade or QS, our window needs to be
shown again.

Similarly, in AOD, our window should again be shown.

This CL adds that logic to Flexiglass.

The majority of the logic is in the new
SceneContainerOcclusionInteractor which reuses the
KeyguardOcclusionInteractor (with one minor change). The
visibility-setting logic in our SceneContainerStartable is also altered
to take that new state into account.

Still missing:
1. Transition to bouncer when the "camera roll" button is clicked inside
   the camera app (this requires compliance with a signal from
   KeyguardService, most likely)
2. Inability to navigate home or back while the occluding activity is
   showing

Bug: 308001302
Test: manually verified that showing an occluding activity (used the
camera lockscreen shortcut) hides system UI
Test: manually verified that expanding the shade or QS scenes when
occluded, shows those scenes and that collapsing them re-occludes system
UI
Test: manually verified that AOD also shows system UI, even when the
camera activity was on top
Test: Launched Maps Navigation while unlocked, turned the screen off, and then back on (ended on occluding activity, tests waking to occluded)
Test: Set a timer, turned the screen off, waited for the timer activity to open over lockscreen, dismissed it (tests manually interacting with the device to end an occluding activity)
Test: Received a phone call and didn't answer it (this tests the occluding activity ending itself)
Test: added unit and integration tests
Flag: ACONFIG com.android.systemui.scene_container DEVELOPMENT

Change-Id: If004ecae201d8193e598030a89a0dc589bd6b4d4
parent c3d7b636
Loading
Loading
Loading
Loading
+47 −10
Original line number Diff line number Diff line
@@ -30,17 +30,24 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.systemui.keyguard.domain.interactor

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.coroutines.collectValues
import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository
import com.android.systemui.flags.EnableSceneContainer
import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
import com.android.systemui.keyguard.data.repository.keyguardOcclusionRepository
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.kosmos.testScope
import com.android.systemui.power.domain.interactor.PowerInteractor
import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAsleepForTest
import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAwakeForTest
import com.android.systemui.power.domain.interactor.powerInteractor
@@ -49,22 +56,33 @@ import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class KeyguardOcclusionInteractorTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val underTest = kosmos.keyguardOcclusionInteractor
    private val powerInteractor = kosmos.powerInteractor
    private val transitionRepository = kosmos.fakeKeyguardTransitionRepository

    private lateinit var underTest: KeyguardOcclusionInteractor
    private lateinit var powerInteractor: PowerInteractor
    private lateinit var transitionRepository: FakeKeyguardTransitionRepository

    @Before
    fun setUp() {
        powerInteractor = kosmos.powerInteractor
        transitionRepository = kosmos.fakeKeyguardTransitionRepository
        underTest = kosmos.keyguardOcclusionInteractor
    }

    @Test
    fun testTransitionFromPowerGesture_whileGoingToSleep_isTrue() =
    fun transitionFromPowerGesture_whileGoingToSleep_isTrue() =
        testScope.runTest {
            powerInteractor.setAwakeForTest()
            transitionRepository.sendTransitionSteps(
@@ -81,7 +99,7 @@ class KeyguardOcclusionInteractorTest : SysuiTestCase() {
        }

    @Test
    fun testTransitionFromPowerGesture_whileAsleep_isTrue() =
    fun transitionFromPowerGesture_whileAsleep_isTrue() =
        testScope.runTest {
            powerInteractor.setAwakeForTest()
            transitionRepository.sendTransitionSteps(
@@ -97,7 +115,7 @@ class KeyguardOcclusionInteractorTest : SysuiTestCase() {
        }

    @Test
    fun testTransitionFromPowerGesture_whileWaking_isFalse() =
    fun transitionFromPowerGesture_whileWaking_isFalse() =
        testScope.runTest {
            powerInteractor.setAwakeForTest()
            transitionRepository.sendTransitionSteps(
@@ -119,7 +137,7 @@ class KeyguardOcclusionInteractorTest : SysuiTestCase() {
        }

    @Test
    fun testTransitionFromPowerGesture_whileAwake_isFalse() =
    fun transitionFromPowerGesture_whileAwake_isFalse() =
        testScope.runTest {
            powerInteractor.setAwakeForTest()
            transitionRepository.sendTransitionSteps(
@@ -140,7 +158,7 @@ class KeyguardOcclusionInteractorTest : SysuiTestCase() {
        }

    @Test
    fun testShowWhenLockedActivityLaunchedFromPowerGesture_notTrueSecondTime() =
    fun showWhenLockedActivityLaunchedFromPowerGesture_notTrueSecondTime() =
        testScope.runTest {
            val values by collectValues(underTest.showWhenLockedActivityLaunchedFromPowerGesture)
            powerInteractor.setAsleepForTest()
@@ -187,7 +205,7 @@ class KeyguardOcclusionInteractorTest : SysuiTestCase() {
        }

    @Test
    fun testShowWhenLockedActivityLaunchedFromPowerGesture_falseIfReturningToGone() =
    fun showWhenLockedActivityLaunchedFromPowerGesture_falseIfReturningToGone() =
        testScope.runTest {
            val values by collectValues(underTest.showWhenLockedActivityLaunchedFromPowerGesture)
            powerInteractor.setAwakeForTest()
@@ -221,4 +239,23 @@ class KeyguardOcclusionInteractorTest : SysuiTestCase() {
                    false,
                )
        }

    @Test
    @EnableSceneContainer
    fun occludingActivityWillDismissKeyguard() =
        testScope.runTest {
            val occludingActivityWillDismissKeyguard by
                collectLastValue(underTest.occludingActivityWillDismissKeyguard)
            assertThat(occludingActivityWillDismissKeyguard).isFalse()

            // Unlock device:
            kosmos.fakeDeviceEntryRepository.setUnlocked(true)
            runCurrent()
            assertThat(occludingActivityWillDismissKeyguard).isTrue()

            // Re-lock device:
            kosmos.fakeDeviceEntryRepository.setUnlocked(false)
            runCurrent()
            assertThat(occludingActivityWillDismissKeyguard).isFalse()
        }
}
+2 −0
Original line number Diff line number Diff line
@@ -64,6 +64,7 @@ import com.android.systemui.power.domain.interactor.powerInteractor
import com.android.systemui.qs.footerActionsController
import com.android.systemui.qs.footerActionsViewModelFactory
import com.android.systemui.qs.ui.adapter.FakeQSSceneAdapter
import com.android.systemui.scene.domain.interactor.sceneContainerOcclusionInteractor
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.domain.startable.SceneContainerStartable
import com.android.systemui.scene.shared.flag.fakeSceneContainerFlags
@@ -285,6 +286,7 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
                deviceProvisioningInteractor = kosmos.deviceProvisioningInteractor,
                centralSurfaces = mock(),
                headsUpInteractor = kosmos.headsUpNotificationInteractor,
                occlusionInteractor = kosmos.sceneContainerOcclusionInteractor,
            )
        startable.start()

+270 −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.scene.domain.interactor

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.ObservableTransitionState
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.kosmos.testScope
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.scene.shared.model.sceneDataSource
import com.android.systemui.statusbar.domain.interactor.keyguardOcclusionInteractor
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.mock
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class SceneContainerOcclusionInteractorTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val keyguardOcclusionInteractor = kosmos.keyguardOcclusionInteractor
    private val keyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository
    private val keyguardTransitionInteractor = kosmos.keyguardTransitionInteractor
    private val mutableTransitionState =
        MutableStateFlow<ObservableTransitionState>(
            ObservableTransitionState.Idle(Scenes.Lockscreen)
        )
    private val sceneInteractor =
        kosmos.sceneInteractor.apply { setTransitionState(mutableTransitionState) }
    private val sceneDataSource =
        kosmos.sceneDataSource.apply { changeScene(toScene = Scenes.Lockscreen) }

    private val underTest = kosmos.sceneContainerOcclusionInteractor

    @Test
    fun invisibleDueToOcclusion() =
        testScope.runTest {
            val invisibleDueToOcclusion by collectLastValue(underTest.invisibleDueToOcclusion)
            val keyguardState by collectLastValue(keyguardTransitionInteractor.currentKeyguardState)

            // Assert that we have the desired preconditions:
            assertThat(keyguardState).isEqualTo(KeyguardState.LOCKSCREEN)
            assertThat(sceneInteractor.currentScene.value).isEqualTo(Scenes.Lockscreen)
            assertThat(sceneInteractor.transitionState.value)
                .isEqualTo(ObservableTransitionState.Idle(Scenes.Lockscreen))
            assertWithMessage("Should start unoccluded").that(invisibleDueToOcclusion).isFalse()

            // Actual testing starts here:
            showOccludingActivity()
            assertWithMessage("Should become occluded when occluding activity is shown")
                .that(invisibleDueToOcclusion)
                .isTrue()

            transitionIntoAod {
                assertWithMessage("Should become unoccluded when transitioning into AOD")
                    .that(invisibleDueToOcclusion)
                    .isFalse()
            }
            assertWithMessage("Should stay unoccluded when in AOD")
                .that(invisibleDueToOcclusion)
                .isFalse()

            transitionOutOfAod {
                assertWithMessage("Should remain unoccluded while transitioning away from AOD")
                    .that(invisibleDueToOcclusion)
                    .isFalse()
            }
            assertWithMessage("Should become occluded now that no longer in AOD")
                .that(invisibleDueToOcclusion)
                .isTrue()

            expandShade {
                assertWithMessage("Should become unoccluded once shade begins to expand")
                    .that(invisibleDueToOcclusion)
                    .isFalse()
            }
            assertWithMessage("Should be unoccluded when shade is fully expanded")
                .that(invisibleDueToOcclusion)
                .isFalse()

            collapseShade {
                assertWithMessage("Should remain unoccluded while shade is collapsing")
                    .that(invisibleDueToOcclusion)
                    .isFalse()
            }
            assertWithMessage("Should become occluded now that shade is fully collapsed")
                .that(invisibleDueToOcclusion)
                .isTrue()

            hideOccludingActivity()
            assertWithMessage("Should become unoccluded once the occluding activity is hidden")
                .that(invisibleDueToOcclusion)
                .isFalse()
        }

    /** Simulates the appearance of a show-when-locked `Activity` in the foreground. */
    private fun TestScope.showOccludingActivity() {
        keyguardOcclusionInteractor.setWmNotifiedShowWhenLockedActivityOnTop(
            showWhenLockedActivityOnTop = true,
            taskInfo = mock(),
        )
        runCurrent()
    }

    /** Simulates the disappearance of a show-when-locked `Activity` from the foreground. */
    private fun TestScope.hideOccludingActivity() {
        keyguardOcclusionInteractor.setWmNotifiedShowWhenLockedActivityOnTop(
            showWhenLockedActivityOnTop = false,
        )
        runCurrent()
    }

    /** Simulates a user-driven gradual expansion of the shade. */
    private fun TestScope.expandShade(
        assertMidTransition: () -> Unit = {},
    ) {
        val progress = MutableStateFlow(0f)
        mutableTransitionState.value =
            ObservableTransitionState.Transition(
                fromScene = sceneDataSource.currentScene.value,
                toScene = Scenes.Shade,
                progress = progress,
                isInitiatedByUserInput = true,
                isUserInputOngoing = flowOf(true),
            )
        runCurrent()

        progress.value = 0.5f
        runCurrent()
        assertMidTransition()

        progress.value = 1f
        runCurrent()

        mutableTransitionState.value = ObservableTransitionState.Idle(Scenes.Shade)
        runCurrent()
    }

    /** Simulates a user-driven gradual collapse of the shade. */
    private fun TestScope.collapseShade(
        assertMidTransition: () -> Unit = {},
    ) {
        val progress = MutableStateFlow(0f)
        mutableTransitionState.value =
            ObservableTransitionState.Transition(
                fromScene = Scenes.Shade,
                toScene = Scenes.Lockscreen,
                progress = progress,
                isInitiatedByUserInput = true,
                isUserInputOngoing = flowOf(true),
            )
        runCurrent()

        progress.value = 0.5f
        runCurrent()
        assertMidTransition()

        progress.value = 1f
        runCurrent()

        mutableTransitionState.value = ObservableTransitionState.Idle(Scenes.Lockscreen)
        runCurrent()
    }

    /** Simulates a transition into AOD. */
    private suspend fun TestScope.transitionIntoAod(
        assertMidTransition: () -> Unit = {},
    ) {
        val currentKeyguardState = keyguardTransitionInteractor.getCurrentState()
        keyguardTransitionRepository.sendTransitionStep(
            TransitionStep(
                from = currentKeyguardState,
                to = KeyguardState.AOD,
                value = 0f,
                transitionState = TransitionState.STARTED,
            )
        )
        runCurrent()

        keyguardTransitionRepository.sendTransitionStep(
            TransitionStep(
                from = currentKeyguardState,
                to = KeyguardState.AOD,
                value = 0.5f,
                transitionState = TransitionState.RUNNING,
            )
        )
        runCurrent()
        assertMidTransition()

        keyguardTransitionRepository.sendTransitionStep(
            TransitionStep(
                from = currentKeyguardState,
                to = KeyguardState.AOD,
                value = 1f,
                transitionState = TransitionState.FINISHED,
            )
        )
        runCurrent()
    }

    /** Simulates a transition away from AOD. */
    private suspend fun TestScope.transitionOutOfAod(
        assertMidTransition: () -> Unit = {},
    ) {
        keyguardTransitionRepository.sendTransitionStep(
            TransitionStep(
                from = KeyguardState.AOD,
                to = KeyguardState.LOCKSCREEN,
                value = 0f,
                transitionState = TransitionState.STARTED,
            )
        )
        runCurrent()

        keyguardTransitionRepository.sendTransitionStep(
            TransitionStep(
                from = KeyguardState.AOD,
                to = KeyguardState.LOCKSCREEN,
                value = 0.5f,
                transitionState = TransitionState.RUNNING,
            )
        )
        runCurrent()
        assertMidTransition()

        keyguardTransitionRepository.sendTransitionStep(
            TransitionStep(
                from = KeyguardState.AOD,
                to = KeyguardState.LOCKSCREEN,
                value = 1f,
                transitionState = TransitionState.FINISHED,
            )
        )
        runCurrent()
    }
}
+25 −0
Original line number Diff line number Diff line
@@ -46,11 +46,13 @@ import com.android.systemui.model.SysUiState
import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAsleepForTest
import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAwakeForTest
import com.android.systemui.power.domain.interactor.PowerInteractorFactory
import com.android.systemui.scene.domain.interactor.sceneContainerOcclusionInteractor
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.shared.flag.fakeSceneContainerFlags
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.scene.shared.model.fakeSceneDataSource
import com.android.systemui.statusbar.NotificationShadeWindowController
import com.android.systemui.statusbar.domain.interactor.keyguardOcclusionInteractor
import com.android.systemui.statusbar.notification.data.repository.FakeHeadsUpRowRepository
import com.android.systemui.statusbar.notification.data.repository.HeadsUpRowRepository
import com.android.systemui.statusbar.notification.stack.data.repository.headsUpNotificationRepository
@@ -128,6 +130,7 @@ class SceneContainerStartableTest : SysuiTestCase() {
                deviceProvisioningInteractor = kosmos.deviceProvisioningInteractor,
                centralSurfaces = centralSurfaces,
                headsUpInteractor = kosmos.headsUpNotificationInteractor,
                occlusionInteractor = kosmos.sceneContainerOcclusionInteractor,
            )
    }

@@ -203,6 +206,28 @@ class SceneContainerStartableTest : SysuiTestCase() {
            assertThat(isVisible).isTrue()
        }

    @Test
    fun hydrateVisibility_basedOnOcclusion() =
        testScope.runTest {
            val isVisible by collectLastValue(sceneInteractor.isVisible)
            prepareState(
                isDeviceUnlocked = true,
                initialSceneKey = Scenes.Lockscreen,
            )

            underTest.start()
            assertThat(isVisible).isTrue()

            kosmos.keyguardOcclusionInteractor.setWmNotifiedShowWhenLockedActivityOnTop(
                true,
                mock()
            )
            assertThat(isVisible).isFalse()

            kosmos.keyguardOcclusionInteractor.setWmNotifiedShowWhenLockedActivityOnTop(false)
            assertThat(isVisible).isTrue()
        }

    @Test
    fun startsInLockscreenScene() =
        testScope.runTest {
+18 −8
Original line number Diff line number Diff line
@@ -19,13 +19,17 @@ package com.android.systemui.keyguard.domain.interactor
import android.app.ActivityManager.RunningTaskInfo
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor
import com.android.systemui.keyguard.data.repository.KeyguardOcclusionRepository
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.power.domain.interactor.PowerInteractor
import com.android.systemui.scene.shared.flag.SceneContainerFlag
import com.android.systemui.util.kotlin.sample
import dagger.Lazy
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@@ -45,11 +49,12 @@ import kotlinx.coroutines.flow.stateIn
class KeyguardOcclusionInteractor
@Inject
constructor(
    @Application scope: CoroutineScope,
    val repository: KeyguardOcclusionRepository,
    val powerInteractor: PowerInteractor,
    val transitionInteractor: KeyguardTransitionInteractor,
    val keyguardInteractor: KeyguardInteractor,
    @Application applicationScope: CoroutineScope,
    private val repository: KeyguardOcclusionRepository,
    private val powerInteractor: PowerInteractor,
    private val transitionInteractor: KeyguardTransitionInteractor,
    keyguardInteractor: KeyguardInteractor,
    deviceUnlockedInteractor: Lazy<DeviceUnlockedInteractor>,
) {
    val showWhenLockedActivityInfo = repository.showWhenLockedActivityInfo.asStateFlow()

@@ -94,14 +99,19 @@ constructor(
                // Emit false once that activity goes away.
                isShowWhenLockedActivityOnTop.filter { !it }.map { false }
            )
            .stateIn(scope, SharingStarted.Eagerly, false)
            .stateIn(applicationScope, SharingStarted.Eagerly, false)

    /**
     * Whether launching an occluding activity will automatically dismiss keyguard. This happens if
     * the keyguard is dismissable.
     */
    val occludingActivityWillDismissKeyguard =
        keyguardInteractor.isKeyguardDismissible.stateIn(scope, SharingStarted.Eagerly, false)
    val occludingActivityWillDismissKeyguard: StateFlow<Boolean> =
        if (SceneContainerFlag.isEnabled) {
                deviceUnlockedInteractor.get().isDeviceUnlocked
            } else {
                keyguardInteractor.isKeyguardDismissible
            }
            .stateIn(scope = applicationScope, SharingStarted.Eagerly, false)

    /**
     * Called to let System UI know that WM says a SHOW_WHEN_LOCKED activity is on top (or no longer
Loading