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

Commit 5dd44157 authored by William Xiao's avatar William Xiao
Browse files

Move glanceable hub container to the bottom of NotificationShadeWindowView

We want to be able to open the shade over the glanceable hub and we
also don't want to have the hub's SceneTransitionLayout block touches
to the rest of the system, so this CL moves the hub container to the
lowest z-order in NotificationShadeWindowView.

In order for it to continue working, we route touches from
NotificationShadeWindowView to the hub in two cases. The first is if
the touch starts from the right-most edge, so that the hub's
SceneTransitionLayout can detect the gesture and animate open the hub.
Unfortunately we cannot drive the transition externally as
SceneTransitionLayout expects to manage all of this on its own.

The second case is that when the glanceable hub is fully open, we route
all touches to it, until it closes. Further work is needed to animate
out keyguard and to allow the shade/bouncer to be opened when in the
hub but for now it's functional.

Bug: 315207481
Fixed: 315203485
Test: atest NotificationShadeWindowViewControllerTest GlanceableHubContainerControllerTest
Flag: ACONFIG com.android.systemui.communal_hub DEVELOPMENT
Change-Id: I7de8bdaf277c37d1744346c532b65c7344ed37d4
parent e4d06ae4
Loading
Loading
Loading
Loading
+6 −6
Original line number Diff line number Diff line
@@ -26,6 +26,12 @@
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <!-- Placeholder for the communal UI that will be replaced if the feature is enabled. -->
    <ViewStub
        android:id="@+id/communal_ui_stub"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <com.android.systemui.scrim.ScrimView
        android:id="@+id/scrim_behind"
        android:layout_width="match_parent"
@@ -64,12 +70,6 @@
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <!-- Placeholder for the communal UI that will be replaced if the feature is enabled. -->
    <ViewStub
        android:id="@+id/communal_ui_stub"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <include layout="@layout/brightness_mirror_container" />

    <com.android.systemui.scrim.ScrimView
+6 −1
Original line number Diff line number Diff line
@@ -80,6 +80,11 @@ enum class KeyguardState {
            return state != GONE
        }

        /** Whether either of the bouncers are visible when we're FINISHED in the given state. */
        fun isBouncerState(state: KeyguardState): Boolean {
            return state == PRIMARY_BOUNCER || state == ALTERNATE_BOUNCER
        }

        /**
         * Whether the device is awake ([PowerInteractor.isAwake]) when we're FINISHED in the given
         * keyguard state.
+186 −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.shade

import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.communal.domain.interactor.CommunalInteractor
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.compose.ComposeFacade.createCommunalContainer
import com.android.systemui.compose.ComposeFacade.isComposeAvailable
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.res.R
import com.android.systemui.shade.domain.interactor.ShadeInteractor
import com.android.systemui.util.kotlin.collectFlow
import javax.inject.Inject

/**
 * Controller that's responsible for the glanceable hub container view and its touch handling.
 *
 * This will be used until the glanceable hub is integrated into Flexiglass.
 */
