Merge changes I92b9c220,I31a55ca1,I536a2e1e into main

* changes:
  Fix ShadeTouchHandler over the lock screen
  Use TouchMonitor for touch handling over hub
  Stop overlay touch handling when the bouncer or glanceable hub are visible over the dream
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 04c4efb..fefe5a0 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
@@ -149,7 +149,6 @@
                 mUiEventLogger);
 
         when(mScrimManager.getCurrentController()).thenReturn(mScrimController);
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(false);
         when(mValueAnimatorCreator.create(anyFloat(), anyFloat())).thenReturn(mValueAnimator);
         when(mVelocityTrackerFactory.obtain()).thenReturn(mVelocityTracker);
         when(mFlingAnimationUtils.getMinVelocityPxPerSecond()).thenReturn(Float.MAX_VALUE);
@@ -193,11 +192,6 @@
                         2)).isTrue();
     }
 
-    private enum Direction {
-        DOWN,
-        UP,
-    }
-
     @Test
     public void testSwipeUp_whenBouncerInitiallyShowing_reduceHeightWithExclusionRects() {
         mTouchHandler.getTouchInitiationRegion(SCREEN_BOUNDS, mRegion,
@@ -210,7 +204,7 @@
                 SCREEN_HEIGHT_PX * MIN_BOUNCER_HEIGHT;
         final int minAllowableBottom = SCREEN_HEIGHT_PX - Math.round(minBouncerHeight);
 
-        expected.set(0, minAllowableBottom , SCREEN_WIDTH_PX, SCREEN_HEIGHT_PX);
+        expected.set(0, minAllowableBottom, SCREEN_WIDTH_PX, SCREEN_HEIGHT_PX);
 
         assertThat(bounds).isEqualTo(expected);
 
@@ -278,69 +272,11 @@
     }
 
     /**
-     * Makes sure swiping up when bouncer initially showing doesn't change the expansion amount.
-     */
-    @DisableFlags(Flags.FLAG_DREAM_OVERLAY_BOUNCER_SWIPE_DIRECTION_FILTERING)
-    @Test
-    public void testSwipeUp_whenBouncerInitiallyShowing_doesNotSetExpansion() {
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
-
-        mTouchHandler.onSessionStart(mTouchSession);
-        ArgumentCaptor<GestureDetector.OnGestureListener> gestureListenerCaptor =
-                ArgumentCaptor.forClass(GestureDetector.OnGestureListener.class);
-        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
-
-        final OnGestureListener gestureListener = gestureListenerCaptor.getValue();
-
-        final float percent = .3f;
-        final float distanceY = SCREEN_HEIGHT_PX * percent;
-
-        // Swiping up near the top of the screen where the touch initiation region is.
-        final MotionEvent event1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, distanceY, 0);
-        final MotionEvent event2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, 0, 0);
-
-        assertThat(gestureListener.onScroll(event1, event2, 0, distanceY)).isTrue();
-
-        verify(mScrimController, never()).expand(any());
-    }
-
-    /**
-     * Makes sure swiping up when bouncer initially showing doesn't change the expansion amount.
-     */
-    @Test
-    @EnableFlags(Flags.FLAG_DREAM_OVERLAY_BOUNCER_SWIPE_DIRECTION_FILTERING)
-    public void testSwipeUp_whenBouncerInitiallyShowing_doesNotSetExpansion_directionFiltering() {
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
-
-        mTouchHandler.onSessionStart(mTouchSession);
-        ArgumentCaptor<GestureDetector.OnGestureListener> gestureListenerCaptor =
-                ArgumentCaptor.forClass(GestureDetector.OnGestureListener.class);
-        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
-
-        final OnGestureListener gestureListener = gestureListenerCaptor.getValue();
-
-        final float percent = .3f;
-        final float distanceY = SCREEN_HEIGHT_PX * percent;
-
-        // Swiping up near the top of the screen where the touch initiation region is.
-        final MotionEvent event1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, distanceY, 0);
-        final MotionEvent event2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, 0, 0);
-
-        assertThat(gestureListener.onScroll(event1, event2, 0, distanceY)).isFalse();
-
-        verify(mScrimController, never()).expand(any());
-    }
-
-    /**
-     * Makes sure swiping down when bouncer initially hidden doesn't change the expansion amount.
+     * Makes sure swiping down doesn't change the expansion amount.
      */
     @Test
     @DisableFlags(Flags.FLAG_DREAM_OVERLAY_BOUNCER_SWIPE_DIRECTION_FILTERING)
-    public void testSwipeDown_whenBouncerInitiallyHidden_doesNotSetExpansion() {
+    public void testSwipeDown_doesNotSetExpansion() {
         mTouchHandler.onSessionStart(mTouchSession);
         ArgumentCaptor<GestureDetector.OnGestureListener> gestureListenerCaptor =
                 ArgumentCaptor.forClass(GestureDetector.OnGestureListener.class);
@@ -401,34 +337,8 @@
 
         final OnGestureListener gestureListener = gestureListenerCaptor.getValue();
 
-        verifyScroll(.3f, Direction.UP, false, gestureListener);
-
-        // Ensure that subsequent gestures are treated as expanding even if the bouncer state
-        // changes.
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
-        verifyScroll(.7f, Direction.UP, false, gestureListener);
-    }
-
-    /**
-     * Makes sure the expansion amount is proportional to scroll.
-     */
-    @Test
-    public void testSwipeDown_setsCorrectExpansionAmount() {
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
-
-        mTouchHandler.onSessionStart(mTouchSession);
-        ArgumentCaptor<GestureDetector.OnGestureListener> gestureListenerCaptor =
-                ArgumentCaptor.forClass(GestureDetector.OnGestureListener.class);
-        verify(mTouchSession).registerGestureListener(gestureListenerCaptor.capture());
-
-        final OnGestureListener gestureListener = gestureListenerCaptor.getValue();
-
-        verifyScroll(.3f, Direction.DOWN, true, gestureListener);
-
-        // Ensure that subsequent gestures are treated as collapsing even if the bouncer state
-        // changes.
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(false);
-        verifyScroll(.7f, Direction.DOWN, true, gestureListener);
+        verifyScroll(.3f, gestureListener);
+        verifyScroll(.7f, gestureListener);
     }
 
     /**
@@ -493,25 +403,24 @@
         verify(mCentralSurfaces, never()).awakenDreams();
     }
 
-    private void verifyScroll(float percent, Direction direction,
-            boolean isBouncerInitiallyShowing, GestureDetector.OnGestureListener gestureListener) {
+    private void verifyScroll(float percent,
+            OnGestureListener gestureListener) {
         final float distanceY = SCREEN_HEIGHT_PX * percent;
 
         final MotionEvent event1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, direction == Direction.UP ? SCREEN_HEIGHT_PX : 0, 0);
+                0, SCREEN_HEIGHT_PX, 0);
         final MotionEvent event2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, direction == Direction.UP ? SCREEN_HEIGHT_PX - distanceY : distanceY, 0);
+                0, SCREEN_HEIGHT_PX - distanceY, 0);
 
         reset(mScrimController);
         assertThat(gestureListener.onScroll(event1, event2, 0,
-                direction == Direction.UP ? distanceY : -distanceY))
+                distanceY))
                 .isTrue();
 
         // Ensure only called once
         verify(mScrimController).expand(any());
 
-        final float expansion = isBouncerInitiallyShowing ? percent : 1 - percent;
-        final float dragDownAmount = event2.getY() - event1.getY();
+        final float expansion = 1 - percent;
 
         // Ensure correct expansion passed in.
         ShadeExpansionChangeEvent event =
@@ -529,7 +438,7 @@
         final float expansion = 1 - swipeUpPercentage;
         // The upward velocity is ignored.
         final float velocityY = -1;
-        swipeToPosition(swipeUpPercentage, Direction.UP, velocityY);
+        swipeToPosition(swipeUpPercentage, velocityY);
 
         verify(mValueAnimatorCreator).create(eq(expansion),
                 eq(KeyguardBouncerConstants.EXPANSION_HIDDEN));
@@ -552,7 +461,7 @@
         final float expansion = 1 - swipeUpPercentage;
         // The downward velocity is ignored.
         final float velocityY = 1;
-        swipeToPosition(swipeUpPercentage, Direction.UP, velocityY);
+        swipeToPosition(swipeUpPercentage, velocityY);
 
         verify(mValueAnimatorCreator).create(eq(expansion),
                 eq(KeyguardBouncerConstants.EXPANSION_VISIBLE));
@@ -573,57 +482,6 @@
     }
 
     /**
-     * Tests that ending a downward swipe above the set threshold will continue the expansion,
-     * but will not trigger logging of the DREAM_SWIPED event.
-     */
-    @Test
-    public void testSwipeDownPositionAboveThreshold_expandsBouncer_doesNotLog() {
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
-
-        final float swipeDownPercentage = .3f;
-        // The downward velocity is ignored.
-        final float velocityY = 1;
-        swipeToPosition(swipeDownPercentage, Direction.DOWN, velocityY);
-
-        verify(mValueAnimatorCreator).create(eq(swipeDownPercentage),
-                eq(KeyguardBouncerConstants.EXPANSION_VISIBLE));
-        verify(mValueAnimator, never()).addListener(any());
-
-        verify(mFlingAnimationUtils).apply(eq(mValueAnimator),
-                eq(SCREEN_HEIGHT_PX * swipeDownPercentage),
-                eq(SCREEN_HEIGHT_PX * KeyguardBouncerConstants.EXPANSION_VISIBLE),
-                eq(velocityY), eq((float) SCREEN_HEIGHT_PX));
-        verify(mValueAnimator).start();
-        verify(mUiEventLogger, never()).log(any());
-    }
-
-    /**
-     * Tests that swiping down with a speed above the set threshold leads to bouncer collapsing
-     * down.
-     */
-    @Test
-    public void testSwipeDownVelocityAboveMin_collapsesBouncer() {
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
-        when(mFlingAnimationUtils.getMinVelocityPxPerSecond()).thenReturn((float) 0);
-
-        // The ending position above the set threshold is ignored.
-        final float swipeDownPercentage = .3f;
-        final float velocityY = 1;
-        swipeToPosition(swipeDownPercentage, Direction.DOWN, velocityY);
-
-        verify(mValueAnimatorCreator).create(eq(swipeDownPercentage),
-                eq(KeyguardBouncerConstants.EXPANSION_HIDDEN));
-        verify(mValueAnimator, never()).addListener(any());
-
-        verify(mFlingAnimationUtilsClosing).apply(eq(mValueAnimator),
-                eq(SCREEN_HEIGHT_PX * swipeDownPercentage),
-                eq(SCREEN_HEIGHT_PX * KeyguardBouncerConstants.EXPANSION_HIDDEN),
-                eq(velocityY), eq((float) SCREEN_HEIGHT_PX));
-        verify(mValueAnimator).start();
-        verify(mUiEventLogger, never()).log(any());
-    }
-
-    /**
      * Tests that swiping up with a speed above the set threshold will continue the expansion.
      */
     @Test
@@ -634,7 +492,7 @@
         final float swipeUpPercentage = .3f;
         final float expansion = 1 - swipeUpPercentage;
         final float velocityY = -1;
-        swipeToPosition(swipeUpPercentage, Direction.UP, velocityY);
+        swipeToPosition(swipeUpPercentage, velocityY);
 
         verify(mValueAnimatorCreator).create(eq(expansion),
                 eq(KeyguardBouncerConstants.EXPANSION_VISIBLE));
@@ -654,26 +512,6 @@
         verify(mUiEventLogger).log(BouncerSwipeTouchHandler.DreamEvent.DREAM_BOUNCER_FULLY_VISIBLE);
     }
 
