Allow fullscreen swipes for shade and bouncer.

This changelist adds support for fullscreen swipes on the dream
and glanceable hub for bringing down the notification shade and
dragging up the keyguard bouncer.

Test: atest BouncerFullscreenSwipeTouchHandlerTest#testFullSwipe_notInitiatedWhenNotAvailable
Test: atest BouncerFullscreenSwipeTouchHandlerTest#testFullSwipe_initiatedWhenAvailable
Test: atest BouncerFullscreenSwipeTouchHandlerTest#testFullSwipe_motionCancelResetsTouchState
Test: atest BouncerFullscreenSwipeTouchHandlerTest#testFullSwipe_motionUpResetsTouchState
Test: atest ShadeTouchHandlerTest#testFullVerticalSwipe_initiatedWhenAvailable
Test: atest ShadeTouchHandlerTest#testFullVerticalSwipe_notInitiatedWhenNotAvailable
Test: atest ShadeTouchHandlerTest#testFullVerticalSwipe_resetsTouchStateOnCancell
Test: atest ShadeTouchHandlerTest#testFullVerticalSwipe_resetsTouchStateOnUp
Test: atest CommunalViewModelTest#glanceableTouchAvailable_availableWhenNestedScrollingWithoutConsumption
Fixes: 340177049
Flag: com.android.systemui.hubmode_fullscreen_vertical_swipe
Change-Id: Ib65987789785b0c3b5edca8cb8cfb858f22fc0a7
diff --git a/packages/SystemUI/aconfig/systemui.aconfig b/packages/SystemUI/aconfig/systemui.aconfig
index eb9d0ab..462db34 100644
--- a/packages/SystemUI/aconfig/systemui.aconfig
+++ b/packages/SystemUI/aconfig/systemui.aconfig
@@ -1206,6 +1206,13 @@
 }
 
 flag {
+  name: "hubmode_fullscreen_vertical_swipe"
+  namespace: "systemui"
+  description: "Enables fullscreen vertical swiping in hub mode to bring up and down the bouncer and shade"
+  bug: "340177049"
+}
+
+flag {
    namespace: "systemui"
    name: "remove_update_listener_in_qs_icon_view_impl"
    description: "Remove update listeners in QsIconViewImpl class to avoid memory leak."
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt
index 650c2d3..c381881 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt
@@ -42,6 +42,7 @@
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.awaitFirstDown
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -112,6 +113,11 @@
 import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.changedToUp
+import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.boundsInWindow
@@ -138,6 +144,7 @@
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.times
+import androidx.compose.ui.util.fastAll
 import androidx.compose.ui.viewinterop.AndroidView
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.window.layout.WindowMetricsCalculator
@@ -204,13 +211,51 @@
         ScrollOnUpdatedLiveContentEffect(communalContent, gridState)
     }
 
