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

Commit c84e0559 authored by William Xiao's avatar William Xiao
Browse files

Use TouchMonitor for touch handling over hub

This change swaps from simply allowing touches that start at the top
or bottom of the glanceable hub parent view to fall through in order
to open the notification shade and bouncer to using the same
TouchMonitor interception as the dream uses.

Bug: 328838259
Bug: 335505186
Test: atest GlanceableHubContainerController
      verified manually, see video on bug
Flag: ACONFIG com.android.systemui.communal_hub TEAMFOOD
Change-Id: I31a55ca141d2df00c73b00740ac909c27268a375
parent d4401987
Loading
Loading
Loading
Loading
+64 −51
Original line number Diff line number Diff line
@@ -28,10 +28,14 @@ import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.compose.theme.PlatformTheme
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.ambient.touch.TouchMonitor
import com.android.systemui.ambient.touch.dagger.AmbientTouchComponent
import com.android.systemui.communal.dagger.Communal
import com.android.systemui.communal.domain.interactor.CommunalInteractor
import com.android.systemui.communal.ui.compose.CommunalContainer
@@ -67,31 +71,32 @@ constructor(
    private val shadeInteractor: ShadeInteractor,
    private val powerManager: PowerManager,
    private val communalColors: CommunalColors,
    @Communal private val dataSourceDelegator: SceneDataSourceDelegator,
) {
    private val ambientTouchComponentFactory: AmbientTouchComponent.Factory,
    @Communal private val dataSourceDelegator: SceneDataSourceDelegator
) : LifecycleOwner {
    /** The container view for the hub. This will not be initialized until [initView] is called. */
    private var communalContainerView: View? = null

    /**
     * The width of the area in which a right edge swipe can open the hub, in pixels. Read from
     * resources when [initView] is called.
     * This lifecycle is used to control when the [touchMonitor] listens to touches. The lifecycle
     * should only be [Lifecycle.State.RESUMED] when the hub is showing and not covered by anything,
     * such as the notification shade or bouncer.
     */
    // TODO(b/320786721): support RTL layouts
    private var rightEdgeSwipeRegionWidth: Int = 0
    private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)

    /**
     * The height of the area in which a top edge swipe while the hub is open will not intercept
     * touches, in pixels. This allows the top edge swipe to instead open the notification shade.
     * Read from resources when [initView] is called.
     * This [TouchMonitor] listens for top and bottom swipe gestures globally when the hub is open.
     * When a top or bottom swipe is detected, they will be intercepted and used to open the
     * notification shade/bouncer.
     */
    private var topEdgeSwipeRegionWidth: Int = 0
    private var touchMonitor: TouchMonitor? = null

    /**
     * The height of the area in which a bottom edge swipe while the hub is open will not intercept
     * touches, in pixels. This allows the bottom edge swipe to instead open the bouncer. Read from
     * The width of the area in which a right edge swipe can open the hub, in pixels. Read from
     * resources when [initView] is called.
     */
    private var bottomEdgeSwipeRegionWidth: Int = 0
    // TODO(b/320786721): support RTL layouts
    private var rightEdgeSwipeRegionWidth: Int = 0

    /**
     * True if we are currently tracking a gesture for opening the hub that started in the edge
@@ -102,9 +107,6 @@ 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.
     *
@@ -132,8 +134,6 @@ constructor(
     * 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

@@ -192,28 +192,45 @@ constructor(
            throw RuntimeException("Communal view has already been initialized")
        }

        if (touchMonitor == null) {
            touchMonitor =
                ambientTouchComponentFactory.create(this, HashSet()).getTouchMonitor().apply {
                    init()
                }
        }
        lifecycleRegistry.currentState = Lifecycle.State.CREATED

        communalContainerView = containerView

        rightEdgeSwipeRegionWidth =
            containerView.resources.getDimensionPixelSize(
                R.dimen.communal_right_edge_swipe_region_width
            )
        topEdgeSwipeRegionWidth =
            containerView.resources.getDimensionPixelSize(
                R.dimen.communal_top_edge_swipe_region_height
            )
        bottomEdgeSwipeRegionWidth =
            containerView.resources.getDimensionPixelSize(
                R.dimen.communal_bottom_edge_swipe_region_height
            )

        collectFlow(
            containerView,
            keyguardTransitionInteractor.isFinishedInStateWhere(KeyguardState::isBouncerState),
            { anyBouncerShowing = it }
            {
                anyBouncerShowing = it
                updateLifecycleState()
            }
        )
        collectFlow(
            containerView,
            communalInteractor.isCommunalShowing,
            {
                hubShowing = it
                updateLifecycleState()
            }
        )
        collectFlow(
            containerView,
            shadeInteractor.isAnyFullyExpanded,
            {
                shadeShowing = it
                updateLifecycleState()
            }
        )
        collectFlow(containerView, communalInteractor.isCommunalShowing, { hubShowing = it })
        collectFlow(containerView, shadeInteractor.isAnyFullyExpanded, { shadeShowing = it })
        collectFlow(containerView, keyguardInteractor.isDreaming, { isDreaming = it })

        communalContainerView = containerView
@@ -221,10 +238,24 @@ constructor(
        return containerView
    }

    /**
     * Updates the lifecycle stored by the [lifecycleRegistry] to control when the [touchMonitor]
     * should listen for and intercept top and bottom swipes.
     */
    private fun updateLifecycleState() {
        val shouldInterceptGestures = hubShowing && !(shadeShowing || anyBouncerShowing)
        if (shouldInterceptGestures) {
            lifecycleRegistry.currentState = Lifecycle.State.RESUMED
        } else {
            lifecycleRegistry.currentState = Lifecycle.State.STARTED
        }
    }

    /** Removes the container view from its parent. */
    fun disposeView() {
        communalContainerView?.let {
            (it.parent as ViewGroup).removeView(it)
            lifecycleRegistry.currentState = Lifecycle.State.CREATED
            communalContainerView = null
        }
    }