-    /**
-     * Ensures {@link CentralSurfaces}
-     */
-    @Test
-    public void testInformBouncerShowingOnExpand() {
-        swipeToPosition(1f, Direction.UP, 0);
-    }
-
-    /**
-     * Ensures {@link CentralSurfaces}
-     */
-    @Test
-    public void testInformBouncerHidingOnCollapse() {
-        // Must swipe up to set initial state.
-        swipeToPosition(1f, Direction.UP, 0);
-        Mockito.clearInvocations(mCentralSurfaces);
-
-        swipeToPosition(0f, Direction.DOWN, 0);
-    }
-
     @Test
     public void testTouchSessionOnRemovedCalledTwice() {
         mTouchHandler.onSessionStart(mTouchSession);
@@ -684,7 +522,7 @@
         onRemovedCallbackCaptor.getValue().onRemoved();
     }
 
-    private void swipeToPosition(float percent, Direction direction, float velocityY) {
+    private void swipeToPosition(float percent, float velocityY) {
         Mockito.clearInvocations(mTouchSession);
         mTouchHandler.onSessionStart(mTouchSession);
         ArgumentCaptor<GestureDetector.OnGestureListener> gestureListenerCaptor =
@@ -699,12 +537,12 @@
         final float distanceY = SCREEN_HEIGHT_PX * percent;
 
         final MotionEvent event1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, direction == Direction.UP ? SCREEN_HEIGHT_PX : 0, 0);
+                0, SCREEN_HEIGHT_PX, 0);
         final MotionEvent event2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
-                0, direction == Direction.UP ? SCREEN_HEIGHT_PX - distanceY : distanceY, 0);
+                0, SCREEN_HEIGHT_PX - distanceY, 0);
 
         assertThat(gestureListenerCaptor.getValue().onScroll(event1, event2, 0,
-                direction == Direction.UP ? distanceY : -distanceY))
+                distanceY))
                 .isTrue();
 
         final MotionEvent upEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP,
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.java
index 27bffd0..11a4241 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/ambient/touch/ShadeTouchHandlerTest.java
@@ -18,8 +18,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
 
 import android.view.GestureDetector;
 import android.view.MotionEvent;
@@ -28,7 +30,6 @@
 import androidx.test.filters.SmallTest;
 
 import com.android.systemui.SysuiTestCase;
-import com.android.systemui.shade.ShadeViewController;
 import com.android.systemui.shared.system.InputChannelCompat;
 import com.android.systemui.statusbar.phone.CentralSurfaces;
 
@@ -36,6 +37,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
@@ -49,66 +51,89 @@
     CentralSurfaces mCentralSurfaces;
 
     @Mock
-    ShadeViewController mShadeViewController;
-
-    @Mock
     TouchHandler.TouchSession mTouchSession;
 
     ShadeTouchHandler mTouchHandler;
 
+    @Captor
+    ArgumentCaptor<GestureDetector.OnGestureListener> mGestureListenerCaptor;
+    @Captor
+    ArgumentCaptor<InputChannelCompat.InputEventListener> mInputListenerCaptor;
+
     private static final int TOUCH_HEIGHT = 20;
 
     @Before
     public void setup() {
         MockitoAnnotations.initMocks(this);
-        mTouchHandler = new ShadeTouchHandler(Optional.of(mCentralSurfaces), mShadeViewController,
-                TOUCH_HEIGHT);
+
+        mTouchHandler = new ShadeTouchHandler(Optional.of(mCentralSurfaces), TOUCH_HEIGHT);
+    }
+
+    // Verifies that a swipe down in the gesture region is captured by the shade touch handler.
+    @Test
+    public void testSwipeDown_captured() {
+        final boolean captured = swipe(Direction.DOWN);
+
+        assertThat(captured).isTrue();
+    }
+
+    // Verifies that a swipe in the upward direction is not catpured.
+    @Test
+    public void testSwipeUp_notCaptured() {
+        final boolean captured = swipe(Direction.UP);
+
+        // Motion events not captured as the swipe is going in the wrong direction.
+        assertThat(captured).isFalse();
+    }
+
+    // Verifies that a swipe down forwards captured touches to the shade window for handling.
+    @Test
+    public void testSwipeDown_sentToShadeWindow() {
+        swipe(Direction.DOWN);
+
+        // Both motion events are sent for the shade window to process.
+        verify(mCentralSurfaces, times(2)).handleExternalShadeWindowTouch(any());
+    }
+
+    // Verifies that a swipe down is not forwarded to the shade window.
+    @Test
+    public void testSwipeUp_touchesNotSent() {
+        swipe(Direction.UP);
+
+        // Motion events are not sent for the shade window to process as the swipe is going in the
+        // wrong direction.
+        verify(mCentralSurfaces, never()).handleExternalShadeWindowTouch(any());
     }
 
     /**
-     * Verify that touches aren't handled when the bouncer is showing.
+     * Simulates a swipe in the given direction and returns true if the touch was intercepted by the
+     * touch handler's gesture listener.
+     * <p>
+     * Swipe down starts from a Y coordinate of 0 and goes downward. Swipe up starts from the edge
+     * of the gesture region, {@link #TOUCH_HEIGHT}, and goes upward to 0.
      */
-    @Test
-    public void testInactiveOnBouncer() {
-        when(mCentralSurfaces.isBouncerShowing()).thenReturn(true);
+    private boolean swipe(Direction direction) {
+        Mockito.clearInvocations(mTouchSession);
         mTouchHandler.onSessionStart(mTouchSession);
-        verify(mTouchSession).pop();
+
+        verify(mTouchSession).registerGestureListener(mGestureListenerCaptor.capture());
+        verify(mTouchSession).registerInputListener(mInputListenerCaptor.capture());
+
+        final float startY = direction == Direction.UP ? TOUCH_HEIGHT : 0;
+        final float endY = direction == Direction.UP ? 0 : TOUCH_HEIGHT;
+
+        // Send touches to the input and gesture listener.
+        final MotionEvent event1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0, startY, 0);
+        final MotionEvent event2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0, endY, 0);
+        mInputListenerCaptor.getValue().onInputEvent(event1);
+        mInputListenerCaptor.getValue().onInputEvent(event2);
+        final boolean captured = mGestureListenerCaptor.getValue().onScroll(event1, event2, 0,
+                startY - endY);
+
+        return captured;
     }
 