+    val nestedScrollConnection = remember {
+        object : NestedScrollConnection {
+            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+                // Begin tracking nested scrolling
+                viewModel.onNestedScrolling()
+                return super.onPreScroll(available, source)
+            }
+        }
+    }
+
     Box(
         modifier =
             modifier
                 .semantics { testTagsAsResourceId = true }
                 .testTag(COMMUNAL_HUB_TEST_TAG)
                 .fillMaxSize()
+                .nestedScroll(nestedScrollConnection)
                 .pointerInput(gridState, contentOffset, contentListState) {
+                    awaitPointerEventScope {
+                        while (true) {
+                            var event = awaitFirstDown(requireUnconsumed = false)
+                            // Reset touch on first event.
+                            viewModel.onResetTouchState()
+
+                            // Process down event in case it's consumed immediately
+                            if (event.isConsumed) {
+                                viewModel.onHubTouchConsumed()
+                            }
+
+                            do {
+                                var event = awaitPointerEvent()
+                                for (change in event.changes) {
+                                    if (change.isConsumed) {
+                                        // Signal touch consumption on any consumed event.
+                                        viewModel.onHubTouchConsumed()
+                                    }
+                                }
+                            } while (
+                                !event.changes.fastAll {
+                                    it.changedToUp() || it.changedToUpIgnoreConsumed()
+                                }
+                            )
+                        }
+                    }
+
                     // If not in edit mode, don't allow selecting items.
                     if (!viewModel.isEditMode) return@pointerInput
                     observeTaps { offset ->
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerFullscreenSwipeTouchHandlerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerFullscreenSwipeTouchHandlerTest.java
new file mode 100644
index 0000000..4850085
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerFullscreenSwipeTouchHandlerTest.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.ambient.touch;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.animation.ValueAnimator;
+import android.content.pm.UserInfo;
+import android.graphics.Rect;
+import android.platform.test.annotations.DisableFlags;
+import android.platform.test.annotations.EnableFlags;
+import android.view.GestureDetector.OnGestureListener;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.logging.UiEventLogger;
+import com.android.internal.widget.LockPatternUtils;
+import com.android.systemui.Flags;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.ambient.touch.scrim.ScrimController;
+import com.android.systemui.ambient.touch.scrim.ScrimManager;
+import com.android.systemui.communal.ui.viewmodel.CommunalViewModel;
+import com.android.systemui.kosmos.KosmosJavaAdapter;
+import com.android.systemui.plugins.ActivityStarter;
+import com.android.systemui.settings.FakeUserTracker;
+import com.android.systemui.shared.system.InputChannelCompat;
+import com.android.systemui.statusbar.NotificationShadeWindowController;
+import com.android.systemui.statusbar.phone.CentralSurfaces;
+import com.android.wm.shell.animation.FlingAnimationUtils;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Collections;
+import java.util.Optional;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@EnableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
+@DisableFlags(Flags.FLAG_COMMUNAL_BOUNCER_DO_NOT_MODIFY_PLUGIN_OPEN)
+public class BouncerFullscreenSwipeTouchHandlerTest extends SysuiTestCase {
+    private KosmosJavaAdapter mKosmos;
+
+    @Mock
+    CentralSurfaces mCentralSurfaces;
+
+    @Mock
+    ScrimManager mScrimManager;
+
+    @Mock
+    ScrimController mScrimController;
+
+    @Mock
+    NotificationShadeWindowController mNotificationShadeWindowController;
+
+    @Mock
+    FlingAnimationUtils mFlingAnimationUtils;
+
+    @Mock
+    FlingAnimationUtils mFlingAnimationUtilsClosing;
+
+    @Mock
+    TouchHandler.TouchSession mTouchSession;
+
+    BouncerSwipeTouchHandler mTouchHandler;
+
+    @Mock
+    BouncerSwipeTouchHandler.ValueAnimatorCreator mValueAnimatorCreator;
+
+    @Mock
+    ValueAnimator mValueAnimator;
+
+    @Mock
+    BouncerSwipeTouchHandler.VelocityTrackerFactory mVelocityTrackerFactory;
+
+    @Mock
+    VelocityTracker mVelocityTracker;
+
+    @Mock
+    UiEventLogger mUiEventLogger;
+
+    @Mock
+    LockPatternUtils mLockPatternUtils;
+
+    @Mock
+    ActivityStarter mActivityStarter;
+
+    @Mock
+    CommunalViewModel mCommunalViewModel;
+
+    FakeUserTracker mUserTracker;
+
+    private static final float TOUCH_REGION = .3f;
+    private static final float MIN_BOUNCER_HEIGHT = .05f;
+
+    private static final Rect SCREEN_BOUNDS = new Rect(0, 0, 1024, 100);
+    private static final UserInfo CURRENT_USER_INFO = new UserInfo(
+            10,
+            /* name= */ "user10",
+            /* flags= */ 0
+    );
+
+    @Before
+    public void setup() {
+        mKosmos = new KosmosJavaAdapter(this);
+        MockitoAnnotations.initMocks(this);
+        mUserTracker = new FakeUserTracker();
+        mTouchHandler = new BouncerSwipeTouchHandler(
+                mKosmos.getTestScope(),
+                mScrimManager,
+                Optional.of(mCentralSurfaces),
+                mNotificationShadeWindowController,
+                mValueAnimatorCreator,
+                mVelocityTrackerFactory,
+                mLockPatternUtils,
+                mUserTracker,
+                mCommunalViewModel,
+                mFlingAnimationUtils,
+                mFlingAnimationUtilsClosing,
+                TOUCH_REGION,
+                MIN_BOUNCER_HEIGHT,
+                mUiEventLogger,
+                mActivityStarter);
+
+        when(mScrimManager.getCurrentController()).thenReturn(mScrimController);
+        when(mValueAnimatorCreator.create(anyFloat(), anyFloat())).thenReturn(mValueAnimator);
+        when(mVelocityTrackerFactory.obtain()).thenReturn(mVelocityTracker);
+        when(mFlingAnimationUtils.getMinVelocityPxPerSecond()).thenReturn(Float.MAX_VALUE);
+        when(mTouchSession.getBounds()).thenReturn(SCREEN_BOUNDS);
+        when(mLockPatternUtils.isSecure(CURRENT_USER_INFO.id)).thenReturn(true);
+
+        mUserTracker.set(Collections.singletonList(CURRENT_USER_INFO), 0);
+    }
+
+    /**
+     * Ensures expansion does not happen for full vertical swipes when touch is not available.
+     */
+    @Test
+    public void testFullSwipe_notInitiatedWhenNotAvailable() {
+        mTouchHandler.onGlanceableTouchAvailable(false);
+        mTouchHandler.onSessionStart(mTouchSession);
+        ArgumentCaptor<OnGestureListener> gestureListenerCaptor =
+                ArgumentCaptor.forClass(OnGestureListener.class);
+        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
+
+        // A touch within range at the bottom of the screen should trigger listening
+        assertThat(gestureListenerCaptor.getValue()
+                .onScroll(Mockito.mock(MotionEvent.class),
+                        Mockito.mock(MotionEvent.class),
+                        1,
+                        2)).isFalse();
+    }
+
+    /**
+     * Ensures expansion only happens for full vertical swipes when touch is available.
+     */
+    @Test
+    public void testFullSwipe_initiatedWhenAvailable() {
+        mTouchHandler.onGlanceableTouchAvailable(true);
+        mTouchHandler.onSessionStart(mTouchSession);
+        ArgumentCaptor<OnGestureListener> gestureListenerCaptor =
+                ArgumentCaptor.forClass(OnGestureListener.class);
+        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
+
+        // A touch within range at the bottom of the screen should trigger listening
+        assertThat(gestureListenerCaptor.getValue()
+                .onScroll(Mockito.mock(MotionEvent.class),
+                        Mockito.mock(MotionEvent.class),
+                        1,
+                        2)).isTrue();
+    }
+
+    @Test
+    public void testFullSwipe_motionUpResetsTouchState() {
+        mTouchHandler.onGlanceableTouchAvailable(true);
+        mTouchHandler.onSessionStart(mTouchSession);
+        ArgumentCaptor<OnGestureListener> gestureListenerCaptor =
+                ArgumentCaptor.forClass(OnGestureListener.class);
+        ArgumentCaptor<InputChannelCompat.InputEventListener> inputListenerCaptor =
+                ArgumentCaptor.forClass(InputChannelCompat.InputEventListener.class);
+        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
+        verify(mTouchSession).registerInputListener(inputListenerCaptor.capture());
+
+        // A touch within range at the bottom of the screen should trigger listening
+        assertThat(gestureListenerCaptor.getValue()
+                .onScroll(Mockito.mock(MotionEvent.class),
+                        Mockito.mock(MotionEvent.class),
+                        1,
+                        2)).isTrue();
+
+        MotionEvent upEvent = Mockito.mock(MotionEvent.class);
+        when(upEvent.getAction()).thenReturn(MotionEvent.ACTION_UP);
+        inputListenerCaptor.getValue().onInputEvent(upEvent);
+        verify(mCommunalViewModel).onResetTouchState();
+    }
+
+    @Test
+    public void testFullSwipe_motionCancelResetsTouchState() {
+        mTouchHandler.onGlanceableTouchAvailable(true);
+        mTouchHandler.onSessionStart(mTouchSession);
+        ArgumentCaptor<OnGestureListener> gestureListenerCaptor =
+                ArgumentCaptor.forClass(OnGestureListener.class);
+        ArgumentCaptor<InputChannelCompat.InputEventListener> inputListenerCaptor =
+                ArgumentCaptor.forClass(InputChannelCompat.InputEventListener.class);
+        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
+        verify(mTouchSession).registerInputListener(inputListenerCaptor.capture());
+
+        // A touch within range at the bottom of the screen should trigger listening
+        assertThat(gestureListenerCaptor.getValue()
+                .onScroll(Mockito.mock(MotionEvent.class),
+                        Mockito.mock(MotionEvent.class),
+                        1,
+                        2)).isTrue();
+
+        MotionEvent upEvent = Mockito.mock(MotionEvent.class);
+        when(upEvent.getAction()).thenReturn(MotionEvent.ACTION_CANCEL);
+        inputListenerCaptor.getValue().onInputEvent(upEvent);
+        verify(mCommunalViewModel).onResetTouchState();
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandlerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandlerTest.java
index 7ebc224..0e98b84 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandlerTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandlerTest.java
@@ -50,6 +50,8 @@
 import com.android.systemui.ambient.touch.scrim.ScrimController;
 import com.android.systemui.ambient.touch.scrim.ScrimManager;
 import com.android.systemui.bouncer.shared.constants.KeyguardBouncerConstants;
+import com.android.systemui.communal.ui.viewmodel.CommunalViewModel;
+import com.android.systemui.kosmos.KosmosJavaAdapter;
 import com.android.systemui.plugins.ActivityStarter;
 import com.android.systemui.settings.FakeUserTracker;
 import com.android.systemui.shade.ShadeExpansionChangeEvent;
@@ -72,7 +74,9 @@
 
 @SmallTest
 @RunWith(AndroidJUnit4.class)
+@DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
 public class BouncerSwipeTouchHandlerTest extends SysuiTestCase {
+    private KosmosJavaAdapter mKosmos;
     @Mock
     CentralSurfaces mCentralSurfaces;
 
@@ -120,6 +124,9 @@
     @Mock
     Region mRegion;
 
+    @Mock
+    CommunalViewModel mCommunalViewModel;
+
     @Captor
     ArgumentCaptor<Rect> mRectCaptor;
 
@@ -139,9 +146,11 @@
 
     @Before
     public void setup() {
+        mKosmos = new KosmosJavaAdapter(this);
         MockitoAnnotations.initMocks(this);
         mUserTracker = new FakeUserTracker();
         mTouchHandler = new BouncerSwipeTouchHandler(
+                mKosmos.getTestScope(),
                 mScrimManager,
                 Optional.of(mCentralSurfaces),
                 mNotificationShadeWindowController,
@@ -149,6 +158,7 @@
                 mVelocityTrackerFactory,
                 mLockPatternUtils,
                 mUserTracker,
+                mCommunalViewModel,
                 mFlingAnimationUtils,
                 mFlingAnimationUtilsClosing,
                 TOUCH_REGION,
@@ -201,7 +211,6 @@
                         2)).isTrue();
     }
 
-
     /**
      * Ensures expansion only happens when touch down happens in valid part of the screen.
      */
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.kt
index 7fd9ce2..204d4b0 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.kt
@@ -26,8 +26,10 @@
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.ambient.touch.TouchHandler.TouchSession
 import com.android.systemui.communal.domain.interactor.communalSettingsInteractor
+import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
 import com.android.systemui.flags.Flags.COMMUNAL_SERVICE_ENABLED
 import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.kosmos.testScope
 import com.android.systemui.shade.ShadeViewController
 import com.android.systemui.shared.system.InputChannelCompat
 import com.android.systemui.statusbar.phone.CentralSurfaces
@@ -50,11 +52,11 @@
 @RunWith(AndroidJUnit4::class)
 class ShadeTouchHandlerTest : SysuiTestCase() {
     private var kosmos = testKosmos()
-
     private var mCentralSurfaces = mock<CentralSurfaces>()
     private var mShadeViewController = mock<ShadeViewController>()
     private var mDreamManager = mock<DreamManager>()
     private var mTouchSession = mock<TouchSession>()
+    private var communalViewModel = mock<CommunalViewModel>()
 
     private lateinit var mTouchHandler: ShadeTouchHandler
 
@@ -65,9 +67,11 @@
     fun setup() {
         mTouchHandler =
             ShadeTouchHandler(
+                kosmos.testScope,
                 Optional.of(mCentralSurfaces),
                 mShadeViewController,
                 mDreamManager,
+                communalViewModel,
                 kosmos.communalSettingsInteractor,
                 TOUCH_HEIGHT
             )
@@ -75,6 +79,7 @@
 
     // Verifies that a swipe down in the gesture region is captured by the shade touch handler.
     @Test
+    @DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     fun testSwipeDown_captured() {
         val captured = swipe(Direction.DOWN)
         Truth.assertThat(captured).isTrue()
@@ -82,6 +87,7 @@
 
     // Verifies that a swipe in the upward direction is not captured.
     @Test
+    @DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     fun testSwipeUp_notCaptured() {
         val captured = swipe(Direction.UP)
 
@@ -91,6 +97,7 @@
 
     // Verifies that a swipe down forwards captured touches to central surfaces for handling.
     @Test
+    @DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     @EnableFlags(Flags.FLAG_COMMUNAL_HUB)
     fun testSwipeDown_communalEnabled_sentToCentralSurfaces() {
         kosmos.fakeFeatureFlagsClassic.set(COMMUNAL_SERVICE_ENABLED, true)
@@ -103,7 +110,7 @@
 
     // Verifies that a swipe down forwards captured touches to the shade view for handling.
     @Test
-    @DisableFlags(Flags.FLAG_COMMUNAL_HUB)
+    @DisableFlags(Flags.FLAG_COMMUNAL_HUB, Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     fun testSwipeDown_communalDisabled_sentToShadeView() {
         swipe(Direction.DOWN)
 
@@ -114,6 +121,7 @@
     // Verifies that a swipe down while dreaming forwards captured touches to the shade view for
     // handling.
     @Test
+    @DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     fun testSwipeDown_dreaming_sentToShadeView() {
         whenever(mDreamManager.isDreaming).thenReturn(true)
         swipe(Direction.DOWN)
@@ -124,6 +132,7 @@
 
     // Verifies that a swipe up is not forwarded to central surfaces.
     @Test
+    @DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     @EnableFlags(Flags.FLAG_COMMUNAL_HUB)
     fun testSwipeUp_communalEnabled_touchesNotSent() {
         kosmos.fakeFeatureFlagsClassic.set(COMMUNAL_SERVICE_ENABLED, true)
@@ -137,7 +146,7 @@
 
     // Verifies that a swipe up is not forwarded to the shade view.
     @Test
-    @DisableFlags(Flags.FLAG_COMMUNAL_HUB)
+    @DisableFlags(Flags.FLAG_COMMUNAL_HUB, Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     fun testSwipeUp_communalDisabled_touchesNotSent() {
         swipe(Direction.UP)
 
@@ -147,6 +156,7 @@
     }
 
     @Test
+    @DisableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
     fun testCancelMotionEvent_popsTouchSession() {
         swipe(Direction.DOWN)
         val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
@@ -154,6 +164,60 @@
         verify(mTouchSession).pop()
     }
 
+    @Test
+    @EnableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
+    fun testFullVerticalSwipe_initiatedWhenAvailable() {
+        // Indicate touches are available
+        mTouchHandler.onGlanceableTouchAvailable(true)
+
+        // Verify swipe is handled
+        val captured = swipe(Direction.DOWN)
+        Truth.assertThat(captured).isTrue()
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
+    fun testFullVerticalSwipe_notInitiatedWhenNotAvailable() {
+        // Indicate touches aren't available
+        mTouchHandler.onGlanceableTouchAvailable(false)
+
+        // Verify swipe is not handled
+        val captured = swipe(Direction.DOWN)
+        Truth.assertThat(captured).isFalse()
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
+    fun testFullVerticalSwipe_resetsTouchStateOnUp() {
+        // Indicate touches are available
+        mTouchHandler.onGlanceableTouchAvailable(true)
+
+        // Verify swipe is handled
+        swipe(Direction.DOWN)
+
+        val upEvent: MotionEvent = mock()
+        whenever(upEvent.action).thenReturn(MotionEvent.ACTION_UP)
+        mInputListenerCaptor.lastValue.onInputEvent(upEvent)
+
+        verify(communalViewModel).onResetTouchState()
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_HUBMODE_FULLSCREEN_VERTICAL_SWIPE)
+    fun testFullVerticalSwipe_resetsTouchStateOnCancel() {
+        // Indicate touches are available
+        mTouchHandler.onGlanceableTouchAvailable(true)
+
+        // Verify swipe is handled
+        swipe(Direction.DOWN)
+
+        val upEvent: MotionEvent = mock()
+        whenever(upEvent.action).thenReturn(MotionEvent.ACTION_CANCEL)
+        mInputListenerCaptor.lastValue.onInputEvent(upEvent)
+
+        verify(communalViewModel).onResetTouchState()
+    }
+
     /**
      * Simulates a swipe in the given direction and returns true if the touch was intercepted by the
      * touch handler's gesture listener.
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalViewModelTest.kt
index 86871ed..7a41bc6 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalViewModelTest.kt
@@ -756,6 +756,17 @@
         verify(metricsLogger).logTapWidget("test_pkg/test_cls", rank = 10)
     }
 
+    @Test
+    fun glanceableTouchAvailable_availableWhenNestedScrollingWithoutConsumption() =
+        testScope.runTest {
+            val touchAvailable by collectLastValue(underTest.glanceableTouchAvailable)
+            assertThat(touchAvailable).isTrue()
+            underTest.onHubTouchConsumed()
+            assertThat(touchAvailable).isFalse()
+            underTest.onNestedScrolling()
+            assertThat(touchAvailable).isTrue()
+        }
+
     private suspend fun setIsMainUser(isMainUser: Boolean) {
         val user = if (isMainUser) MAIN_USER_INFO else SECONDARY_USER_INFO
         with(userRepository) {
diff --git a/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.kt b/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.kt
index 00ca008..d5790a4 100644
--- a/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.kt
+++ b/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.kt
@@ -36,6 +36,7 @@
 import com.android.systemui.ambient.touch.scrim.ScrimController
 import com.android.systemui.ambient.touch.scrim.ScrimManager
 import com.android.systemui.bouncer.shared.constants.KeyguardBouncerConstants
+import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
 import com.android.systemui.plugins.ActivityStarter
 import com.android.systemui.settings.UserTracker
 import com.android.systemui.shade.ShadeExpansionChangeEvent
@@ -49,11 +50,14 @@
 import kotlin.math.hypot
 import kotlin.math.max
 import kotlin.math.min
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
 
 /** Monitor for tracking touches on the DreamOverlay to bring up the bouncer. */
 class BouncerSwipeTouchHandler
 @Inject
 constructor(
+    scope: CoroutineScope,
     private val scrimManager: ScrimManager,
     private val centralSurfaces: Optional<CentralSurfaces>,
     private val notificationShadeWindowController: NotificationShadeWindowController,
@@ -61,6 +65,7 @@
     private val velocityTrackerFactory: VelocityTrackerFactory,
     private val lockPatternUtils: LockPatternUtils,
     private val userTracker: UserTracker,
+    private val communalViewModel: CommunalViewModel,
     @param:Named(BouncerSwipeModule.SWIPE_TO_BOUNCER_FLING_ANIMATION_UTILS_OPENING)
     private val flingAnimationUtils: FlingAnimationUtils,
     @param:Named(BouncerSwipeModule.SWIPE_TO_BOUNCER_FLING_ANIMATION_UTILS_CLOSING)
@@ -96,6 +101,10 @@
 
             currentScrimController = controller
         }
+
+    /** Determines whether the touch handler should process touches in fullscreen swiping mode */
+    private var touchAvailable = false
+
     private val onGestureListener: GestureDetector.OnGestureListener =
         object : SimpleOnGestureListener() {
             override fun onScroll(
@@ -107,7 +116,9 @@
                 if (capture == null) {
                     capture =
                         if (Flags.dreamOverlayBouncerSwipeDirectionFiltering()) {
-                            (abs(distanceY.toDouble()) > abs(distanceX.toDouble()) && distanceY > 0)
+                            (abs(distanceY.toDouble()) > abs(distanceX.toDouble()) &&
+                                distanceY > 0) &&
+                                if (Flags.hubmodeFullscreenVerticalSwipe()) touchAvailable else true
                         } else {
                             // If the user scrolling favors a vertical direction, begin capturing
                             // scrolls.
@@ -163,6 +174,21 @@
             }
         }
 
+    init {
+        if (Flags.hubmodeFullscreenVerticalSwipe()) {
+            scope.launch {
+                communalViewModel.glanceableTouchAvailable.collect {
+                    onGlanceableTouchAvailable(it)
+                }
+            }
+        }
+    }
+
+    @VisibleForTesting
+    fun onGlanceableTouchAvailable(available: Boolean) {
+        touchAvailable = available
+    }
+
     private fun setPanelExpansion(expansion: Float) {
         currentExpansion = expansion
         val event =
@@ -191,6 +217,12 @@
         val minAllowableBottom = Math.round(height * (1 - minBouncerZoneScreenPercentage))
         val normalRegion =
             Rect(0, Math.round(height * (1 - bouncerZoneScreenPercentage)), width, height)
+
+        if (Flags.hubmodeFullscreenVerticalSwipe()) {
+            region.op(bounds, Region.Op.UNION)
+            exclusionRect?.apply { region.op(this, Region.Op.DIFFERENCE) }
+        }
+
         if (exclusionRect != null) {
             val lowestBottom =
                 min(max(0.0, exclusionRect.bottom.toDouble()), minAllowableBottom.toDouble())
@@ -233,6 +265,9 @@
         when (motionEvent.action) {
             MotionEvent.ACTION_CANCEL,
             MotionEvent.ACTION_UP -> {
+                if (Flags.hubmodeFullscreenVerticalSwipe() && capture == true) {
+                    communalViewModel.onResetTouchState()
+                }
                 touchSession?.apply { pop() }
                 // If we are not capturing any input, there is no need to consider animating to
                 // finish transition.
diff --git a/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.kt b/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.kt
index 87f02aa..06b41de 100644
--- a/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.kt
+++ b/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.kt
@@ -21,15 +21,20 @@
 import android.view.GestureDetector.SimpleOnGestureListener
 import android.view.InputEvent
 import android.view.MotionEvent
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.Flags
 import com.android.systemui.ambient.touch.TouchHandler.TouchSession
 import com.android.systemui.ambient.touch.dagger.ShadeModule
 import com.android.systemui.communal.domain.interactor.CommunalSettingsInteractor
+import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
 import com.android.systemui.shade.ShadeViewController
 import com.android.systemui.statusbar.phone.CentralSurfaces
 import java.util.Optional
 import javax.inject.Inject
 import javax.inject.Named
 import kotlin.math.abs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
 
 /**
  * [ShadeTouchHandler] is responsible for handling swipe down gestures over dream to bring down the
@@ -38,9 +43,11 @@
 class ShadeTouchHandler
 @Inject
 constructor(
+    scope: CoroutineScope,
     private val surfaces: Optional<CentralSurfaces>,
     private val shadeViewController: ShadeViewController,
     private val dreamManager: DreamManager,
+    private val communalViewModel: CommunalViewModel,
     private val communalSettingsInteractor: CommunalSettingsInteractor,
     @param:Named(ShadeModule.NOTIFICATION_SHADE_GESTURE_INITIATION_HEIGHT)
     private val initiationHeight: Int
@@ -50,6 +57,24 @@
      */
     private var capture: Boolean? = null
 
+    /** Determines whether the touch handler should process touches in fullscreen swiping mode */
+    private var touchAvailable = false
+
+    init {
+        if (Flags.hubmodeFullscreenVerticalSwipe()) {
+            scope.launch {
+                communalViewModel.glanceableTouchAvailable.collect {
+                    onGlanceableTouchAvailable(it)
+                }
+            }
+        }
+    }
+
+    @VisibleForTesting
+    fun onGlanceableTouchAvailable(available: Boolean) {
+        touchAvailable = available
+    }
+
     override fun onSessionStart(session: TouchSession) {
         if (surfaces.isEmpty) {
             session.pop()
@@ -62,6 +87,9 @@
                     sendTouchEvent(ev)
                 }
                 if (ev.action == MotionEvent.ACTION_UP || ev.action == MotionEvent.ACTION_CANCEL) {
+                    if (capture == true) {
+                        communalViewModel.onResetTouchState()
+                    }
                     session.pop()
                 }
             }
@@ -77,7 +105,9 @@
                     if (capture == null) {
                         // Only capture swipes that are going downwards.
                         capture =
-                            abs(distanceY.toDouble()) > abs(distanceX.toDouble()) && distanceY < 0
+                            abs(distanceY.toDouble()) > abs(distanceX.toDouble()) &&
+                                distanceY < 0 &&
+                                if (Flags.hubmodeFullscreenVerticalSwipe()) touchAvailable else true
                         if (capture == true) {
                             // Send the initial touches over, as the input listener has already
                             // processed these touches.
@@ -112,7 +142,14 @@
         }
     }
 
-    override fun getTouchInitiationRegion(bounds: Rect, region: Region, exclusionRect: Rect) {
+    override fun getTouchInitiationRegion(bounds: Rect, region: Region, exclusionRect: Rect?) {
+        // If fullscreen swipe, use entire space minus exclusion region
+        if (Flags.hubmodeFullscreenVerticalSwipe()) {
+            region.op(bounds, Region.Op.UNION)
+
+            exclusionRect?.apply { region.op(this, Region.Op.DIFFERENCE) }
+        }
+
         val outBounds = Rect(bounds)
         outBounds.inset(0, 0, 0, outBounds.height() - initiationHeight)
         region.op(outBounds, Region.Op.UNION)
diff --git a/packages/SystemUI/src/com/android/systemui/ambient/touch/dagger/AmbientTouchModule.kt b/packages/SystemUI/src/com/android/systemui/ambient/touch/dagger/AmbientTouchModule.kt
index a4924d1..ae21e56 100644
--- a/packages/SystemUI/src/com/android/systemui/ambient/touch/dagger/AmbientTouchModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/ambient/touch/dagger/AmbientTouchModule.kt
@@ -17,12 +17,14 @@
 
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.coroutineScope
 import com.android.systemui.ambient.dagger.AmbientModule
 import com.android.systemui.ambient.touch.TouchHandler
 import dagger.Module
 import dagger.Provides
 import dagger.multibindings.ElementsIntoSet
 import javax.inject.Named
+import kotlinx.coroutines.CoroutineScope
 
 @Module
 interface AmbientTouchModule {
@@ -33,6 +35,12 @@
             return lifecycleOwner.lifecycle
         }
 
+        @JvmStatic
+        @Provides
+        fun providesLifecycleScope(lifecycle: Lifecycle): CoroutineScope {
+            return lifecycle.coroutineScope
+        }
+
         @Provides
         @ElementsIntoSet
         fun providesDreamTouchHandlers(
diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt
index b8601ec..4be93cc 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt
@@ -29,9 +29,12 @@
 import com.android.systemui.communal.widgets.WidgetConfigurator
 import com.android.systemui.keyguard.shared.model.KeyguardState
 import com.android.systemui.media.controls.ui.view.MediaHost
+import com.android.systemui.util.kotlin.BooleanFlowOperators.anyOf
+import com.android.systemui.util.kotlin.BooleanFlowOperators.not
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.flowOf
 
 /** The base view model for the communal hub. */
@@ -57,6 +60,26 @@
     val selectedKey: StateFlow<String?>
         get() = _selectedKey
 
+    private val _isTouchConsumed: MutableStateFlow<Boolean> = MutableStateFlow(false)
+
+    /** Whether an element inside the lazy grid is actively consuming touches */
+    val isTouchConsumed: Flow<Boolean> = _isTouchConsumed.asStateFlow()
+
+    private val _isNestedScrolling: MutableStateFlow<Boolean> = MutableStateFlow(false)
+
+    /** Whether the lazy grid is reporting scrolling within itself */
+    val isNestedScrolling: Flow<Boolean> = _isNestedScrolling.asStateFlow()
+
+    /**
+     * Whether touch is available to be consumed by a touch handler. Touch is available during
+     * nested scrolling as lazy grid reports this for all scroll directions that it detects. In the
+     * case that there is consumed scrolling on a nested element, such as an AndroidView, no nested
+     * scrolling will be reported. It is up to the flow consumer to determine whether the nested
+     * scroll can be applied. In the communal case, this would be identifying the scroll as
+     * vertical, which the lazy horizontal grid does not handle.
+     */
+    val glanceableTouchAvailable: Flow<Boolean> = anyOf(not(isTouchConsumed), isNestedScrolling)
+
     /** Accessibility delegate to be set on CommunalAppWidgetHostView. */
     open val widgetAccessibilityDelegate: View.AccessibilityDelegate? = null
 
@@ -200,4 +223,28 @@
     fun setSelectedKey(key: String?) {
         _selectedKey.value = key
     }
+
+    /** Invoked once touches inside the lazy grid are consumed */
+    fun onHubTouchConsumed() {
+        if (_isTouchConsumed.value) {
+            return
+        }
+
+        _isTouchConsumed.value = true
+    }
+
+    /** Invoked when nested scrolling begins on the lazy grid */
+    fun onNestedScrolling() {
+        if (_isNestedScrolling.value) {
+            return
+        }
+
+        _isNestedScrolling.value = true
+    }
+
+    /** Resets nested scroll and touch consumption state */
+    fun onResetTouchState() {
+        _isTouchConsumed.value = false
+        _isNestedScrolling.value = false
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt b/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt
index b468d0e..25d1cd1 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt
@@ -39,6 +39,7 @@
 import androidx.lifecycle.repeatOnLifecycle
 import com.android.compose.theme.PlatformTheme
 import com.android.internal.annotations.VisibleForTesting
+import com.android.systemui.Flags
 import com.android.systemui.Flags.glanceableHubBackGesture
 import com.android.systemui.ambient.touch.TouchMonitor
 import com.android.systemui.ambient.touch.dagger.AmbientTouchComponent
@@ -294,24 +295,37 @@
                     }
 
                 containerView.systemGestureExclusionRects =
-                    listOf(
-                        // Only allow swipe up to bouncer and swipe down to shade in the very
-                        // top/bottom to avoid conflicting with widgets in the hub grid.
-                        Rect(
-                            insets.left,
-                            topEdgeSwipeRegionWidth,
-                            containerView.right - insets.right,
-                            containerView.bottom - bottomEdgeSwipeRegionWidth
-                        ),
-                        // Disable back gestures on the left side of the screen, to avoid
-                        // conflicting with scene transitions.
-                        Rect(
-                            0,
-                            0,
-                            insets.right,
-                            containerView.bottom,
+                    if (Flags.hubmodeFullscreenVerticalSwipe()) {
+                        listOf(
+                            // Disable back gestures on the left side of the screen, to avoid
+                            // conflicting with scene transitions.
+                            Rect(
+                                0,
+                                0,
+                                insets.right,
+                                containerView.bottom,
+                            )
                         )
-                    )
+                    } else {
+                        listOf(
+                            // Only allow swipe up to bouncer and swipe down to shade in the very
+                            // top/bottom to avoid conflicting with widgets in the hub grid.
+                            Rect(
+                                insets.left,
+                                topEdgeSwipeRegionWidth,
+                                containerView.right - insets.right,
+                                containerView.bottom - bottomEdgeSwipeRegionWidth
+                            ),
+                            // Disable back gestures on the left side of the screen, to avoid
+                            // conflicting with scene transitions.
+                            Rect(
+                                0,
+                                0,
+                                insets.right,
+                                containerView.bottom,
+                            )
+                        )
+                    }
             }
         }