@@ -262,16 +293,8 @@ constructor(
        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) {
                isTrackingHubGesture = true
            } else {
            isTrackingHubTouch = true
        }
        }

        if (isTrackingHubTouch) {
            // Tracking a touch on the hub UI itself.
@@ -283,19 +306,6 @@ constructor(
            // 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
@@ -347,4 +357,7 @@ constructor(
            0
        )
    }

    override val lifecycle: Lifecycle
        get() = lifecycleRegistry
}
+150 −81
Original line number Diff line number Diff line
@@ -25,23 +25,24 @@ import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.SceneKey
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.android.systemui.ambient.touch.TouchHandler
import com.android.systemui.ambient.touch.TouchMonitor
import com.android.systemui.ambient.touch.dagger.AmbientTouchComponent
import com.android.systemui.communal.data.repository.FakeCommunalRepository
import com.android.systemui.communal.data.repository.fakeCommunalRepository
import com.android.systemui.communal.domain.interactor.CommunalInteractor
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.communal.util.CommunalColors
import com.android.systemui.coroutines.collectLastValue
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
@@ -51,7 +52,6 @@ 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
@@ -60,7 +60,6 @@ import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
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
@@ -87,16 +86,14 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
    @Mock private lateinit var communalViewModel: CommunalViewModel
    @Mock private lateinit var powerManager: PowerManager
    @Mock private lateinit var dialogFactory: SystemUIDialogFactory
    @Mock private lateinit var touchMonitor: TouchMonitor
    @Mock private lateinit var communalColors: CommunalColors
    private lateinit var keyguardTransitionInteractor: KeyguardTransitionInteractor
    private lateinit var shadeInteractor: ShadeInteractor
    private lateinit var keyguardInteractor: KeyguardInteractor
    private lateinit var ambientTouchComponentFactory: AmbientTouchComponent.Factory

    private lateinit var parentView: FrameLayout
    private lateinit var containerView: View
    private lateinit var testableLooper: TestableLooper

    private lateinit var communalInteractor: CommunalInteractor
    private lateinit var communalRepository: FakeCommunalRepository
    private lateinit var underTest: GlanceableHubContainerController

@@ -104,12 +101,20 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
    fun setUp() {
        MockitoAnnotations.initMocks(this)

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

        ambientTouchComponentFactory =
            object : AmbientTouchComponent.Factory {
                override fun create(
                    lifecycleOwner: LifecycleOwner,
                    touchHandlers: Set<TouchHandler>
                ): AmbientTouchComponent =
                    object : AmbientTouchComponent {
                        override fun getTouchMonitor(): TouchMonitor = touchMonitor
                    }
            }

        with(kosmos) {
            underTest =
                GlanceableHubContainerController(
                    communalInteractor,
@@ -120,16 +125,13 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                    shadeInteractor,
                    powerManager,
                    communalColors,
                    ambientTouchComponentFactory,
                    kosmos.sceneDataSourceDelegator,
                )
        }
        testableLooper = TestableLooper.get(this)

        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(
            R.dimen.communal_bottom_edge_swipe_region_height,
            BOTTOM_SWIPE_REGION_WIDTH
        )

        // Make communal available so that communalInteractor.desiredScene accurately reflects
        // scene changes instead of just returning Blank.
@@ -161,6 +163,7 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                        shadeInteractor,
                        powerManager,
                        communalColors,
                        ambientTouchComponentFactory,
                        kosmos.sceneDataSourceDelegator,
                    )