-    /**
-     * Make sure {@link ShadeTouchHandler}
-     */
-    @Test
-    public void testTouchPilferingOnScroll() {
-        final MotionEvent motionEvent1 = Mockito.mock(MotionEvent.class);
-        final MotionEvent motionEvent2 = Mockito.mock(MotionEvent.class);
-
-        final ArgumentCaptor<GestureDetector.OnGestureListener> gestureListenerArgumentCaptor =
-                ArgumentCaptor.forClass(GestureDetector.OnGestureListener.class);
-
-        mTouchHandler.onSessionStart(mTouchSession);
-        verify(mTouchSession).registerGestureListener(gestureListenerArgumentCaptor.capture());
-
-        assertThat(gestureListenerArgumentCaptor.getValue()
-                .onScroll(motionEvent1, motionEvent2, 1, 1))
-                .isTrue();
+    private enum Direction {
+        DOWN, UP,
     }
-
-    /**
-     * Ensure touches are propagated to the {@link ShadeViewController}.
-     */
-    @Test
-    public void testEventPropagation() {
-        final MotionEvent motionEvent = Mockito.mock(MotionEvent.class);
-
-        final ArgumentCaptor<InputChannelCompat.InputEventListener>
-                inputEventListenerArgumentCaptor =
-                    ArgumentCaptor.forClass(InputChannelCompat.InputEventListener.class);
-
-        mTouchHandler.onSessionStart(mTouchSession);
-        verify(mTouchSession).registerInputListener(inputEventListenerArgumentCaptor.capture());
-        inputEventListenerArgumentCaptor.getValue().onInputEvent(motionEvent);
-        verify(mShadeViewController).handleExternalTouch(motionEvent);
-    }
-
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt
index 6a8ab39..bdb0c9a 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt
@@ -17,41 +17,52 @@
 
 import android.content.ComponentName
 import android.content.Intent
-import android.os.RemoteException
 import android.service.dreams.IDreamOverlay
 import android.service.dreams.IDreamOverlayCallback
 import android.service.dreams.IDreamOverlayClient
 import android.service.dreams.IDreamOverlayClientCallback
+import android.testing.TestableLooper
 import android.view.View
 import android.view.ViewGroup
 import android.view.WindowManager
 import android.view.WindowManagerImpl
 import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LifecycleRegistry
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
+import com.android.compose.animation.scene.ObservableTransitionState
 import com.android.internal.logging.UiEventLogger
 import com.android.keyguard.KeyguardUpdateMonitor
+import com.android.keyguard.KeyguardUpdateMonitorCallback
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.ambient.touch.TouchMonitor
 import com.android.systemui.ambient.touch.dagger.AmbientTouchComponent
 import com.android.systemui.ambient.touch.scrim.ScrimController
 import com.android.systemui.ambient.touch.scrim.ScrimManager
-import com.android.systemui.communal.domain.interactor.CommunalInteractor
+import com.android.systemui.bouncer.data.repository.FakeKeyguardBouncerRepository
+import com.android.systemui.bouncer.data.repository.fakeKeyguardBouncerRepository
+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.shared.model.CommunalScenes
 import com.android.systemui.complication.ComplicationHostViewController
 import com.android.systemui.complication.ComplicationLayoutEngine
 import com.android.systemui.complication.dagger.ComplicationComponent
 import com.android.systemui.dreams.complication.HideComplicationTouchHandler
 import com.android.systemui.dreams.dagger.DreamOverlayComponent
+import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.testKosmos
 import com.android.systemui.touch.TouchInsetManager
 import com.android.systemui.util.concurrency.FakeExecutor
 import com.android.systemui.util.mockito.any
-import com.android.systemui.util.mockito.eq
-import com.android.systemui.util.mockito.nullable
 import com.android.systemui.util.mockito.whenever
 import com.android.systemui.util.time.FakeSystemClock
-import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runCurrent
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -59,20 +70,24 @@
 import org.mockito.Captor
 import org.mockito.Mock
 import org.mockito.Mockito
+import org.mockito.Mockito.verify
 import org.mockito.MockitoAnnotations
-import org.mockito.invocation.InvocationOnMock
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @SmallTest
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
 @RunWith(AndroidJUnit4::class)
 class DreamOverlayServiceTest : SysuiTestCase() {
     private val mFakeSystemClock = FakeSystemClock()
     private val mMainExecutor = FakeExecutor(mFakeSystemClock)
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
 
     @Mock lateinit var mLifecycleOwner: DreamOverlayLifecycleOwner
 
-    @Mock lateinit var mLifecycleRegistry: LifecycleRegistry
+    private lateinit var lifecycleRegistry: FakeLifecycleRegistry
 
-    lateinit var mWindowParams: WindowManager.LayoutParams
+    private lateinit var mWindowParams: WindowManager.LayoutParams
 
     @Mock lateinit var mDreamOverlayCallback: IDreamOverlayCallback
 
@@ -124,22 +139,29 @@
 
     @Mock lateinit var mScrimController: ScrimController
 
-    @Mock lateinit var mCommunalInteractor: CommunalInteractor
-
     @Mock lateinit var mSystemDialogsCloser: SystemDialogsCloser
 
     @Mock lateinit var mDreamOverlayCallbackController: DreamOverlayCallbackController
 
+    private lateinit var bouncerRepository: FakeKeyguardBouncerRepository
+    private lateinit var communalRepository: FakeCommunalRepository
+
     @Captor var mViewCaptor: ArgumentCaptor<View>? = null
-    var mService: DreamOverlayService? = null
+    private lateinit var mService: DreamOverlayService
+
     @Before
     fun setup() {
         MockitoAnnotations.initMocks(this)
+
+        lifecycleRegistry = FakeLifecycleRegistry(mLifecycleOwner)
+        bouncerRepository = kosmos.fakeKeyguardBouncerRepository
+        communalRepository = kosmos.fakeCommunalRepository
+
         whenever(mDreamOverlayComponent.getDreamOverlayContainerViewController())
             .thenReturn(mDreamOverlayContainerViewController)
         whenever(mComplicationComponent.getComplicationHostViewController())
             .thenReturn(mComplicationHostViewController)
-        whenever(mLifecycleOwner.registry).thenReturn(mLifecycleRegistry)
+        whenever(mLifecycleOwner.registry).thenReturn(lifecycleRegistry)
         whenever(mComplicationComponentFactory.create(any(), any(), any(), any()))
             .thenReturn(mComplicationComponent)
         whenever(mComplicationComponent.getVisibilityController())
@@ -170,26 +192,29 @@
                 mStateController,
                 mKeyguardUpdateMonitor,
                 mScrimManager,
-                mCommunalInteractor,
+                kosmos.communalInteractor,
                 mSystemDialogsCloser,
                 mUiEventLogger,
                 mTouchInsetManager,
                 LOW_LIGHT_COMPONENT,
                 HOME_CONTROL_PANEL_DREAM_COMPONENT,
                 mDreamOverlayCallbackController,
+                kosmos.keyguardInteractor,
                 WINDOW_NAME
             )
     }
 
-    @get:Throws(RemoteException::class)
-    val client: IDreamOverlayClient
+    private val client: IDreamOverlayClient
         get() {
-            val proxy = mService!!.onBind(Intent())
+            mService.onCreate()
+            TestableLooper.get(this).processAllMessages()
+
+            val proxy = mService.onBind(Intent())
             val overlay = IDreamOverlay.Stub.asInterface(proxy)
             val callback = Mockito.mock(IDreamOverlayClientCallback::class.java)
             overlay.getClient(callback)
             val clientCaptor = ArgumentCaptor.forClass(IDreamOverlayClient::class.java)
-            Mockito.verify(callback).onDreamOverlayClient(clientCaptor.capture())
+            verify(callback).onDreamOverlayClient(clientCaptor.capture())
             return clientCaptor.value
         }
 
@@ -205,9 +230,8 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Mockito.verify(mUiEventLogger)
-            .log(DreamOverlayService.DreamOverlayEvent.DREAM_OVERLAY_ENTER_START)
-        Mockito.verify(mUiEventLogger)
+        verify(mUiEventLogger).log(DreamOverlayService.DreamOverlayEvent.DREAM_OVERLAY_ENTER_START)
+        verify(mUiEventLogger)
             .log(DreamOverlayService.DreamOverlayEvent.DREAM_OVERLAY_COMPLETE_START)
     }
 
@@ -223,7 +247,7 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Mockito.verify(mWindowManager).addView(any(), any())
+        verify(mWindowManager).addView(any(), any())
     }
 
     // Validates that {@link DreamOverlayService} properly handles the case where the dream's
@@ -242,14 +266,14 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Mockito.verify(mWindowManager).addView(any(), any())
-        Mockito.verify(mStateController).setOverlayActive(false)
-        Mockito.verify(mStateController).setLowLightActive(false)
-        Mockito.verify(mStateController).setEntryAnimationsFinished(false)
-        Mockito.verify(mStateController, Mockito.never()).setOverlayActive(true)
-        Mockito.verify(mUiEventLogger, Mockito.never())
+        verify(mWindowManager).addView(any(), any())
+        verify(mStateController).setOverlayActive(false)
+        verify(mStateController).setLowLightActive(false)
+        verify(mStateController).setEntryAnimationsFinished(false)
+        verify(mStateController, Mockito.never()).setOverlayActive(true)
+        verify(mUiEventLogger, Mockito.never())
             .log(DreamOverlayService.DreamOverlayEvent.DREAM_OVERLAY_COMPLETE_START)
-        Mockito.verify(mDreamOverlayCallbackController, Mockito.never()).onStartDream()
+        verify(mDreamOverlayCallbackController, Mockito.never()).onStartDream()
     }
 
     @Test
@@ -264,7 +288,7 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Mockito.verify(mDreamOverlayContainerViewController).init()
+        verify(mDreamOverlayContainerViewController).init()
     }
 
     @Test
@@ -282,7 +306,7 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Mockito.verify(mDreamOverlayContainerViewParent).removeView(mDreamOverlayContainerView)
+        verify(mDreamOverlayContainerViewParent).removeView(mDreamOverlayContainerView)
     }
 
     @Test
@@ -297,7 +321,7 @@
             true /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Truth.assertThat(mService!!.shouldShowComplications()).isTrue()
+        assertThat(mService.shouldShowComplications()).isTrue()
     }
 
     @Test
@@ -312,8 +336,8 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Truth.assertThat(mService!!.dreamComponent).isEqualTo(LOW_LIGHT_COMPONENT)
-        Mockito.verify(mStateController).setLowLightActive(true)
+        assertThat(mService.dreamComponent).isEqualTo(LOW_LIGHT_COMPONENT)
+        verify(mStateController).setLowLightActive(true)
     }
 
     @Test
@@ -328,8 +352,8 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Truth.assertThat(mService!!.dreamComponent).isEqualTo(HOME_CONTROL_PANEL_DREAM_COMPONENT)
-        Mockito.verify(mStateController).setHomeControlPanelActive(true)
+        assertThat(mService.dreamComponent).isEqualTo(HOME_CONTROL_PANEL_DREAM_COMPONENT)
+        verify(mStateController).setHomeControlPanelActive(true)
     }
 
     @Test
@@ -346,19 +370,19 @@
         mMainExecutor.runAllReady()
 
         // Verify view added.
-        Mockito.verify(mWindowManager).addView(mViewCaptor!!.capture(), any())
+        verify(mWindowManager).addView(mViewCaptor!!.capture(), any())
 
         // Service destroyed.
-        mService!!.onEndDream()
+        mService.onEndDream()
         mMainExecutor.runAllReady()
 
         // Verify view removed.
-        Mockito.verify(mWindowManager).removeView(mViewCaptor!!.value)
+        verify(mWindowManager).removeView(mViewCaptor!!.value)
 
         // Verify state correctly set.
