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

Commit 96e8b051 authored by William Xiao's avatar William Xiao
Browse files

Fix hub touch handling interfering with bouncer swipe on dream

The dream overlay intercepts touches globally to implement the bouncer
swipe up gesture. However, the hub's touch handling does not intercept
those touches, meaning they fall through to the shade window and are
also processed as a swipe for the shade. This caused occasional issues
where swiping up on the hub over the dream would briefly show the
bouncer then start showing the notification shade instead.

This fix makes the hub touches near the top and bottom when dreaming to
fix this specific issue, but does not implement a long-term solution
for gesture handling over the hub.

Bug: 327679394
Fixed: 327679394
Test: atest GlanceableHubContainerControllerTest
      see video in bug for demo
Flag: ACONFIG com.android.systemui.communal_hub TEAMFOOD
Change-Id: I7175f037744a6b62197bd1ff3868a081d8fc4c3a
parent 57636844
Loading
Loading
Loading
Loading
+65 −24
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import com.android.systemui.communal.dagger.Communal
import com.android.systemui.communal.domain.interactor.CommunalInteractor
import com.android.systemui.communal.ui.compose.CommunalContainer
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.res.R
@@ -52,6 +53,7 @@ constructor(
    private val communalViewModel: CommunalViewModel,
    private val dialogFactory: SystemUIDialogFactory,
    private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
    private val keyguardInteractor: KeyguardInteractor,
    private val shadeInteractor: ShadeInteractor,
    private val powerManager: PowerManager,
    @Communal private val dataSourceDelegator: SceneDataSourceDelegator,
@@ -89,6 +91,9 @@ constructor(
    /** True if we are currently tracking a touch on the hub while it's open. */
    private var isTrackingHubTouch = false

    /** True if we are tracking a top or bottom swipe gesture while the hub is open. */
    private var isTrackingHubGesture = false

    /**
     * True if the hub UI is fully open, meaning it should receive touch input.
     *
@@ -111,6 +116,16 @@ constructor(
     */
    private var shadeShowing = false

    /**
     * True if the device is dreaming, in which case we shouldn't do anything for top/bottom swipes
     * and just let the dream overlay's touch handling deal with them.
     *
     * Tracks [KeyguardInteractor.isDreaming].
     *
     * TODO(b/328838259): figure out a proper solution for touch handling above the lock screen too
     */
    private var isDreaming = false

    /** Returns a flow that tracks whether communal hub is available. */
    fun communalAvailable(): Flow<Boolean> = communalInteractor.isCommunalAvailable

@@ -166,6 +181,7 @@ constructor(
        )
        collectFlow(containerView, communalInteractor.isCommunalShowing, { hubShowing = it })
        collectFlow(containerView, shadeInteractor.isAnyFullyExpanded, { shadeShowing = it })
        collectFlow(containerView, keyguardInteractor.isDreaming, { isDreaming = it })

        communalContainerView = containerView

@@ -194,61 +210,86 @@ constructor(
    }

    private fun handleTouchEventOnCommunalView(view: View, ev: MotionEvent): Boolean {
        // If the hub is fully visible, send all touch events to it, other than top and bottom edge
        // swipes.
        return if (hubShowing) {
            handleHubOpenTouch(view, ev)
        } else {
            handleHubClosedTouch(view, ev)
        }
    }

    private fun handleHubOpenTouch(view: View, ev: MotionEvent): Boolean {
        val isDown = ev.actionMasked == MotionEvent.ACTION_DOWN
        val isUp = ev.actionMasked == MotionEvent.ACTION_UP
        val isCancel = ev.actionMasked == MotionEvent.ACTION_CANCEL

        // TODO(b/315207481): also account for opening animations of shade/bouncer and not just
        //  fully showing state
        val hubOccluded = anyBouncerShowing || shadeShowing

        // If the hub is fully visible, send all touch events to it, other than top and bottom edge
        // swipes.
        if (hubShowing && isDown) {
        if (isDown && !hubOccluded) {
            // Only intercept down events if the hub isn't occluded by the bouncer or
            // notification shade.
            val y = ev.rawY
            val topSwipe: Boolean = y <= topEdgeSwipeRegionWidth
            val bottomSwipe = y >= view.height - bottomEdgeSwipeRegionWidth

            if (topSwipe || bottomSwipe) {
                // Don't intercept touches at the top/bottom edge so that swipes can open the
                // notification shade and bouncer.
                return false
            }

            if (!hubOccluded) {
                isTrackingHubGesture = true
            } else {
                isTrackingHubTouch = true
                dispatchTouchEvent(view, ev)
                // Return true regardless of dispatch result as some touches at the start of a
                // gesture may return false from dispatchTouchEvent.
                return true
            }
        } else if (isTrackingHubTouch) {
        }

        if (isTrackingHubTouch) {
            // Tracking a touch on the hub UI itself.
            if (isUp || isCancel) {
                isTrackingHubTouch = false
            }
            dispatchTouchEvent(view, ev)
            // Return true regardless of dispatch result as some touches at the start of a gesture
            // Return true regardless of dispatch result as some touches at the start of a
            // gesture
            // may return false from dispatchTouchEvent.
            return true
        } else if (isTrackingHubGesture) {
            // Tracking a top or bottom swipe on the hub UI.
            if (isUp || isCancel) {
                isTrackingHubGesture = false
            }

            // If we're dreaming, intercept touches so the hub UI doesn't receive them, but
            // don't do anything so that the dream's touch handling takes care of opening
            // the bouncer or shade.
            //
            // If we're not dreaming, we don't intercept touches at the top/bottom edge so that
            // swipes can open the notification shade and bouncer.
            return isDreaming
        }

        return false
    }

    private fun handleHubClosedTouch(view: View, ev: MotionEvent): Boolean {
        val isDown = ev.actionMasked == MotionEvent.ACTION_DOWN
        val isUp = ev.actionMasked == MotionEvent.ACTION_UP
        val isCancel = ev.actionMasked == MotionEvent.ACTION_CANCEL

        val hubOccluded = anyBouncerShowing || shadeShowing

        if (rightEdgeSwipeRegionWidth == 0) {
            // If the edge region width has not been read yet for whatever reason, don't bother
            // intercepting touches to open the hub.
            return false
        }

        if (!isTrackingOpenGesture && isDown) {
        if (isDown && !hubOccluded) {
            val x = ev.rawX
            val inOpeningSwipeRegion: Boolean = x >= view.width - rightEdgeSwipeRegionWidth
            if (inOpeningSwipeRegion && !hubOccluded) {
            if (inOpeningSwipeRegion) {
                isTrackingOpenGesture = true
                dispatchTouchEvent(view, ev)
                // Return true regardless of dispatch result as some touches at the start of a
                // gesture may return false from dispatchTouchEvent.
                return true
            }
        } else if (isTrackingOpenGesture) {
        }

        if (isTrackingOpenGesture) {
            if (isUp || isCancel) {
                isTrackingOpenGesture = false
            }
+173 −99
Original line number Diff line number Diff line
@@ -36,22 +36,30 @@ import com.android.systemui.communal.domain.interactor.communalInteractor
import com.android.systemui.communal.domain.interactor.setCommunalAvailable
import com.android.systemui.communal.shared.model.CommunalScenes
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.kosmos.testScope
import com.android.systemui.res.R
import com.android.systemui.scene.shared.model.sceneDataSourceDelegator
import com.android.systemui.shade.data.repository.fakeShadeRepository
import com.android.systemui.shade.domain.interactor.ShadeInteractor
import com.android.systemui.shade.domain.interactor.shadeInteractor
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Before
@@ -75,10 +83,11 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
        }

    @Mock private lateinit var communalViewModel: CommunalViewModel
    @Mock private lateinit var keyguardTransitionInteractor: KeyguardTransitionInteractor
    @Mock private lateinit var shadeInteractor: ShadeInteractor
    @Mock private lateinit var powerManager: PowerManager
    @Mock private lateinit var dialogFactory: SystemUIDialogFactory
    private lateinit var keyguardTransitionInteractor: KeyguardTransitionInteractor
    private lateinit var shadeInteractor: ShadeInteractor
    private lateinit var keyguardInteractor: KeyguardInteractor

    private lateinit var parentView: FrameLayout
    private lateinit var containerView: View
@@ -88,15 +97,15 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
    private lateinit var communalRepository: FakeCommunalRepository
    private lateinit var underTest: GlanceableHubContainerController

    private val bouncerShowingFlow = MutableStateFlow(false)
    private val shadeShowingFlow = MutableStateFlow(false)

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)

        communalInteractor = kosmos.communalInteractor
        communalRepository = kosmos.fakeCommunalRepository
        keyguardTransitionInteractor = kosmos.keyguardTransitionInteractor
        keyguardInteractor = kosmos.keyguardInteractor
        shadeInteractor = kosmos.shadeInteractor

        underTest =
            GlanceableHubContainerController(
@@ -104,16 +113,13 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                communalViewModel,
                dialogFactory,
                keyguardTransitionInteractor,
                keyguardInteractor,
                shadeInteractor,
                powerManager,
                kosmos.sceneDataSourceDelegator,
            )
        testableLooper = TestableLooper.get(this)

        whenever(keyguardTransitionInteractor.isFinishedInStateWhere(any()))
            .thenReturn(bouncerShowingFlow)
        whenever(shadeInteractor.isAnyFullyExpanded).thenReturn(shadeShowingFlow)

        overrideResource(R.dimen.communal_right_edge_swipe_region_width, RIGHT_SWIPE_REGION_WIDTH)
        overrideResource(R.dimen.communal_top_edge_swipe_region_height, TOP_SWIPE_REGION_WIDTH)
        overrideResource(
@@ -138,13 +144,16 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
    }

    @Test
    fun initView_calledTwice_throwsException() {
    fun initView_calledTwice_throwsException() =
        with(kosmos) {
            testScope.runTest {
                underTest =
                    GlanceableHubContainerController(
                        communalInteractor,
                        communalViewModel,
                        dialogFactory,
                        keyguardTransitionInteractor,
                        keyguardInteractor,
                        shadeInteractor,
                        powerManager,
                        kosmos.sceneDataSourceDelegator,
@@ -156,30 +165,40 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                // Second call throws.
                assertThrows(RuntimeException::class.java) { underTest.initView(context) }
            }
        }

    @Test
    fun onTouchEvent_communalClosed_doesNotIntercept() {
    fun onTouchEvent_communalClosed_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is closed.
                goToScene(CommunalScenes.Blank)

                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_openGesture_interceptsTouches() {
    fun onTouchEvent_openGesture_interceptsTouches() =
        with(kosmos) {
            testScope.runTest {
                // Communal is closed.
                goToScene(CommunalScenes.Blank)

        // Initial touch down is intercepted, and so are touches outside of the region, until an
                // Initial touch down is intercepted, and so are touches outside of the region,
                // until an
                // up event is received.
                assertThat(underTest.onTouchEvent(DOWN_IN_RIGHT_SWIPE_REGION_EVENT)).isTrue()
                assertThat(underTest.onTouchEvent(MOVE_EVENT)).isTrue()
                assertThat(underTest.onTouchEvent(UP_EVENT)).isTrue()
                assertThat(underTest.onTouchEvent(MOVE_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_communalOpen_interceptsTouches() {
    fun onTouchEvent_communalOpen_interceptsTouches() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

@@ -188,32 +207,77 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                // User activity sent to PowerManager.
                verify(powerManager).userActivity(any(), any(), any())
            }
        }

    @Test
    fun onTouchEvent_topSwipeWhenCommunalOpen_doesNotIntercept() {
    fun onTouchEvent_topSwipeWhenCommunalOpen_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

        // Touch event in the top swipe reqgion is not intercepted.
                // Touch event in the top swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_TOP_SWIPE_REGION_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_bottomSwipeWhenCommunalOpen_doesNotIntercept() {
    fun onTouchEvent_bottomSwipeWhenCommunalOpen_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

        // Touch event in the bottom swipe reqgion is not intercepted.
                // Touch event in the bottom swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_BOTTOM_SWIPE_REGION_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_communalAndBouncerShowing_doesNotIntercept() {
    fun onTouchEvent_topSwipeWhenDreaming_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

                // Device is dreaming.
                fakeKeyguardRepository.setDreaming(true)
                runCurrent()

                // Touch event in the top swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_TOP_SWIPE_REGION_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_bottomSwipeWhenDreaming_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

                // Device is dreaming.
                fakeKeyguardRepository.setDreaming(true)
                runCurrent()

                // Touch event in the bottom swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_BOTTOM_SWIPE_REGION_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_communalAndBouncerShowing_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

                // Bouncer is visible.
        bouncerShowingFlow.value = true
                fakeKeyguardTransitionRepository.sendTransitionSteps(
                    KeyguardState.GLANCEABLE_HUB,
                    KeyguardState.PRIMARY_BOUNCER,
                    testScope
                )
                testableLooper.processAllMessages()

                // Touch events are not intercepted.
@@ -221,21 +285,28 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                // User activity is not sent to PowerManager.
                verify(powerManager, times(0)).userActivity(any(), any(), any())
            }
        }

    @Test
    fun onTouchEvent_communalAndShadeShowing_doesNotIntercept() {
    fun onTouchEvent_communalAndShadeShowing_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

        shadeShowingFlow.value = true
                // Shade shows up.
                fakeShadeRepository.setQsExpansion(1.0f)
                testableLooper.processAllMessages()

                // Touch events are not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
            }
        }

    @Test
    fun onTouchEvent_containerViewDisposed_doesNotIntercept() {
    fun onTouchEvent_containerViewDisposed_doesNotIntercept() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
                goToScene(CommunalScenes.Communal)

@@ -248,6 +319,7 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                // Touch events are not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
            }
        }

    private fun initAndAttachContainerView() {
        containerView = View(context)
@@ -259,6 +331,8 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {

        // Attach the view so that flows start collecting.
        ViewUtils.attachView(parentView)
        // Attaching is async so processAllMessages is required for view.repeatWhenAttached to run.
        testableLooper.processAllMessages()

        // Give the view a fixed size to simplify testing for edge swipes.
        val lp =