@@ -215,63 +218,137 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
        }

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

                // Touch event in the top swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_TOP_SWIPE_REGION_EVENT)).isFalse()
                // Bouncer is visible.
                fakeKeyguardTransitionRepository.sendTransitionSteps(
                    KeyguardState.GLANCEABLE_HUB,
                    KeyguardState.PRIMARY_BOUNCER,
                    testScope
                )
                testableLooper.processAllMessages()

                // Touch events are not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
                // User activity is not sent to PowerManager.
                verify(powerManager, times(0)).userActivity(any(), any(), any())
            }
        }

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

                // Touch event in the bottom swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_BOTTOM_SWIPE_REGION_EVENT)).isFalse()
                // Shade shows up.
                fakeShadeRepository.setQsExpansion(1.0f)
                testableLooper.processAllMessages()

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

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

                // Device is dreaming.
                fakeKeyguardRepository.setDreaming(true)
                runCurrent()
                // Touch events are intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isTrue()

                // Container view disposed.
                underTest.disposeView()

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

    @Test
    fun lifecycle_initializedAfterConstruction() =
        with(kosmos) {
            val underTest =
                GlanceableHubContainerController(
                    communalInteractor,
                    communalViewModel,
                    dialogFactory,
                    keyguardTransitionInteractor,
                    keyguardInteractor,
                    shadeInteractor,
                    powerManager,
                    communalColors,
                    ambientTouchComponentFactory,
                    kosmos.sceneDataSourceDelegator,
                )

            assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.INITIALIZED)
        }

    @Test
    fun lifecycle_createdAfterViewCreated() =
        with(kosmos) {
            val underTest =
                GlanceableHubContainerController(
                    communalInteractor,
                    communalViewModel,
                    dialogFactory,
                    keyguardTransitionInteractor,
                    keyguardInteractor,
                    shadeInteractor,
                    powerManager,
                    communalColors,
                    ambientTouchComponentFactory,
                    kosmos.sceneDataSourceDelegator,
                )

            // Only initView without attaching a view as we don't want the flows to start collecting
            // yet.
            underTest.initView(View(context))

                // Touch event in the top swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_TOP_SWIPE_REGION_EVENT)).isFalse()
            assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.CREATED)
        }

    @Test
    fun lifecycle_startedAfterFlowsUpdate() {
        // Flows start collecting due to test setup, causing the state to advance to STARTED.
        assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
    }

    @Test
    fun onTouchEvent_bottomSwipeWhenDreaming_doesNotIntercept() =
    fun lifecycle_resumedAfterCommunalShows() {
        // Communal is open.
        goToScene(CommunalScenes.Communal)

        assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
    }

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

                // Device is dreaming.
                fakeKeyguardRepository.setDreaming(true)
                runCurrent()
                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)

                // Touch event in the bottom swipe region is not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_IN_BOTTOM_SWIPE_REGION_EVENT)).isFalse()
                // Communal closes.
                goToScene(CommunalScenes.Blank)

                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
            }
        }

    @Test
    fun onTouchEvent_communalAndBouncerShowing_doesNotIntercept() =
    fun lifecycle_startedAfterPrimaryBouncerShows() =
        with(kosmos) {
            testScope.runTest {
                // Communal is open.
@@ -285,44 +362,49 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
                )
                testableLooper.processAllMessages()

                // Touch events are not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
                // User activity is not sent to PowerManager.
                verify(powerManager, times(0)).userActivity(any(), any(), any())
                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
            }
        }

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

                // Shade shows up.
                fakeShadeRepository.setQsExpansion(1.0f)
                // Bouncer is visible.
                fakeKeyguardTransitionRepository.sendTransitionSteps(
                    KeyguardState.GLANCEABLE_HUB,
                    KeyguardState.ALTERNATE_BOUNCER,
                    testScope
                )
                testableLooper.processAllMessages()

                // Touch events are not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
            }
        }

    @Test
    fun onTouchEvent_containerViewDisposed_doesNotIntercept() =
    fun lifecycle_createdAfterDisposeView() {
        // Container view disposed.
        underTest.disposeView()

        assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.CREATED)
    }

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

                // Touch events are intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isTrue()

                // Container view disposed.
                underTest.disposeView()
                // Shade shows up.
                fakeShadeRepository.setQsExpansion(1.0f)
                testableLooper.processAllMessages()

                // Touch events are not intercepted.
                assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse()
                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
            }
        }

@@ -371,8 +453,6 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
        private const val CONTAINER_WIDTH = 100
        private const val CONTAINER_HEIGHT = 100
        private const val RIGHT_SWIPE_REGION_WIDTH = 20
        private const val TOP_SWIPE_REGION_WIDTH = 20
        private const val BOTTOM_SWIPE_REGION_WIDTH = 20

        /**
         * A touch down event right in the middle of the screen, to avoid being in any of the swipe
@@ -389,17 +469,6 @@ class GlanceableHubContainerControllerTest : SysuiTestCase() {
            )
        private val DOWN_IN_RIGHT_SWIPE_REGION_EVENT =
            MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, CONTAINER_WIDTH.toFloat(), 0f, 0)
        private val DOWN_IN_TOP_SWIPE_REGION_EVENT =
            MotionEvent.obtain(
                0L,
                0L,
                MotionEvent.ACTION_DOWN,
                0f,
                TOP_SWIPE_REGION_WIDTH.toFloat(),
                0
            )
        private val DOWN_IN_BOTTOM_SWIPE_REGION_EVENT =
            MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, CONTAINER_HEIGHT.toFloat(), 0)
        private val MOVE_EVENT = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 0f, 0f, 0)
        private val UP_EVENT = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_UP, 0f, 0f, 0)
    }