-        Mockito.verify(mStateController).setOverlayActive(false)
-        Mockito.verify(mStateController).setLowLightActive(false)
-        Mockito.verify(mStateController).setEntryAnimationsFinished(false)
+        verify(mStateController).setOverlayActive(false)
+        verify(mStateController).setLowLightActive(false)
+        verify(mStateController).setEntryAnimationsFinished(false)
     }
 
     @Test
@@ -391,7 +415,7 @@
 
         // Schedule the endDream call in the middle of the startDream implementation, as any
         // ordering is possible.
-        Mockito.doAnswer { invocation: InvocationOnMock? ->
+        Mockito.doAnswer {
                 client.endDream()
                 null
             }
@@ -427,37 +451,37 @@
         mMainExecutor.runAllReady()
 
         // Verify view added.
-        Mockito.verify(mWindowManager).addView(mViewCaptor!!.capture(), any())
+        verify(mWindowManager).addView(mViewCaptor!!.capture(), any())
 
         // Service destroyed.
-        mService!!.onDestroy()
+        mService.onDestroy()
         mMainExecutor.runAllReady()
 
         // Verify view removed.
-        Mockito.verify(mWindowManager).removeView(mViewCaptor!!.value)
+        verify(mWindowManager).removeView(mViewCaptor!!.value)
 
         // Verify state correctly set.
-        Mockito.verify(mKeyguardUpdateMonitor).removeCallback(any())
-        Mockito.verify(mLifecycleRegistry).currentState = Lifecycle.State.DESTROYED
-        Mockito.verify(mStateController).setOverlayActive(false)
-        Mockito.verify(mStateController).setLowLightActive(false)
-        Mockito.verify(mStateController).setEntryAnimationsFinished(false)
+        verify(mKeyguardUpdateMonitor).removeCallback(any())
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.DESTROYED)
+        verify(mStateController).setOverlayActive(false)
+        verify(mStateController).setLowLightActive(false)
+        verify(mStateController).setEntryAnimationsFinished(false)
     }
 
     @Test
     fun testDoNotRemoveViewOnDestroyIfOverlayNotStarted() {
         // Service destroyed without ever starting dream.
-        mService!!.onDestroy()
+        mService.onDestroy()
         mMainExecutor.runAllReady()
 
         // Verify no view is removed.
-        Mockito.verify(mWindowManager, Mockito.never()).removeView(any())
+        verify(mWindowManager, Mockito.never()).removeView(any())
 
         // Verify state still correctly set.
-        Mockito.verify(mKeyguardUpdateMonitor).removeCallback(any())
-        Mockito.verify(mLifecycleRegistry).currentState = Lifecycle.State.DESTROYED
-        Mockito.verify(mStateController).setOverlayActive(false)
-        Mockito.verify(mStateController).setLowLightActive(false)
+        verify(mKeyguardUpdateMonitor).removeCallback(any())
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.DESTROYED)
+        verify(mStateController).setOverlayActive(false)
+        verify(mStateController).setLowLightActive(false)
     }
 
     @Test
@@ -465,7 +489,7 @@
         val client = client
 
         // Destroy the service.
-        mService!!.onDestroy()
+        mService.onDestroy()
         mMainExecutor.runAllReady()
 
         // Inform the overlay service of dream starting.
@@ -476,15 +500,15 @@
             false /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        Mockito.verify(mWindowManager, Mockito.never()).addView(any(), any())
+        verify(mWindowManager, Mockito.never()).addView(any(), any())
     }
 
     @Test
     fun testNeverRemoveDecorViewIfNotAdded() {
         // Service destroyed before dream started.
-        mService!!.onDestroy()
+        mService.onDestroy()
         mMainExecutor.runAllReady()
-        Mockito.verify(mWindowManager, Mockito.never()).removeView(any())
+        verify(mWindowManager, Mockito.never()).removeView(any())
     }
 
     @Test
@@ -501,11 +525,11 @@
         mMainExecutor.runAllReady()
 
         // Verify that a new window is added.
-        Mockito.verify(mWindowManager).addView(mViewCaptor!!.capture(), any())
+        verify(mWindowManager).addView(mViewCaptor!!.capture(), any())
         val windowDecorView = mViewCaptor!!.value
 
         // Assert that the overlay is not showing complications.
-        Truth.assertThat(mService!!.shouldShowComplications()).isFalse()
+        assertThat(mService.shouldShowComplications()).isFalse()
         Mockito.clearInvocations(mDreamOverlayComponent)
         Mockito.clearInvocations(mAmbientTouchComponent)
         Mockito.clearInvocations(mWindowManager)
@@ -522,16 +546,16 @@
         mMainExecutor.runAllReady()
 
         // Assert that the overlay is showing complications.
-        Truth.assertThat(mService!!.shouldShowComplications()).isTrue()
+        assertThat(mService.shouldShowComplications()).isTrue()
 
         // Verify that the old overlay window has been removed, and a new one created.
-        Mockito.verify(mWindowManager).removeView(windowDecorView)
-        Mockito.verify(mWindowManager).addView(any(), any())
+        verify(mWindowManager).removeView(windowDecorView)
+        verify(mWindowManager).addView(any(), any())
 
         // Verify that new instances of overlay container view controller and overlay touch monitor
         // are created.
-        Mockito.verify(mDreamOverlayComponent).getDreamOverlayContainerViewController()
-        Mockito.verify(mAmbientTouchComponent).getTouchMonitor()
+        verify(mDreamOverlayComponent).getDreamOverlayContainerViewController()
+        verify(mAmbientTouchComponent).getTouchMonitor()
     }
 
     @Test
@@ -546,15 +570,15 @@
             true /*shouldShowComplication*/
         )
         mMainExecutor.runAllReady()
-        mService!!.onWakeUp()
-        Mockito.verify(mDreamOverlayContainerViewController).wakeUp()
-        Mockito.verify(mDreamOverlayCallbackController).onWakeUp()
+        mService.onWakeUp()
+        verify(mDreamOverlayContainerViewController).wakeUp()
+        verify(mDreamOverlayCallbackController).onWakeUp()
     }
 
     @Test
     fun testWakeUpBeforeStartDoesNothing() {
-        mService!!.onWakeUp()
-        Mockito.verify(mDreamOverlayContainerViewController, Mockito.never()).wakeUp()
+        mService.onWakeUp()
+        verify(mDreamOverlayContainerViewController, Mockito.never()).wakeUp()
     }
 
     @Test
@@ -572,8 +596,8 @@
         val paramsCaptor = ArgumentCaptor.forClass(WindowManager.LayoutParams::class.java)
 
         // Verify that a new window is added.
-        Mockito.verify(mWindowManager).addView(any(), paramsCaptor.capture())
-        Truth.assertThat(
+        verify(mWindowManager).addView(any(), paramsCaptor.capture())
+        assertThat(
                 paramsCaptor.value.privateFlags and
                     WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS ==
                     WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS
@@ -598,7 +622,7 @@
 
         whenever(mDreamOverlayContainerViewController.isBouncerShowing()).thenReturn(true)
         mService!!.onComeToFront()
-        Mockito.verify(mScrimController).expand(any())
+        verify(mScrimController).expand(any())
     }
 
     // Tests that glanceable hub is hidden when DreamOverlayService is told that the dream is
@@ -617,7 +641,7 @@
         mMainExecutor.runAllReady()
 
         mService!!.onComeToFront()
-        Mockito.verify(mCommunalInteractor).changeScene(eq(CommunalScenes.Blank), nullable())
+        assertThat(communalRepository.currentScene.value).isEqualTo(CommunalScenes.Blank)
     }
 
     // Tests that system dialogs (e.g. notification shade) closes when DreamOverlayService is told
@@ -636,7 +660,197 @@
         mMainExecutor.runAllReady()
 
         mService!!.onComeToFront()