class GlanceableHubContainerController
@Inject
constructor(
    private val communalInteractor: CommunalInteractor,
    private val communalViewModel: CommunalViewModel,
    private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
    private val shadeInteractor: ShadeInteractor,
) {
    /** The container view for the hub. This will not be initialized until [initView] is called. */
    private lateinit var communalContainerView: View

    /**
     * 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 edgeSwipeRegionWidth: Int = 0

    /**
     * True if we are currently tracking a gesture for opening the hub that started in the edge
     * swipe region.
     */
    private var isTrackingOpenGesture = false

    /**
     * True if the hub UI is fully open, meaning it should receive touch input.
     *
     * Tracks [CommunalInteractor.isCommunalShowing].
     */
    private var hubShowing = false

    /**
     * True if either the primary or alternate bouncer are open, meaning the hub should not receive
     * any touch input.
     *
     * Tracks [KeyguardTransitionInteractor.isFinishedInState] for [KeyguardState.isBouncerState].
     */
    private var anyBouncerShowing = false

    /**
     * True if the shade is fully expanded, meaning the hub should not receive any touch input.
     *
     * Tracks [ShadeInteractor.isAnyFullyExpanded].
     */
    private var shadeShowing = false

    /** Returns true if the glanceable hub is enabled and the container view can be created. */
    fun isEnabled(): Boolean {
        return communalInteractor.isCommunalEnabled && isComposeAvailable()
    }

    /**
     * Creates the container view containing the glanceable hub UI.
     *
     * @throws RuntimeException if [isEnabled] is false or the view is already initialized
     */
    fun initView(
        context: Context,
    ): View {
        return initView(createCommunalContainer(context, communalViewModel))
    }

    /** Override for testing. */
    @VisibleForTesting
    internal fun initView(containerView: View): View {
        if (!isEnabled()) {
            throw RuntimeException("Glanceable hub is not enabled")
        }
        if (::communalContainerView.isInitialized) {
            throw RuntimeException("Communal view has already been initialized")
        }

        communalContainerView = containerView

        edgeSwipeRegionWidth =
            communalContainerView.resources.getDimensionPixelSize(R.dimen.communal_grid_gutter_size)

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

        return communalContainerView
    }

    /**
     * Notifies the hub container of a touch event. Returns true if it's determined that the touch
     * should go to the hub container and no one else.
     *
     * Special handling is needed because the hub container sits at the lowest z-order in
     * [NotificationShadeWindowView] and would not normally receive touches. We also cannot use a
     * [GestureDetector] as the hub container's SceneTransitionLayout is a Compose view that expects
     * to be fully in control of its own touch handling.
     */
    fun onTouchEvent(ev: MotionEvent): Boolean {
        if (!::communalContainerView.isInitialized) {
            return false
        }

        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.
        val communalVisible = hubShowing && !hubOccluded
        if (communalVisible) {
            return communalContainerView.dispatchTouchEvent(ev)
        }

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

        if (!isTrackingOpenGesture && isDown) {
            val x = ev.rawX
            val inOpeningSwipeRegion: Boolean =
                x >= communalContainerView.width - edgeSwipeRegionWidth
            if (inOpeningSwipeRegion && !hubOccluded) {
                isTrackingOpenGesture = true
                return communalContainerView.dispatchTouchEvent(ev)
            }
        } else if (isTrackingOpenGesture) {
            if (isUp || isCancel) {
                isTrackingOpenGesture = false
            }
            return communalContainerView.dispatchTouchEvent(ev)
        }

        return false
    }
}
+14 −16
Original line number Diff line number Diff line
@@ -42,9 +42,6 @@ import com.android.systemui.bouncer.domain.interactor.PrimaryBouncerInteractor;
import com.android.systemui.bouncer.ui.binder.KeyguardBouncerViewBinder;
import com.android.systemui.bouncer.ui.viewmodel.KeyguardBouncerViewModel;
import com.android.systemui.classifier.FalsingCollector;
import com.android.systemui.communal.data.repository.CommunalRepository;
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel;
import com.android.systemui.compose.ComposeFacade;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.deviceentry.shared.DeviceEntryUdfpsRefactor;
import com.android.systemui.dock.DockManager;
@@ -108,14 +105,14 @@ public class NotificationShadeWindowViewController implements Dumpable {
    private final PulsingGestureListener mPulsingGestureListener;
    private final LockscreenHostedDreamGestureListener mLockscreenHostedDreamGestureListener;
    private final NotificationInsetsController mNotificationInsetsController;
    private final CommunalViewModel mCommunalViewModel;
    private final CommunalRepository mCommunalRepository;
    private final boolean mIsTrackpadCommonEnabled;
    private final FeatureFlagsClassic mFeatureFlagsClassic;
    private final SysUIKeyEventHandler mSysUIKeyEventHandler;
    private final PrimaryBouncerInteractor mPrimaryBouncerInteractor;
    private final AlternateBouncerInteractor mAlternateBouncerInteractor;
    private final QuickSettingsController mQuickSettingsController;
    private final GlanceableHubContainerController
            mGlanceableHubContainerController;
    private GestureDetector mPulsingWakeupGestureHandler;
    private GestureDetector mDreamingWakeupGestureHandler;
    private View mBrightnessMirror;
@@ -183,8 +180,7 @@ public class NotificationShadeWindowViewController implements Dumpable {
            KeyguardMessageAreaController.Factory messageAreaControllerFactory,
            KeyguardTransitionInteractor keyguardTransitionInteractor,
            PrimaryBouncerToGoneTransitionViewModel primaryBouncerToGoneTransitionViewModel,
            CommunalViewModel communalViewModel,
            CommunalRepository communalRepository,
            GlanceableHubContainerController glanceableHubContainerController,
            NotificationLaunchAnimationInteractor notificationLaunchAnimationInteractor,
            FeatureFlagsClassic featureFlagsClassic,
            SystemClock clock,
@@ -217,8 +213,7 @@ public class NotificationShadeWindowViewController implements Dumpable {
        mPulsingGestureListener = pulsingGestureListener;
        mLockscreenHostedDreamGestureListener = lockscreenHostedDreamGestureListener;
        mNotificationInsetsController = notificationInsetsController;
        mCommunalViewModel = communalViewModel;
        mCommunalRepository = communalRepository;
        mGlanceableHubContainerController = glanceableHubContainerController;
        mIsTrackpadCommonEnabled = featureFlagsClassic.isEnabled(TRACKPAD_GESTURE_COMMON);
        mFeatureFlagsClassic = featureFlagsClassic;
        mSysUIKeyEventHandler = sysUIKeyEventHandler;
@@ -347,6 +342,10 @@ public class NotificationShadeWindowViewController implements Dumpable {

                mFalsingCollector.onTouchEvent(ev);
                mPulsingWakeupGestureHandler.onTouchEvent(ev);

                if (mGlanceableHubContainerController.onTouchEvent(ev)) {
                    return logDownDispatch(ev, "dispatched to glanceable hub container", true);
                }
                if (mDreamingWakeupGestureHandler != null
                        && mDreamingWakeupGestureHandler.onTouchEvent(ev)) {
                    return logDownDispatch(ev, "dream wakeup gesture handled", true);
@@ -587,14 +586,13 @@ public class NotificationShadeWindowViewController implements Dumpable {
    }

    /**
     * Sets up the communal hub UI if the {@link com.android.systemui.Flags#FLAG_COMMUNAL_HUB} flag
     * is enabled.
     * Sets up the glanceable hub UI if the {@link com.android.systemui.Flags#FLAG_COMMUNAL_HUB}
     * flag is enabled.
     *
     * The layout lives in {@link R.id.communal_ui_container}.
     * The layout lives in {@link R.id.communal_ui_stub}.
     */
    public void setupCommunalHubLayout() {
        if (!mCommunalRepository.isCommunalEnabled()
                || !ComposeFacade.INSTANCE.isComposeAvailable()) {
        if (!mGlanceableHubContainerController.isEnabled()) {
            return;
        }

@@ -602,8 +600,8 @@ public class NotificationShadeWindowViewController implements Dumpable {
        View communalPlaceholder = mView.findViewById(R.id.communal_ui_stub);
        int index = mView.indexOfChild(communalPlaceholder);
        mView.removeView(communalPlaceholder);
        mView.addView(ComposeFacade.INSTANCE.createCommunalContainer(mView.getContext(),
                mCommunalViewModel), index);

        mView.addView(mGlanceableHubContainerController.initView(mView.getContext()), index);
    }

    private boolean didNotificationPanelInterceptEvent(MotionEvent ev) {
+220 −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.shade

import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.testing.ViewUtils
import android.view.MotionEvent
import android.view.View
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.data.repository.FakeCommunalRepository
import com.android.systemui.communal.domain.interactor.CommunalInteractor
import com.android.systemui.communal.domain.interactor.CommunalInteractorFactory
import com.android.systemui.communal.shared.model.CommunalSceneKey
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.compose.ComposeFacade
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.res.R
import com.android.systemui.shade.domain.interactor.ShadeInteractor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations

@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
@SmallTest
class GlanceableHubContainerControllerTest : SysuiTestCase() {
    @Mock private lateinit var communalViewModel: CommunalViewModel
    @Mock private lateinit var keyguardTransitionInteractor: KeyguardTransitionInteractor
    @Mock private lateinit var shadeInteractor: ShadeInteractor

    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

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

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

        val withDeps = CommunalInteractorFactory.create()
        communalInteractor = withDeps.communalInteractor
        communalRepository = withDeps.communalRepository

        underTest =
            GlanceableHubContainerController(
                communalInteractor,
                communalViewModel,
                keyguardTransitionInteractor,
                shadeInteractor
            )
        testableLooper = TestableLooper.get(this)

        communalRepository.setIsCommunalEnabled(true)

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

        overrideResource(R.dimen.communal_grid_gutter_size, SWIPE_REGION_WIDTH)
    }

    @Test
    fun isEnabled_interactorEnabled_returnsTrue() {
        communalRepository.setIsCommunalEnabled(true)

        assertThat(underTest.isEnabled()).isTrue()
    }

    @Test
    fun isEnabled_interactorDisabled_returnsFalse() {
        communalRepository.setIsCommunalEnabled(false)

        assertThat(underTest.isEnabled()).isFalse()
    }

    @Test
    fun initView_notEnabled_throwsException() {
        communalRepository.setIsCommunalEnabled(false)

        assertThrows(RuntimeException::class.java) { underTest.initView(context) }
    }

    @Test
    fun initView_calledTwice_throwsException() {
        // First call succeeds.
        underTest.initView(context)

        // Second call throws.
        assertThrows(RuntimeException::class.java) { underTest.initView(context) }
    }

    @Test
    fun onTouchEvent_touchInsideGestureRegion_returnsTrue() {
        // Communal is open.
        communalRepository.setDesiredScene(CommunalSceneKey.Communal)

        initAndAttachContainerView()

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

    @Test
    fun onTouchEvent_subsequentTouchesAfterGestureStart_returnsTrue() {
        // Communal is open.
        communalRepository.setDesiredScene(CommunalSceneKey.Communal)

        initAndAttachContainerView()

        // Initial touch down is intercepted, and so are touches outside of the region, until an up
        // event is received.
        assertThat(underTest.onTouchEvent(DOWN_IN_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_returnsTrue() {
        // Communal is open.
        communalRepository.setDesiredScene(CommunalSceneKey.Communal)

        initAndAttachContainerView()
        testableLooper.processAllMessages()

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

    @Test
    fun onTouchEvent_communalAndBouncerShowing_returnsFalse() {
        // Communal is open.
        communalRepository.setDesiredScene(CommunalSceneKey.Communal)

        initAndAttachContainerView()

        // Bouncer is visible.
        bouncerShowingFlow.value = true
        testableLooper.processAllMessages()

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

    @Test
    fun onTouchEvent_communalAndShadeShowing_returnsFalse() {
        // Communal is open.
        communalRepository.setDesiredScene(CommunalSceneKey.Communal)

        initAndAttachContainerView()

        shadeShowingFlow.value = true
        testableLooper.processAllMessages()

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

    private fun initAndAttachContainerView() {
        containerView = View(context)
        // Make view clickable so that dispatchTouchEvent returns true.
        containerView.isClickable = true

        underTest.initView(containerView)
        // Attach the view so that flows start collecting.
        ViewUtils.attachView(containerView)
        // Give the view a size so that determining if a touch starts at the right edge works.
        containerView.layout(0, 0, CONTAINER_WIDTH, CONTAINER_HEIGHT)
    }

    companion object {
        private const val CONTAINER_WIDTH = 100
        private const val CONTAINER_HEIGHT = 100
        private const val SWIPE_REGION_WIDTH = 20

        private val DOWN_EVENT = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0)
        private val DOWN_IN_SWIPE_REGION_EVENT =
            MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, CONTAINER_WIDTH.toFloat(), 0f, 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)

        @BeforeClass
        @JvmStatic
        fun beforeClass() {
            // Glanceable hub requires Compose, no point running any of these tests if compose isn't
            // enabled.
            assumeTrue(ComposeFacade.isComposeAvailable())
        }
    }
}
Loading