-        Mockito.verify(mSystemDialogsCloser).closeSystemDialogs()
+        verify(mSystemDialogsCloser).closeSystemDialogs()
+    }
+
+    @Test
+    fun testLifecycle_createdAfterConstruction() {
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.CREATED)
+    }
+
+    @Test
+    fun testLifecycle_resumedAfterDreamStarts() {
+        val client = client
+
+        // Inform the overlay service of dream starting. Do not show dream complications.
+        client.startDream(
+            mWindowParams,
+            mDreamOverlayCallback,
+            DREAM_COMPONENT,
+            false /*shouldShowComplication*/
+        )
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.mLifecycles)
+            .containsExactly(
+                Lifecycle.State.CREATED,
+                Lifecycle.State.STARTED,
+                Lifecycle.State.RESUMED
+            )
+    }
+
+    @Test
+    fun testLifecycle_destroyedAfterOnDestroy() {
+        val client = client
+
+        // Inform the overlay service of dream starting. Do not show dream complications.
+        client.startDream(
+            mWindowParams,
+            mDreamOverlayCallback,
+            DREAM_COMPONENT,
+            false /*shouldShowComplication*/
+        )
+        mMainExecutor.runAllReady()
+        mService.onDestroy()
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.mLifecycles)
+            .containsExactly(
+                Lifecycle.State.CREATED,
+                Lifecycle.State.STARTED,
+                Lifecycle.State.RESUMED,
+                Lifecycle.State.DESTROYED
+            )
+    }
+
+    @Test
+    fun testNotificationShadeShown_setsLifecycleState() {
+        val client = client
+
+        // Inform the overlay service of dream starting. Do not show dream complications.
+        client.startDream(
+            mWindowParams,
+            mDreamOverlayCallback,
+            DREAM_COMPONENT,
+            false /*shouldShowComplication*/
+        )
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+        val callbackCaptor = ArgumentCaptor.forClass(KeyguardUpdateMonitorCallback::class.java)
+        verify(mKeyguardUpdateMonitor).registerCallback(callbackCaptor.capture())
+
+        // Notification shade opens.
+        callbackCaptor.value.onShadeExpandedChanged(true)
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes from resumed back to started when the notification shade shows.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.STARTED)
+
+        // Notification shade closes.
+        callbackCaptor.value.onShadeExpandedChanged(false)
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes back to RESUMED.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+    }
+
+    @Test
+    fun testBouncerShown_setsLifecycleState() {
+        val client = client
+
+        // Inform the overlay service of dream starting. Do not show dream complications.
+        client.startDream(
+            mWindowParams,
+            mDreamOverlayCallback,
+            DREAM_COMPONENT,
+            false /*shouldShowComplication*/
+        )
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+
+        // Bouncer shows.
+        bouncerRepository.setPrimaryShow(true)
+        testScope.runCurrent()
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes from resumed back to started when the notification shade shows.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.STARTED)
+
+        // Bouncer closes.
+        bouncerRepository.setPrimaryShow(false)
+        testScope.runCurrent()
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes back to RESUMED.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+    }
+
+    @Test
+    fun testCommunalVisible_setsLifecycleState() {
+        val client = client
+
+        // Inform the overlay service of dream starting. Do not show dream complications.
+        client.startDream(
+            mWindowParams,
+            mDreamOverlayCallback,
+            DREAM_COMPONENT,
+            false /*shouldShowComplication*/
+        )
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+        val transitionState: MutableStateFlow<ObservableTransitionState> =
+            MutableStateFlow(ObservableTransitionState.Idle(CommunalScenes.Blank))
+        communalRepository.setTransitionState(transitionState)
+
+        // Communal becomes visible.
+        transitionState.value = ObservableTransitionState.Idle(CommunalScenes.Communal)
+        testScope.runCurrent()
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes from resumed back to started when the notification shade shows.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.STARTED)
+
+        // Communal closes.
+        transitionState.value = ObservableTransitionState.Idle(CommunalScenes.Blank)
+        testScope.runCurrent()
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes back to RESUMED.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+    }
+
+    // Verifies the dream's lifecycle
+    @Test
+    fun testLifecycleStarted_whenAnyOcclusion() {
+        val client = client
+
+        // Inform the overlay service of dream starting. Do not show dream complications.
+        client.startDream(
+            mWindowParams,
+            mDreamOverlayCallback,
+            DREAM_COMPONENT,
+            false /*shouldShowComplication*/
+        )
+        mMainExecutor.runAllReady()
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+        val transitionState: MutableStateFlow<ObservableTransitionState> =
+            MutableStateFlow(ObservableTransitionState.Idle(CommunalScenes.Blank))
+        communalRepository.setTransitionState(transitionState)
+
+        // Communal becomes visible.
+        transitionState.value = ObservableTransitionState.Idle(CommunalScenes.Communal)
+        testScope.runCurrent()
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes from resumed back to started when the notification shade shows.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.STARTED)
+
+        // Communal closes.
+        transitionState.value = ObservableTransitionState.Idle(CommunalScenes.Blank)
+        testScope.runCurrent()
+        mMainExecutor.runAllReady()
+
+        // Lifecycle state goes back to RESUMED.
+        assertThat(lifecycleRegistry.currentState).isEqualTo(Lifecycle.State.RESUMED)
+    }
+
+    internal class FakeLifecycleRegistry(provider: LifecycleOwner) : LifecycleRegistry(provider) {
+        val mLifecycles: MutableList<State> = ArrayList()
+
+        override var currentState: State
+            get() = mLifecycles[mLifecycles.size - 1]
+            set(state) {
+                mLifecycles.add(state)
+            }
     }
 
     companion object {
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/touch/CommunalTouchHandlerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/touch/CommunalTouchHandlerTest.java
index 29fbee0..e3c6dee 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/touch/CommunalTouchHandlerTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/touch/CommunalTouchHandlerTest.java
@@ -108,7 +108,7 @@
         mTouchHandler.onSessionStart(mTouchSession);
         verify(mTouchSession).registerInputListener(inputEventListenerArgumentCaptor.capture());
         inputEventListenerArgumentCaptor.getValue().onInputEvent(motionEvent);
-        verify(mCentralSurfaces).handleDreamTouch(motionEvent);
+        verify(mCentralSurfaces).handleExternalShadeWindowTouch(motionEvent);
     }
 
     @Test
diff --git a/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.java b/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.java
index d0f08f5..85aeb27 100644
--- a/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/ambient/touch/BouncerSwipeTouchHandler.java
@@ -27,6 +27,7 @@
 import android.view.MotionEvent;
 import android.view.VelocityTracker;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.VisibleForTesting;
 
 import com.android.internal.logging.UiEvent;
@@ -94,13 +95,11 @@
     private Boolean mCapture;
     private Boolean mExpanded;
 
-    private boolean mBouncerInitiallyShowing;
-
     private TouchSession mTouchSession;
 
-    private ValueAnimatorCreator mValueAnimatorCreator;
+    private final ValueAnimatorCreator mValueAnimatorCreator;
 
-    private VelocityTrackerFactory mVelocityTrackerFactory;
+    private final VelocityTrackerFactory mVelocityTrackerFactory;
 
     private final UiEventLogger mUiEventLogger;
 
@@ -118,17 +117,12 @@
     private final GestureDetector.OnGestureListener mOnGestureListener =
             new GestureDetector.SimpleOnGestureListener() {
                 @Override
-                public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,
+                public boolean onScroll(MotionEvent e1, @NonNull MotionEvent e2, float distanceX,
                         float distanceY) {
                     if (mCapture == null) {
-                        mBouncerInitiallyShowing = mCentralSurfaces
-                                .map(CentralSurfaces::isBouncerShowing)
-                                .orElse(false);
-
                         if (Flags.dreamOverlayBouncerSwipeDirectionFiltering()) {
                             mCapture = Math.abs(distanceY) > Math.abs(distanceX)
-                                    && ((distanceY < 0 && mBouncerInitiallyShowing)
-                                    || (distanceY > 0 && !mBouncerInitiallyShowing));
+                                    && distanceY > 0;
                         } else {
                             // If the user scrolling favors a vertical direction, begin capturing
                             // scrolls.
@@ -146,13 +140,8 @@
                         return false;
                     }
 
-                    // Don't set expansion for downward scroll when the bouncer is hidden.
-                    if (!mBouncerInitiallyShowing && (e1.getY() < e2.getY())) {
-                        return true;
-                    }
-
-                    // Don't set expansion for upward scroll when the bouncer is shown.
-                    if (mBouncerInitiallyShowing && (e1.getY() > e2.getY())) {
+                    // Don't set expansion for downward scroll.
+                    if (e1.getY() < e2.getY()) {
                         return true;
                     }
 
@@ -176,8 +165,7 @@
                     final float dragDownAmount = e2.getY() - e1.getY();
                     final float screenTravelPercentage = Math.abs(e1.getY() - e2.getY())
                             / mTouchSession.getBounds().height();
-                    setPanelExpansion(mBouncerInitiallyShowing
-                            ? screenTravelPercentage : 1 - screenTravelPercentage);
+                    setPanelExpansion(1 - screenTravelPercentage);
                     return true;
                 }
             };
@@ -223,9 +211,9 @@
             LockPatternUtils lockPatternUtils,
             UserTracker userTracker,
             @Named(BouncerSwipeModule.SWIPE_TO_BOUNCER_FLING_ANIMATION_UTILS_OPENING)
-                    FlingAnimationUtils flingAnimationUtils,
+            FlingAnimationUtils flingAnimationUtils,
             @Named(BouncerSwipeModule.SWIPE_TO_BOUNCER_FLING_ANIMATION_UTILS_CLOSING)
-                    FlingAnimationUtils flingAnimationUtilsClosing,
+            FlingAnimationUtils flingAnimationUtilsClosing,
             @Named(BouncerSwipeModule.SWIPE_TO_BOUNCER_START_REGION) float swipeRegionPercentage,
             @Named(BouncerSwipeModule.MIN_BOUNCER_ZONE_SCREEN_PERCENTAGE) float minRegionPercentage,
             UiEventLogger uiEventLogger) {
@@ -247,17 +235,13 @@
     public void getTouchInitiationRegion(Rect bounds, Region region, Rect exclusionRect) {
         final int width = bounds.width();
         final int height = bounds.height();
-        final float minBouncerHeight = height * mMinBouncerZoneScreenPercentage;
         final int minAllowableBottom = Math.round(height * (1 - mMinBouncerZoneScreenPercentage));
 
-        final boolean isBouncerShowing =
-                mCentralSurfaces.map(CentralSurfaces::isBouncerShowing).orElse(false);
-        final Rect normalRegion = isBouncerShowing
-                ? new Rect(0, 0, width, Math.round(height * mBouncerZoneScreenPercentage))
-                : new Rect(0, Math.round(height * (1 - mBouncerZoneScreenPercentage)),
-                        width, height);
+        final Rect normalRegion = new Rect(0,
+                Math.round(height * (1 - mBouncerZoneScreenPercentage)),
+                width, height);
 
-        if (!isBouncerShowing && exclusionRect != null) {
+        if (exclusionRect != null) {
             int lowestBottom = Math.min(Math.max(0, exclusionRect.bottom), minAllowableBottom);
             normalRegion.top = Math.max(normalRegion.top, lowestBottom);
         }
@@ -322,8 +306,7 @@
                         : KeyguardBouncerConstants.EXPANSION_HIDDEN;
 
                 // Log the swiping up to show Bouncer event.
-                if (!mBouncerInitiallyShowing
-                        && expansion == KeyguardBouncerConstants.EXPANSION_VISIBLE) {
+                if (expansion == KeyguardBouncerConstants.EXPANSION_VISIBLE) {
                     mUiEventLogger.log(DreamEvent.DREAM_SWIPED);
                 }
 
@@ -335,17 +318,15 @@
         }
     }
 
-    private ValueAnimator createExpansionAnimator(float targetExpansion, float expansionHeight) {
+    private ValueAnimator createExpansionAnimator(float targetExpansion) {
         final ValueAnimator animator =
                 mValueAnimatorCreator.create(mCurrentExpansion, targetExpansion);
         animator.addUpdateListener(
                 animation -> {
                     float expansionFraction = (float) animation.getAnimatedValue();
-                    float dragDownAmount = expansionFraction * expansionHeight;
                     setPanelExpansion(expansionFraction);
                 });
-        if (!mBouncerInitiallyShowing
-                && targetExpansion == KeyguardBouncerConstants.EXPANSION_VISIBLE) {
+        if (targetExpansion == KeyguardBouncerConstants.EXPANSION_VISIBLE) {
             animator.addListener(
                     new AnimatorListenerAdapter() {
                         @Override
@@ -381,8 +362,7 @@
         final float viewHeight = mTouchSession.getBounds().height();
         final float currentHeight = viewHeight * mCurrentExpansion;
         final float targetHeight = viewHeight * expansion;
-        final float expansionHeight = targetHeight - currentHeight;
-        final ValueAnimator animator = createExpansionAnimator(expansion, expansionHeight);
+        final ValueAnimator animator = createExpansionAnimator(expansion);
         if (expansion == KeyguardBouncerConstants.EXPANSION_HIDDEN) {
             // Hides the bouncer, i.e., fully expands the space above the bouncer.
             mFlingAnimationUtilsClosing.apply(animator, currentHeight, targetHeight, velocity,
diff --git a/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.java b/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.java
index 9ef9938..9c7fc9d 100644
--- a/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/ambient/touch/ShadeTouchHandler.java
@@ -23,7 +23,8 @@
 import android.view.GestureDetector;
 import android.view.MotionEvent;
 
-import com.android.systemui.shade.ShadeViewController;
+import androidx.annotation.NonNull;
+
 import com.android.systemui.statusbar.phone.CentralSurfaces;
 
 import java.util.Optional;
@@ -37,29 +38,34 @@
  */
 public class ShadeTouchHandler implements TouchHandler {
     private final Optional<CentralSurfaces> mSurfaces;
-    private final ShadeViewController mShadeViewController;
     private final int mInitiationHeight;
 
+    /**
+     * Tracks whether or not we are capturing a given touch. Will be null before and after a touch.
+     */
+    private Boolean mCapture;
+
     @Inject
     ShadeTouchHandler(Optional<CentralSurfaces> centralSurfaces,
-            ShadeViewController shadeViewController,
             @Named(NOTIFICATION_SHADE_GESTURE_INITIATION_HEIGHT) int initiationHeight) {
         mSurfaces = centralSurfaces;
-        mShadeViewController = shadeViewController;
         mInitiationHeight = initiationHeight;
     }
 
     @Override
     public void onSessionStart(TouchSession session) {
-        if (mSurfaces.map(CentralSurfaces::isBouncerShowing).orElse(false)) {
+        if (mSurfaces.isEmpty()) {
             session.pop();
             return;
         }
 
-        session.registerInputListener(ev -> {
-            mShadeViewController.handleExternalTouch((MotionEvent) ev);
+        session.registerCallback(() -> mCapture = null);
 
+        session.registerInputListener(ev -> {
             if (ev instanceof MotionEvent) {
+                if (mCapture != null && mCapture) {
+                    mSurfaces.get().handleExternalShadeWindowTouch((MotionEvent) ev);
+                }
                 if (((MotionEvent) ev).getAction() == MotionEvent.ACTION_UP) {
                     session.pop();
                 }
@@ -68,15 +74,25 @@
 
         session.registerGestureListener(new GestureDetector.SimpleOnGestureListener() {
             @Override
-            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,
+            public boolean onScroll(MotionEvent e1, @NonNull MotionEvent e2, float distanceX,
                     float distanceY) {
-                return true;
+                if (mCapture == null) {
+                    // Only capture swipes that are going downwards.
+                    mCapture = Math.abs(distanceY) > Math.abs(distanceX) && distanceY < 0;
+                    if (mCapture) {
+                        // Send the initial touches over, as the input listener has already
+                        // processed these touches.
+                        mSurfaces.get().handleExternalShadeWindowTouch(e1);
+                        mSurfaces.get().handleExternalShadeWindowTouch(e2);
+                    }
+                }
+                return mCapture;
             }
 
             @Override
-            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
+            public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX,
                     float velocityY) {
-                return true;
+                return mCapture;
             }
         });
     }
diff --git a/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java b/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java
index 3d52bcd..a9ef531 100644
--- a/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java
+++ b/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java
@@ -19,9 +19,11 @@
 import static com.android.systemui.dreams.dagger.DreamModule.DREAM_OVERLAY_WINDOW_TITLE;
 import static com.android.systemui.dreams.dagger.DreamModule.DREAM_TOUCH_INSET_MANAGER;
 import static com.android.systemui.dreams.dagger.DreamModule.HOME_CONTROL_PANEL_DREAM_COMPONENT;
+import static com.android.systemui.util.kotlin.JavaAdapterKt.collectFlow;
 
 import android.content.ComponentName;
 import android.content.Context;
+import android.content.Intent;
 import android.graphics.drawable.ColorDrawable;
 import android.util.Log;
 import android.view.View;
@@ -34,7 +36,10 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.LifecycleRegistry;
+import androidx.lifecycle.LifecycleService;
+import androidx.lifecycle.ServiceLifecycleDispatcher;
 import androidx.lifecycle.ViewModelStore;
 
 import com.android.dream.lowlight.dagger.LowLightDreamModule;
@@ -52,12 +57,14 @@
 import com.android.systemui.complication.dagger.ComplicationComponent;
 import com.android.systemui.dagger.qualifiers.Main;
 import com.android.systemui.dreams.dagger.DreamOverlayComponent;
+import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor;
 import com.android.systemui.shade.ShadeExpansionChangeEvent;
 import com.android.systemui.touch.TouchInsetManager;
 import com.android.systemui.util.concurrency.DelayableExecutor;
 
 import java.util.Arrays;
 import java.util.HashSet;
+import java.util.function.Consumer;
 
 import javax.inject.Inject;
 import javax.inject.Named;
@@ -67,7 +74,8 @@
  * dream reaches directly out to the service with a Window reference (via LayoutParams), which the
  * service uses to insert its own child Window into the dream's parent Window.
  */
-public class DreamOverlayService extends android.service.dreams.DreamOverlayService {
+public class DreamOverlayService extends android.service.dreams.DreamOverlayService implements
+        LifecycleOwner {
     private static final String TAG = "DreamOverlayService";
     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
 
@@ -98,6 +106,21 @@
     // True if the service has been destroyed.
     private boolean mDestroyed = false;
 
+    /**
+     * True if the notification shade is open.
+     */
+    private boolean mShadeExpanded = false;
+
+    /**
+     * True if any part of the glanceable hub is visible.
+     */
+    private boolean mCommunalVisible = false;
+
+    /**
+     * True if the primary bouncer is visible.
+     */
+    private boolean mBouncerShowing = false;
+
     private final ComplicationComponent mComplicationComponent;
 
     private final AmbientTouchComponent mAmbientTouchComponent;
@@ -107,9 +130,21 @@
 
     private final DreamOverlayComponent mDreamOverlayComponent;
 
-    private final DreamOverlayLifecycleOwner mLifecycleOwner;
+    /**
+     * This {@link LifecycleRegistry} controls when dream overlay functionality, like touch
+     * handling, should be active. It will automatically be paused when the dream overlay is hidden
+     * while dreaming, such as when the notification shade, bouncer, or glanceable hub are visible.
+     */
     private final LifecycleRegistry mLifecycleRegistry;
 
+    /**
+     * Drives the lifecycle exposed by this service's {@link #getLifecycle()}.
+     * <p>
+     * Used to mimic a {@link LifecycleService}, though we do not update the lifecycle in
+     * {@link #onBind(Intent)} since it's final in the base class.
+     */
+    private final ServiceLifecycleDispatcher mDispatcher = new ServiceLifecycleDispatcher(this);
+
     private TouchMonitor mTouchMonitor;
 
     private final CommunalInteractor mCommunalInteractor;
@@ -121,17 +156,46 @@
                 @Override
                 public void onShadeExpandedChanged(boolean expanded) {
                     mExecutor.execute(() -> {
-                        if (getCurrentStateLocked() != Lifecycle.State.RESUMED
-                                && getCurrentStateLocked() != Lifecycle.State.STARTED) {
+                        if (mShadeExpanded == expanded) {
                             return;
                         }
+                        mShadeExpanded = expanded;
 
-                        setCurrentStateLocked(
-                                expanded ? Lifecycle.State.STARTED : Lifecycle.State.RESUMED);
+                        updateLifecycleStateLocked();
                     });
                 }
             };
 
+    private final Consumer<Boolean> mCommunalVisibleConsumer = new Consumer<>() {
+        @Override
+        public void accept(Boolean communalVisible) {
+            mExecutor.execute(() -> {
+                if (mCommunalVisible == communalVisible) {
+                    return;
+                }
+
+                mCommunalVisible = communalVisible;
+
+                updateLifecycleStateLocked();
+            });
+        }
+    };
+
+    private final Consumer<Boolean> mBouncerShowingConsumer = new Consumer<>() {
+        @Override
+        public void accept(Boolean bouncerShowing) {
+            mExecutor.execute(() -> {
+                if (mBouncerShowing == bouncerShowing) {
+                    return;
+                }
+
+                mBouncerShowing = bouncerShowing;
+
+                updateLifecycleStateLocked();
+            });
+        }
+    };
+
     private final DreamOverlayStateController.Callback mExitAnimationFinishedCallback =
             new DreamOverlayStateController.Callback() {
                 @Override
@@ -183,10 +247,11 @@
             UiEventLogger uiEventLogger,
             @Named(DREAM_TOUCH_INSET_MANAGER) TouchInsetManager touchInsetManager,
             @Nullable @Named(LowLightDreamModule.LOW_LIGHT_DREAM_COMPONENT)
-                    ComponentName lowLightDreamComponent,
+            ComponentName lowLightDreamComponent,
             @Nullable @Named(HOME_CONTROL_PANEL_DREAM_COMPONENT)
-                    ComponentName homeControlPanelDreamComponent,
+            ComponentName homeControlPanelDreamComponent,
             DreamOverlayCallbackController dreamOverlayCallbackController,
+            KeyguardInteractor keyguardInteractor,
             @Named(DREAM_OVERLAY_WINDOW_TITLE) String windowTitle) {
         super(executor);
         mContext = context;
@@ -218,10 +283,32 @@
                 new HashSet<>(Arrays.asList(
                         mDreamComplicationComponent.getHideComplicationTouchHandler(),
                         mDreamOverlayComponent.getCommunalTouchHandler())));
-        mLifecycleOwner = lifecycleOwner;
-        mLifecycleRegistry = mLifecycleOwner.getRegistry();
+        mLifecycleRegistry = lifecycleOwner.getRegistry();
 
-        mExecutor.execute(() -> setCurrentStateLocked(Lifecycle.State.CREATED));
+        mExecutor.execute(() -> setLifecycleStateLocked(Lifecycle.State.CREATED));
+
+        collectFlow(getLifecycle(), communalInteractor.isCommunalVisible(),
+                mCommunalVisibleConsumer);
+        collectFlow(getLifecycle(), keyguardInteractor.primaryBouncerShowing,
+                mBouncerShowingConsumer);
+    }
+
+    @NonNull
+    @Override
+    public Lifecycle getLifecycle() {
+        return mDispatcher.getLifecycle();
+    }
+
+    @Override
+    public void onCreate() {
+        mDispatcher.onServicePreSuperOnCreate();
+        super.onCreate();
+    }
+
+    @Override
+    public void onStart(Intent intent, int startId) {
+        mDispatcher.onServicePreSuperOnStart();
+        super.onStart(intent, startId);
     }
 
     @Override
@@ -229,19 +316,20 @@
         mKeyguardUpdateMonitor.removeCallback(mKeyguardCallback);
 
         mExecutor.execute(() -> {
-            setCurrentStateLocked(Lifecycle.State.DESTROYED);
+            setLifecycleStateLocked(Lifecycle.State.DESTROYED);
 
             resetCurrentDreamOverlayLocked();
 
             mDestroyed = true;
         });
 
+        mDispatcher.onServicePreSuperOnDestroy();
         super.onDestroy();
     }
 
     @Override
     public void onStartDream(@NonNull WindowManager.LayoutParams layoutParams) {
-        setCurrentStateLocked(Lifecycle.State.STARTED);
+        setLifecycleStateLocked(Lifecycle.State.STARTED);
 
         mUiEventLogger.log(DreamOverlayEvent.DREAM_OVERLAY_ENTER_START);
 
@@ -271,7 +359,7 @@
             return;
         }
 
-        setCurrentStateLocked(Lifecycle.State.RESUMED);
+        setLifecycleStateLocked(Lifecycle.State.RESUMED);
         mStateController.setOverlayActive(true);
         final ComponentName dreamComponent = getDreamComponent();
         mStateController.setLowLightActive(
@@ -291,14 +379,27 @@
         resetCurrentDreamOverlayLocked();
     }
 
-    private Lifecycle.State getCurrentStateLocked() {
+    private Lifecycle.State getLifecycleStateLocked() {
         return mLifecycleRegistry.getCurrentState();
     }
 
-    private void setCurrentStateLocked(Lifecycle.State state) {
+    private void setLifecycleStateLocked(Lifecycle.State state) {
         mLifecycleRegistry.setCurrentState(state);
     }
 
+    private void updateLifecycleStateLocked() {
+        if (getLifecycleStateLocked() != Lifecycle.State.RESUMED
+                && getLifecycleStateLocked() != Lifecycle.State.STARTED) {
+            return;
+        }
+
+        // If anything is on top of the dream, we should stop touch handling.
+        boolean shouldPause = mShadeExpanded || mCommunalVisible || mBouncerShowing;
+
+        setLifecycleStateLocked(
+                shouldPause ? Lifecycle.State.STARTED : Lifecycle.State.RESUMED);
+    }
+
     @Override
     public void onWakeUp() {
         if (mDreamOverlayContainerViewController != null) {
diff --git a/packages/SystemUI/src/com/android/systemui/dreams/touch/CommunalTouchHandler.java b/packages/SystemUI/src/com/android/systemui/dreams/touch/CommunalTouchHandler.java
index 1c047dd..fff0c58 100644
--- a/packages/SystemUI/src/com/android/systemui/dreams/touch/CommunalTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/dreams/touch/CommunalTouchHandler.java
@@ -98,7 +98,7 @@
         // Notification shade window has its own logic to be visible if the hub is open, no need to
         // do anything here other than send touch events over.
         session.registerInputListener(ev -> {
-            surfaces.handleDreamTouch((MotionEvent) ev);
+            surfaces.handleExternalShadeWindowTouch((MotionEvent) ev);
             if (ev != null && ((MotionEvent) ev).getAction() == MotionEvent.ACTION_UP) {
                 var unused = session.pop();
             }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt
index 7224536..d191768 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt
@@ -226,7 +226,7 @@
     val ambientIndicationVisible: Flow<Boolean> = repository.ambientIndicationVisible.asStateFlow()
 
     /** Whether the primary bouncer is showing or not. */
-    val primaryBouncerShowing: Flow<Boolean> = bouncerRepository.primaryBouncerShow
+    @JvmField val primaryBouncerShowing: Flow<Boolean> = bouncerRepository.primaryBouncerShow
 
     /** Whether the alternate bouncer is showing or not. */
     val alternateBouncerShowing: Flow<Boolean> = bouncerRepository.alternateBouncerVisible
diff --git a/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt b/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt
index a8481cd..a5a5474 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/GlanceableHubContainerController.kt
@@ -28,10 +28,14 @@
 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
@@ -45,6 +49,8 @@
 import com.android.systemui.scene.shared.model.SceneDataSourceDelegator
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.statusbar.phone.SystemUIDialogFactory
+import com.android.systemui.util.kotlin.BooleanFlowOperators.and
+import com.android.systemui.util.kotlin.BooleanFlowOperators.not
 import com.android.systemui.util.kotlin.BooleanFlowOperators.or
 import com.android.systemui.util.kotlin.collectFlow
 import javax.inject.Inject
@@ -67,12 +73,27 @@
     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
 
     /**
+     * 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.
+     */
+    private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
+
+    /**
+     * 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 touchMonitor: TouchMonitor? = 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.
      */
@@ -80,20 +101,6 @@
     private var rightEdgeSwipeRegionWidth: Int = 0
 
     /**
-     * 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.
-     */
-    private var topEdgeSwipeRegionWidth: Int = 0
-
-    /**
-     * 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
-     * resources when [initView] is called.
-     */
-    private var bottomEdgeSwipeRegionWidth: Int = 0
-
-    /**
      * True if we are currently tracking a gesture for opening the hub that started in the edge
      * swipe region.
      */
@@ -102,9 +109,6 @@
     /** 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.
      *
@@ -121,9 +125,15 @@
     private var anyBouncerShowing = false
 
     /**
-     * True if the shade is fully expanded, meaning the hub should not receive any touch input.
+     * True if the shade is fully expanded and the user is not interacting with it anymore, meaning
+     * the hub should not receive any touch input.
      *
-     * Tracks [ShadeInteractor.isAnyFullyExpanded].
+     * We need to not pause the touch handling lifecycle as soon as the shade opens because if the
+     * user swipes down, then back up without lifting their finger, the lifecycle will be paused
+     * then resumed, and resuming force-stops all active touch sessions. This means the shade will
+     * not receive the end of the gesture and will be stuck open.
+     *
+     * Based on [ShadeInteractor.isAnyFullyExpanded] and [ShadeInteractor.isUserInteracting].
      */
     private var shadeShowing = false
 
@@ -132,8 +142,6 @@
      * 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 +200,45 @@
             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 })
-        collectFlow(containerView, shadeInteractor.isAnyFullyExpanded, { shadeShowing = it })
+        collectFlow(
+            containerView,
+            communalInteractor.isCommunalShowing,
+            {
+                hubShowing = it
+                updateLifecycleState()
+            }
+        )
+        collectFlow(
+            containerView,
+            and(shadeInteractor.isAnyFullyExpanded, not(shadeInteractor.isUserInteracting)),
+            {
+                shadeShowing = it
+                updateLifecycleState()
+            }
+        )
         collectFlow(containerView, keyguardInteractor.isDreaming, { isDreaming = it })
 
         communalContainerView = containerView
@@ -221,10 +246,24 @@
         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,15 +301,7 @@
         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
-            }
+            isTrackingHubTouch = true
         }
 
         if (isTrackingHubTouch) {
@@ -283,19 +314,6 @@
             // 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 +365,7 @@
             0
         )
     }
+
+    override val lifecycle: Lifecycle
+        get() = lifecycleRegistry
 }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java
index 907cf5e..44f86da 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java
@@ -25,7 +25,6 @@
 import android.app.StatusBarManager;
 import android.util.Log;
 import android.view.GestureDetector;
-import android.view.InputDevice;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.View;
@@ -74,14 +73,14 @@
 import com.android.systemui.unfold.UnfoldTransitionProgressProvider;
 import com.android.systemui.util.time.SystemClock;
 
+import kotlinx.coroutines.ExperimentalCoroutinesApi;
+
 import java.io.PrintWriter;
 import java.util.Optional;
 import java.util.function.Consumer;
 
 import javax.inject.Inject;
 
-import kotlinx.coroutines.ExperimentalCoroutinesApi;
-
 /**
  * Controller for {@link NotificationShadeWindowView}.
  */
@@ -137,6 +136,11 @@
     private final PanelExpansionInteractor mPanelExpansionInteractor;
     private final ShadeExpansionStateManager mShadeExpansionStateManager;
 
+    /**
+     * If {@code true}, an external touch sent in {@link #handleExternalTouch(MotionEvent)} has been
+     * intercepted and all future touch events for the gesture should be processed by this view.
+     */
+    private boolean mExternalTouchIntercepted = false;
     private boolean mIsTrackingBarGesture = false;
     private boolean mIsOcclusionTransitionRunning = false;
     private DisableSubpixelTextTransitionListener mDisableSubpixelTextTransitionListener;
@@ -253,11 +257,28 @@
     }
 
     /**
-     * Handle a touch event while dreaming by forwarding the event to the content view.
+     * Handle a touch event while dreaming or on the hub by forwarding the event to the content
+     * view.
+     * <p>
+     * Since important logic for handling touches lives in the dispatch/intercept phases, we
+     * simulate going through all of these stages before sending onTouchEvent if intercepted.
+     *
      * @param event The event to forward.
      */
-    public void handleDreamTouch(MotionEvent event) {
-        mView.dispatchTouchEvent(event);
+    public void handleExternalTouch(MotionEvent event) {
+        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+            mExternalTouchIntercepted = false;
+        }
+
+        if (!mView.dispatchTouchEvent(event)) {
+            return;
+        }
+        if (!mExternalTouchIntercepted) {
+            mExternalTouchIntercepted = mView.onInterceptTouchEvent(event);
+        }
+        if (mExternalTouchIntercepted) {
+            mView.onTouchEvent(event);
+        }
     }
 
     /** Inflates the {@link R.layout#status_bar_expanded} layout and sets it up. */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java
index 8fb552f..7d97428 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java
@@ -283,11 +283,12 @@
     void awakenDreams();
 
     /**
-     * Handle a touch event while dreaming when the touch was initiated within a prescribed
-     * swipeable area. This method is provided for cases where swiping in certain areas of a dream
-     * should be handled by CentralSurfaces instead (e.g. swiping communal hub open).
+     * Handle a touch event while dreaming or on the glanceable hub when the touch was initiated
+     * within a prescribed swipeable area. This method is provided for cases where swiping in
+     * certain areas should be handled by CentralSurfaces instead (e.g. swiping hub open, opening
+     * the notification shade over dream or hub).
      */
-    void handleDreamTouch(MotionEvent event);
+    void handleExternalShadeWindowTouch(MotionEvent event);
 
     boolean isBouncerShowing();
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesEmptyImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesEmptyImpl.kt
index 8af7ee8..d5e66ff 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesEmptyImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesEmptyImpl.kt
@@ -79,7 +79,7 @@
     override fun updateScrimController() {}
     override fun shouldIgnoreTouch() = false
     override fun isDeviceInteractive() = false
-    override fun handleDreamTouch(event: MotionEvent?) {}
+    override fun handleExternalShadeWindowTouch(event: MotionEvent?) {}
     override fun awakenDreams() {}
     override fun isBouncerShowing() = false
     override fun isBouncerShowingScrimmed() = false
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
index e9aa7aa..b2b2cea 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
@@ -2928,8 +2928,8 @@
     };
 
     @Override
-    public void handleDreamTouch(MotionEvent event) {
-        getNotificationShadeWindowViewController().handleDreamTouch(event);
+    public void handleExternalShadeWindowTouch(MotionEvent event) {
+        getNotificationShadeWindowViewController().handleExternalTouch(event);
     }
 
     @Override
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/GlanceableHubContainerControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/GlanceableHubContainerControllerTest.kt
index fd9daf8..03f5ecf 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/shade/GlanceableHubContainerControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/shade/GlanceableHubContainerControllerTest.kt
@@ -25,23 +25,24 @@
 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.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 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 @@
     @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,32 +101,37 @@
     fun setUp() {
         MockitoAnnotations.initMocks(this)
 
-        communalInteractor = kosmos.communalInteractor
         communalRepository = kosmos.fakeCommunalRepository
-        keyguardTransitionInteractor = kosmos.keyguardTransitionInteractor
-        keyguardInteractor = kosmos.keyguardInteractor
-        shadeInteractor = kosmos.shadeInteractor
 
-        underTest =
-            GlanceableHubContainerController(
-                communalInteractor,
-                communalViewModel,
-                dialogFactory,
-                keyguardTransitionInteractor,
-                keyguardInteractor,
-                shadeInteractor,
-                powerManager,
-                communalColors,
-                kosmos.sceneDataSourceDelegator,
-            )
+        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,
+                    communalViewModel,
+                    dialogFactory,
+                    keyguardTransitionInteractor,
+                    keyguardInteractor,
+                    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 @@
                         shadeInteractor,
                         powerManager,
                         communalColors,
+                        ambientTouchComponentFactory,
                         kosmos.sceneDataSourceDelegator,
                     )
 
@@ -215,62 +218,6 @@
         }
 
     @Test
-    fun onTouchEvent_topSwipeWhenCommunalOpen_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()
-            }
-        }
-
-    @Test
-    fun onTouchEvent_bottomSwipeWhenCommunalOpen_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()
-            }
-        }
-
-    @Test
-    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 {
@@ -327,6 +274,141 @@
         }
 
     @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))
+
+            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 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)
+
+                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
+
+                // Communal closes.
+                goToScene(CommunalScenes.Blank)
+
+                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
+            }
+        }
+
+    @Test
+    fun lifecycle_startedAfterPrimaryBouncerShows() =
+        with(kosmos) {
+            testScope.runTest {
+                // Communal is open.
+                goToScene(CommunalScenes.Communal)
+
+                // Bouncer is visible.
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    KeyguardState.GLANCEABLE_HUB,
+                    KeyguardState.PRIMARY_BOUNCER,
+                    testScope
+                )
+                testableLooper.processAllMessages()
+
+                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
+            }
+        }
+
+    @Test
+    fun lifecycle_startedAfterAlternateBouncerShows() =
+        with(kosmos) {
+            testScope.runTest {
+                // Communal is open.
+                goToScene(CommunalScenes.Communal)
+
+                // Bouncer is visible.
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    KeyguardState.GLANCEABLE_HUB,
+                    KeyguardState.ALTERNATE_BOUNCER,
+                    testScope
+                )
+                testableLooper.processAllMessages()
+
+                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
+            }
+        }
+
+    @Test
+    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)
+
+                // Shade shows up.
+                fakeShadeRepository.setQsExpansion(1.0f)
+                testableLooper.processAllMessages()
+
+                assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
+            }
+        }
+
+    @Test
     fun editMode_communalAvailable() =
         with(kosmos) {
             testScope.runTest {
@@ -371,8 +453,6 @@
         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 @@
             )
         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)
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt
index da09579..d95cc2e 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt
@@ -500,6 +500,46 @@
     }
 
     @Test
+    fun handleExternalTouch_intercepted_sendsOnTouch() {
+        // Accept dispatch and also intercept.
+        whenever(view.dispatchTouchEvent(any())).thenReturn(true)
+        whenever(view.onInterceptTouchEvent(any())).thenReturn(true)
+
+        underTest.handleExternalTouch(DOWN_EVENT)
+        underTest.handleExternalTouch(MOVE_EVENT)
+
+        // Once intercepted, both events are sent to the view.
+        verify(view).onTouchEvent(DOWN_EVENT)
+        verify(view).onTouchEvent(MOVE_EVENT)
+    }
+
+    @Test
+    fun handleExternalTouch_notDispatched_interceptNotCalled() {
+        // Don't accept dispatch
+        whenever(view.dispatchTouchEvent(any())).thenReturn(false)
+
+        underTest.handleExternalTouch(DOWN_EVENT)
+
+        // Interception is not offered.
+        verify(view, never()).onInterceptTouchEvent(any())
+    }
+
+    @Test
+    fun handleExternalTouch_notIntercepted_onTouchNotSent() {
+        // Accept dispatch, but don't dispatch
+        whenever(view.dispatchTouchEvent(any())).thenReturn(true)
+        whenever(view.onInterceptTouchEvent(any())).thenReturn(false)
+
+        underTest.handleExternalTouch(DOWN_EVENT)
+        underTest.handleExternalTouch(MOVE_EVENT)
+
+        // Interception offered for both events, but onTouchEvent is never called.
+        verify(view).onInterceptTouchEvent(DOWN_EVENT)
+        verify(view).onInterceptTouchEvent(MOVE_EVENT)
+        verify(view, never()).onTouchEvent(any())
+    }
+
+    @Test
     fun testGetKeyguardMessageArea() =
         testScope.runTest {
             underTest.keyguardMessageArea
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt
index 8dc4756..d4b7937 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt
@@ -22,6 +22,7 @@
 import android.os.fakeExecutorHandler
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.bouncer.data.repository.bouncerRepository
+import com.android.systemui.bouncer.data.repository.fakeKeyguardBouncerRepository
 import com.android.systemui.bouncer.domain.interactor.simBouncerInteractor
 import com.android.systemui.classifier.falsingCollector
 import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository
@@ -41,6 +42,7 @@
 import com.android.systemui.keyguard.domain.interactor.fromLockscreenTransitionInteractor
 import com.android.systemui.keyguard.domain.interactor.fromPrimaryBouncerTransitionInteractor
 import com.android.systemui.keyguard.domain.interactor.keyguardClockInteractor
+import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
 import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
 import com.android.systemui.model.sceneContainerPlugin
 import com.android.systemui.plugins.statusbar.statusBarStateController
@@ -78,6 +80,8 @@
     val bouncerRepository by lazy { kosmos.bouncerRepository }
     val communalRepository by lazy { kosmos.fakeCommunalRepository }
     val keyguardRepository by lazy { kosmos.fakeKeyguardRepository }
+    val keyguardBouncerRepository by lazy { kosmos.fakeKeyguardBouncerRepository }
+    val keyguardInteractor by lazy { kosmos.keyguardInteractor }
     val keyguardTransitionRepository by lazy { kosmos.fakeKeyguardTransitionRepository }
     val keyguardTransitionInteractor by lazy { kosmos.keyguardTransitionInteractor }
     val powerRepository by lazy { kosmos.fakePowerRepository }