Merge "Use return switch expressions in MediaSessionRecord" into main
diff --git a/core/java/android/app/admin/flags/flags.aconfig b/core/java/android/app/admin/flags/flags.aconfig
index 441d521..3ec6fe7 100644
--- a/core/java/android/app/admin/flags/flags.aconfig
+++ b/core/java/android/app/admin/flags/flags.aconfig
@@ -1,3 +1,6 @@
+# proto-file: build/make/tools/aconfig/aconfig_protos/protos/aconfig.proto
+# proto-message: flag_declarations
+
 package: "android.app.admin.flags"
 
 flag {
@@ -180,3 +183,10 @@
   description: "Allow COPE admin to control screen brightness and timeout."
   bug: "323894620"
 }
+
+flag {
+  name: "is_recursive_required_app_merging_enabled"
+  namespace: "enterprise"
+  description: "Guards a new flow for recursive required enterprise app list merging"
+  bug: "319084618"
+}
diff --git a/core/java/android/companion/virtual/VirtualDeviceManager.java b/core/java/android/companion/virtual/VirtualDeviceManager.java
index 3304475..ec59cf6 100644
--- a/core/java/android/companion/virtual/VirtualDeviceManager.java
+++ b/core/java/android/companion/virtual/VirtualDeviceManager.java
@@ -972,6 +972,7 @@
          *
          * @param config camera configuration.
          * @return newly created camera.
+         * @throws UnsupportedOperationException if virtual camera isn't supported on this device.
          * @see VirtualDeviceParams#POLICY_TYPE_CAMERA
          */
         @RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE)
diff --git a/core/java/android/hardware/camera2/CaptureRequest.java b/core/java/android/hardware/camera2/CaptureRequest.java
index 13d5c7e..6f901d7 100644
--- a/core/java/android/hardware/camera2/CaptureRequest.java
+++ b/core/java/android/hardware/camera2/CaptureRequest.java
@@ -2800,7 +2800,9 @@
      * upright.</p>
      * <p>Camera devices may either encode this value into the JPEG EXIF header, or
      * rotate the image data to match this orientation. When the image data is rotated,
-     * the thumbnail data will also be rotated.</p>
+     * the thumbnail data will also be rotated. Additionally, in the case where the image data
+     * is rotated, {@link android.media.Image#getWidth } and {@link android.media.Image#getHeight }
+     * will not be updated to reflect the height and width of the rotated image.</p>
      * <p>Note that this orientation is relative to the orientation of the camera sensor, given
      * by {@link CameraCharacteristics#SENSOR_ORIENTATION android.sensor.orientation}.</p>
      * <p>To translate from the device orientation given by the Android sensor APIs for camera
diff --git a/core/java/android/hardware/camera2/CaptureResult.java b/core/java/android/hardware/camera2/CaptureResult.java
index 7145501..69b1c34 100644
--- a/core/java/android/hardware/camera2/CaptureResult.java
+++ b/core/java/android/hardware/camera2/CaptureResult.java
@@ -3091,7 +3091,9 @@
      * upright.</p>
      * <p>Camera devices may either encode this value into the JPEG EXIF header, or
      * rotate the image data to match this orientation. When the image data is rotated,
-     * the thumbnail data will also be rotated.</p>
+     * the thumbnail data will also be rotated. Additionally, in the case where the image data
+     * is rotated, {@link android.media.Image#getWidth } and {@link android.media.Image#getHeight }
+     * will not be updated to reflect the height and width of the rotated image.</p>
      * <p>Note that this orientation is relative to the orientation of the camera sensor, given
      * by {@link CameraCharacteristics#SENSOR_ORIENTATION android.sensor.orientation}.</p>
      * <p>To translate from the device orientation given by the Android sensor APIs for camera
diff --git a/core/java/android/service/wallpaper/WallpaperService.java b/core/java/android/service/wallpaper/WallpaperService.java
index bbda068..cd486d0 100644
--- a/core/java/android/service/wallpaper/WallpaperService.java
+++ b/core/java/android/service/wallpaper/WallpaperService.java
@@ -211,7 +211,7 @@
      * @hide
      */
     @ChangeId
-    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public static final long WEAROS_WALLPAPER_HANDLES_SCALING = 272527315L;
 
     static final class WallpaperCommand {
diff --git a/core/java/com/android/internal/app/ResolverActivity.java b/core/java/com/android/internal/app/ResolverActivity.java
index 78f06b6..84715aa 100644
--- a/core/java/com/android/internal/app/ResolverActivity.java
+++ b/core/java/com/android/internal/app/ResolverActivity.java
@@ -217,6 +217,12 @@
     public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device";
 
     /**
+     * Boolean extra to indicate if Resolver Sheet needs to be started in single user mode.
+     */
+    protected static final String EXTRA_RESTRICT_TO_SINGLE_USER =
+            "com.android.internal.app.ResolverActivity.EXTRA_RESTRICT_TO_SINGLE_USER";
+
+    /**
      * Integer extra to indicate which profile should be automatically selected.
      * <p>Can only be used if there is a work profile.
      * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}.
@@ -750,8 +756,10 @@
     }
 
     protected UserHandle getPersonalProfileUserHandle() {
-        if (privateSpaceEnabled() && isLaunchedAsPrivateProfile()){
-            return mPrivateProfileUserHandle;
+        // When launched in single user mode, only personal tab is populated, so we use
+        // tabOwnerUserHandleForLaunch as personal tab's user handle.
+        if (privateSpaceEnabled() && isLaunchedInSingleUserMode()) {
+            return getTabOwnerUserHandleForLaunch();
         }
         return mPersonalProfileUserHandle;
     }
@@ -822,11 +830,11 @@
         // If we are in work or private profile's process, return WorkProfile/PrivateProfile user
         // as owner, otherwise we always return PersonalProfile user as owner
         if (UserHandle.of(UserHandle.myUserId()).equals(getWorkProfileUserHandle())) {
-            return getWorkProfileUserHandle();
+            return mWorkProfileUserHandle;
         } else if (privateSpaceEnabled() && isLaunchedAsPrivateProfile()) {
-            return getPrivateProfileUserHandle();
+            return mPrivateProfileUserHandle;
         }
-        return getPersonalProfileUserHandle();
+        return mPersonalProfileUserHandle;
     }
 
     private boolean hasWorkProfile() {
@@ -847,8 +855,18 @@
                 && (UserHandle.myUserId() == getPrivateProfileUserHandle().getIdentifier());
     }
 
+    protected final boolean isLaunchedInSingleUserMode() {
+        // When launched from Private Profile, return true
+        if (isLaunchedAsPrivateProfile()) {
+            return true;
+        }
+        return getIntent()
+                .getBooleanExtra(EXTRA_RESTRICT_TO_SINGLE_USER, /* defaultValue = */ false);
+    }
+
     protected boolean shouldShowTabs() {
-        if (privateSpaceEnabled() && isLaunchedAsPrivateProfile()) {
+        // No Tabs are shown when launched in single user mode.
+        if (privateSpaceEnabled() && isLaunchedInSingleUserMode()) {
             return false;
         }
         return hasWorkProfile() && ENABLE_TABBED_VIEW;
diff --git a/core/java/com/android/internal/widget/ImageFloatingTextView.java b/core/java/com/android/internal/widget/ImageFloatingTextView.java
index 5da6435..352e6d8 100644
--- a/core/java/com/android/internal/widget/ImageFloatingTextView.java
+++ b/core/java/com/android/internal/widget/ImageFloatingTextView.java
@@ -31,6 +31,8 @@
 import android.widget.RemoteViews;
 import android.widget.TextView;
 
+import com.android.internal.R;
+
 /**
  * A TextView that can float around an image on the end.
  *
@@ -49,6 +51,7 @@
     private int mMaxLinesForHeight = -1;
     private int mLayoutMaxLines = -1;
     private int mImageEndMargin;
+    private final int mMaxLineUpperLimit;
 
     private int mStaticLayoutCreationCountInOnMeasure = 0;
 
@@ -71,6 +74,8 @@
         super(context, attrs, defStyleAttr, defStyleRes);
         setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL_FAST);
         setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY);
+        mMaxLineUpperLimit =
+                getResources().getInteger(R.integer.config_notificationLongTextMaxLineCount);
     }
 
     @Override
@@ -102,6 +107,11 @@
         } else {
             maxLines = getMaxLines() >= 0 ? getMaxLines() : Integer.MAX_VALUE;
         }
+
+        if (mMaxLineUpperLimit > 0) {
+            maxLines = Math.min(maxLines, mMaxLineUpperLimit);
+        }
+
         builder.setMaxLines(maxLines);
         mLayoutMaxLines = maxLines;
         if (shouldEllipsize) {
diff --git a/core/jni/OWNERS b/core/jni/OWNERS
index 3aca751..2a4f062 100644
--- a/core/jni/OWNERS
+++ b/core/jni/OWNERS
@@ -27,6 +27,7 @@
 # WindowManager
 per-file android_graphics_BLASTBufferQueue.cpp = file:/services/core/java/com/android/server/wm/OWNERS
 per-file android_view_Surface* = file:/services/core/java/com/android/server/wm/OWNERS
+per-file android_view_WindowManagerGlobal.cpp = file:/services/core/java/com/android/server/wm/OWNERS
 per-file android_window_* = file:/services/core/java/com/android/server/wm/OWNERS
 
 # Resources
diff --git a/core/jni/android_view_WindowManagerGlobal.cpp b/core/jni/android_view_WindowManagerGlobal.cpp
index b03ac88..abc621d 100644
--- a/core/jni/android_view_WindowManagerGlobal.cpp
+++ b/core/jni/android_view_WindowManagerGlobal.cpp
@@ -48,7 +48,7 @@
             surfaceControlObj(env,
                               android_view_SurfaceControl_getJavaSurfaceControl(env,
                                                                                 surfaceControl));
-    jobject clientTokenObj = javaObjectForIBinder(env, clientToken);
+    ScopedLocalRef<jobject> clientTokenObj(env, javaObjectForIBinder(env, clientToken));
     ScopedLocalRef<jobject> clientInputTransferTokenObj(
             env,
             android_window_InputTransferToken_getJavaInputTransferToken(env,
@@ -57,7 +57,7 @@
             inputChannelObj(env,
                             env->CallStaticObjectMethod(gWindowManagerGlobal.clazz,
                                                         gWindowManagerGlobal.createInputChannel,
-                                                        clientTokenObj,
+                                                        clientTokenObj.get(),
                                                         hostInputTransferTokenObj.get(),
                                                         surfaceControlObj.get(),
                                                         clientInputTransferTokenObj.get()));
@@ -68,9 +68,9 @@
 void removeInputChannel(const sp<IBinder>& clientToken) {
     JNIEnv* env = AndroidRuntime::getJNIEnv();
 
-    jobject clientTokenObj(javaObjectForIBinder(env, clientToken));
+    ScopedLocalRef<jobject> clientTokenObj(env, javaObjectForIBinder(env, clientToken));
     env->CallStaticObjectMethod(gWindowManagerGlobal.clazz, gWindowManagerGlobal.removeInputChannel,
-                                clientTokenObj);
+                                clientTokenObj.get());
 }
 
 int register_android_view_WindowManagerGlobal(JNIEnv* env) {
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index e3f1cb6..efba709 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -1483,6 +1483,11 @@
     <!-- Number of notifications to keep in the notification service historical archive -->
     <integer name="config_notificationServiceArchiveSize">100</integer>
 
+    <!-- Upper limit imposed for long text content for BigTextStyle, MessagingStyle and
+    ConversationStyle notifications for performance reasons, and that line count is also
+    capped by vertical space available. It is only enabled when the value is positive int.-->
+    <integer name="config_notificationLongTextMaxLineCount">10</integer>
+
     <!-- Allow the menu hard key to be disabled in LockScreen on some devices -->
     <bool name="config_disableMenuKeyInLockScreen">false</bool>
 
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index f915f03..5639a58 100644
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -231,8 +231,10 @@
     <string name="NetworkPreferenceSwitchSummary">Try changing preferred network. Tap to change.</string>
     <!-- Displayed to tell the user that emergency calls might not be available. -->
     <string name="EmergencyCallWarningTitle">Emergency calling unavailable</string>
-    <!-- Displayed to tell the user that emergency calls might not be available. -->
-    <string name="EmergencyCallWarningSummary">Can\u2019t make emergency calls over Wi\u2011Fi</string>
+    <!-- Displayed to tell the user that emergency calls might not be available; this is shown to
+         the user when only WiFi calling is available and the carrier does not support emergency
+         calls over WiFi calling. -->
+    <string name="EmergencyCallWarningSummary">Emergency calls require a mobile network</string>
 
     <!-- Telephony notification channel name for a channel containing network alert notifications. -->
     <string name="notification_channel_network_alert">Alerts</string>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index f4b42f6..668a88c 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -2078,6 +2078,7 @@
   <java-symbol type="integer" name="config_notificationsBatteryMediumARGB" />
   <java-symbol type="integer" name="config_notificationsBatteryNearlyFullLevel" />
   <java-symbol type="integer" name="config_notificationServiceArchiveSize" />
+  <java-symbol type="integer" name="config_notificationLongTextMaxLineCount" />
   <java-symbol type="dimen" name="config_rotaryEncoderAxisScrollTickInterval" />
   <java-symbol type="integer" name="config_recentVibrationsDumpSizeLimit" />
   <java-symbol type="integer" name="config_previousVibrationsDumpSizeLimit" />
diff --git a/core/tests/bugreports/OWNERS b/core/tests/bugreports/OWNERS
new file mode 100644
index 0000000..dbd767c
--- /dev/null
+++ b/core/tests/bugreports/OWNERS
@@ -0,0 +1,2 @@
+# Bug component: 153446
+file:/platform/frameworks/native:/cmds/dumpstate/OWNERS
diff --git a/core/tests/coretests/src/com/android/internal/app/ResolverActivityTest.java b/core/tests/coretests/src/com/android/internal/app/ResolverActivityTest.java
index cb8754a..488f017 100644
--- a/core/tests/coretests/src/com/android/internal/app/ResolverActivityTest.java
+++ b/core/tests/coretests/src/com/android/internal/app/ResolverActivityTest.java
@@ -27,6 +27,7 @@
 import static androidx.test.espresso.matcher.ViewMatchers.withText;
 
 import static com.android.internal.app.MatcherUtils.first;
+import static com.android.internal.app.ResolverActivity.EXTRA_RESTRICT_TO_SINGLE_USER;
 import static com.android.internal.app.ResolverDataProvider.createPackageManagerMockedInfo;
 import static com.android.internal.app.ResolverWrapperActivity.sOverrides;
 
@@ -1254,6 +1255,51 @@
         }
     }
 
+    @Test
+    public void testTriggerFromMainProfile_inSingleUserMode_withWorkProfilePresent() {
+        mSetFlagsRule.enableFlags(android.os.Flags.FLAG_ALLOW_PRIVATE_PROFILE,
+                android.multiuser.Flags.FLAG_ALLOW_RESOLVER_SHEET_FOR_PRIVATE_SPACE);
+        markWorkProfileUserAvailable();
+        setTabOwnerUserHandleForLaunch(PERSONAL_USER_HANDLE);
+        Intent sendIntent = createSendImageIntent();
+        sendIntent.putExtra(EXTRA_RESTRICT_TO_SINGLE_USER, true);
+        List<ResolvedComponentInfo> personalResolvedComponentInfos =
+                createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE);
+        List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
+                sOverrides.workProfileUserHandle);
+        setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+        final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+        waitForIdle();
+        assertThat(activity.getPersonalListAdapter().getCount(), is(2));
+        onView(withId(R.id.tabs)).check(matches(not(isDisplayed())));
+        assertEquals(activity.getMultiProfilePagerAdapterCount(), 1);
+        for (ResolvedComponentInfo resolvedInfo : personalResolvedComponentInfos) {
+            assertEquals(resolvedInfo.getResolveInfoAt(0).userHandle, PERSONAL_USER_HANDLE);
+        }
+    }
+
+    @Test
+    public void testTriggerFromWorkProfile_inSingleUserMode() {
+        mSetFlagsRule.enableFlags(android.os.Flags.FLAG_ALLOW_PRIVATE_PROFILE,
+                android.multiuser.Flags.FLAG_ALLOW_RESOLVER_SHEET_FOR_PRIVATE_SPACE);
+        markWorkProfileUserAvailable();
+        setTabOwnerUserHandleForLaunch(sOverrides.workProfileUserHandle);
+        Intent sendIntent = createSendImageIntent();
+        sendIntent.putExtra(EXTRA_RESTRICT_TO_SINGLE_USER, true);
+        List<ResolvedComponentInfo> personalResolvedComponentInfos =
+                createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle);
+        setupResolverControllers(personalResolvedComponentInfos);
+        final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+        waitForIdle();
+        assertThat(activity.getPersonalListAdapter().getCount(), is(3));
+        onView(withId(R.id.tabs)).check(matches(not(isDisplayed())));
+        assertEquals(activity.getMultiProfilePagerAdapterCount(), 1);
+        for (ResolvedComponentInfo resolvedInfo : personalResolvedComponentInfos) {
+            assertEquals(resolvedInfo.getResolveInfoAt(0).userHandle,
+                    sOverrides.workProfileUserHandle);
+        }
+    }
+
     private Intent createSendImageIntent() {
         Intent sendIntent = new Intent();
         sendIntent.setAction(Intent.ACTION_SEND);
@@ -1339,6 +1385,10 @@
         ResolverWrapperActivity.sOverrides.privateProfileUserHandle = UserHandle.of(12);
     }
 
+    private void setTabOwnerUserHandleForLaunch(UserHandle tabOwnerUserHandleForLaunch) {
+        sOverrides.tabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch;
+    }
+
     private void setupResolverControllers(
             List<ResolvedComponentInfo> personalResolvedComponentInfos,
             List<ResolvedComponentInfo> workResolvedComponentInfos) {
diff --git a/core/tests/coretests/src/com/android/internal/app/ResolverWrapperActivity.java b/core/tests/coretests/src/com/android/internal/app/ResolverWrapperActivity.java
index 862cbd5..4604b01 100644
--- a/core/tests/coretests/src/com/android/internal/app/ResolverWrapperActivity.java
+++ b/core/tests/coretests/src/com/android/internal/app/ResolverWrapperActivity.java
@@ -116,6 +116,10 @@
             when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM);
             return sOverrides.resolverListController;
         }
+        if (isLaunchedInSingleUserMode()) {
+            when(sOverrides.resolverListController.getUserHandle()).thenReturn(userHandle);
+            return sOverrides.resolverListController;
+        }
         when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle);
         return sOverrides.workResolverListController;
     }
diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/FromSplitScreenEnterPipOnUserLeaveHintTest.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/FromSplitScreenEnterPipOnUserLeaveHintTest.kt
index 1ccc7d8..5f25d70 100644
--- a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/FromSplitScreenEnterPipOnUserLeaveHintTest.kt
+++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/FromSplitScreenEnterPipOnUserLeaveHintTest.kt
@@ -24,6 +24,7 @@
 import android.tools.flicker.legacy.LegacyFlickerTestFactory
 import android.tools.helpers.WindowUtils
 import android.tools.traces.parsers.toFlickerComponent
+import androidx.test.filters.FlakyTest
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.helpers.SimpleAppHelper
 import com.android.server.wm.flicker.testapp.ActivityOptions
@@ -181,6 +182,12 @@
         }
     }
 
+    /** {@inheritDoc} */
+    @FlakyTest(bugId = 312446524)
+    @Test
+    override fun visibleLayersShownMoreThanOneConsecutiveEntry() =
+        super.visibleLayersShownMoreThanOneConsecutiveEntry()
+
     companion object {
         @Parameterized.Parameters(name = "{0}")
         @JvmStatic
diff --git a/media/java/android/media/MediaCas.java b/media/java/android/media/MediaCas.java
index ab7c27f..2d7db5e 100644
--- a/media/java/android/media/MediaCas.java
+++ b/media/java/android/media/MediaCas.java
@@ -35,6 +35,7 @@
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.HandlerThread;
+import android.os.IBinder;
 import android.os.IHwBinder;
 import android.os.Looper;
 import android.os.Message;
@@ -43,7 +44,6 @@
 import android.os.ServiceManager;
 import android.os.ServiceSpecificException;
 import android.util.Log;
-import android.util.Singleton;
 
 import com.android.internal.util.FrameworkStatsLog;
 
@@ -264,71 +264,107 @@
     public static final int PLUGIN_STATUS_SESSION_NUMBER_CHANGED =
             android.hardware.cas.StatusEvent.PLUGIN_SESSION_NUMBER_CHANGED;
 
-    private static final Singleton<IMediaCasService> sService =
-            new Singleton<IMediaCasService>() {
+    private static IMediaCasService sService = null;
+    private static Object sAidlLock = new Object();
+
+    /** DeathListener for AIDL service */
+    private static IBinder.DeathRecipient sDeathListener =
+            new IBinder.DeathRecipient() {
                 @Override
-                protected IMediaCasService create() {
-                    try {
-                        Log.d(TAG, "Trying to get AIDL service");
-                        IMediaCasService serviceAidl =
-                                IMediaCasService.Stub.asInterface(
-                                        ServiceManager.waitForDeclaredService(
-                                                IMediaCasService.DESCRIPTOR + "/default"));
-                        if (serviceAidl != null) {
-                            return serviceAidl;
-                        }
-                    } catch (Exception eAidl) {
-                        Log.d(TAG, "Failed to get cas AIDL service");
+                public void binderDied() {
+                    synchronized (sAidlLock) {
+                        Log.d(TAG, "The service is dead");
+                        sService.asBinder().unlinkToDeath(sDeathListener, 0);
+                        sService = null;
                     }
-                    return null;
-                }
-            };
-
-    private static final Singleton<android.hardware.cas.V1_0.IMediaCasService> sServiceHidl =
-            new Singleton<android.hardware.cas.V1_0.IMediaCasService>() {
-                @Override
-                protected android.hardware.cas.V1_0.IMediaCasService create() {
-                    try {
-                        Log.d(TAG, "Trying to get cas@1.2 service");
-                        android.hardware.cas.V1_2.IMediaCasService serviceV12 =
-                                android.hardware.cas.V1_2.IMediaCasService.getService(
-                                        true /*wait*/);
-                        if (serviceV12 != null) {
-                            return serviceV12;
-                        }
-                    } catch (Exception eV1_2) {
-                        Log.d(TAG, "Failed to get cas@1.2 service");
-                    }
-
-                    try {
-                        Log.d(TAG, "Trying to get cas@1.1 service");
-                        android.hardware.cas.V1_1.IMediaCasService serviceV11 =
-                                android.hardware.cas.V1_1.IMediaCasService.getService(
-                                        true /*wait*/);
-                        if (serviceV11 != null) {
-                            return serviceV11;
-                        }
-                    } catch (Exception eV1_1) {
-                        Log.d(TAG, "Failed to get cas@1.1 service");
-                    }
-
-                    try {
-                        Log.d(TAG, "Trying to get cas@1.0 service");
-                        return android.hardware.cas.V1_0.IMediaCasService.getService(true /*wait*/);
-                    } catch (Exception eV1_0) {
-                        Log.d(TAG, "Failed to get cas@1.0 service");
-                    }
-
-                    return null;
                 }
             };
 
     static IMediaCasService getService() {
-        return sService.get();
+        synchronized (sAidlLock) {
+            if (sService == null || !sService.asBinder().isBinderAlive()) {
+                try {
+                    Log.d(TAG, "Trying to get AIDL service");
+                    sService =
+                            IMediaCasService.Stub.asInterface(
+                                    ServiceManager.waitForDeclaredService(
+                                            IMediaCasService.DESCRIPTOR + "/default"));
+                    if (sService != null) {
+                        sService.asBinder().linkToDeath(sDeathListener, 0);
+                    }
+                } catch (Exception eAidl) {
+                    Log.d(TAG, "Failed to get cas AIDL service");
+                }
+            }
+            return sService;
+        }
     }
 
+    private static android.hardware.cas.V1_0.IMediaCasService sServiceHidl = null;
+    private static Object sHidlLock = new Object();
+
+    /** Used to indicate the right end-point to handle the serviceDied method */
+    private static final long MEDIA_CAS_HIDL_COOKIE = 394;
+
+    /** DeathListener for HIDL service */
+    private static IHwBinder.DeathRecipient sDeathListenerHidl =
+            new IHwBinder.DeathRecipient() {
+                @Override
+                public void serviceDied(long cookie) {
+                    if (cookie == MEDIA_CAS_HIDL_COOKIE) {
+                        synchronized (sHidlLock) {
+                            sServiceHidl = null;
+                        }
+                    }
+                }
+            };
+
     static android.hardware.cas.V1_0.IMediaCasService getServiceHidl() {
-        return sServiceHidl.get();
+        synchronized (sHidlLock) {
+            if (sServiceHidl != null) {
+                return sServiceHidl;
+            } else {
+                try {
+                    Log.d(TAG, "Trying to get cas@1.2 service");
+                    android.hardware.cas.V1_2.IMediaCasService serviceV12 =
+                            android.hardware.cas.V1_2.IMediaCasService.getService(true /*wait*/);
+                    if (serviceV12 != null) {
+                        sServiceHidl = serviceV12;
+                        sServiceHidl.linkToDeath(sDeathListenerHidl, MEDIA_CAS_HIDL_COOKIE);
+                        return sServiceHidl;
+                    }
+                } catch (Exception eV1_2) {
+                    Log.d(TAG, "Failed to get cas@1.2 service");
+                }
+
+                try {
+                    Log.d(TAG, "Trying to get cas@1.1 service");
+                    android.hardware.cas.V1_1.IMediaCasService serviceV11 =
+                            android.hardware.cas.V1_1.IMediaCasService.getService(true /*wait*/);
+                    if (serviceV11 != null) {
+                        sServiceHidl = serviceV11;
+                        sServiceHidl.linkToDeath(sDeathListenerHidl, MEDIA_CAS_HIDL_COOKIE);
+                        return sServiceHidl;
+                    }
+                } catch (Exception eV1_1) {
+                    Log.d(TAG, "Failed to get cas@1.1 service");
+                }
+
+                try {
+                    Log.d(TAG, "Trying to get cas@1.0 service");
+                    sServiceHidl =
+                            android.hardware.cas.V1_0.IMediaCasService.getService(true /*wait*/);
+                    if (sServiceHidl != null) {
+                        sServiceHidl.linkToDeath(sDeathListenerHidl, MEDIA_CAS_HIDL_COOKIE);
+                    }
+                    return sServiceHidl;
+                } catch (Exception eV1_0) {
+                    Log.d(TAG, "Failed to get cas@1.0 service");
+                }
+            }
+        }
+        // Couldn't find an HIDL service, returning null.
+        return null;
     }
 
     private void validateInternalStates() {
@@ -756,7 +792,7 @@
      * @return Whether the specified CA system is supported on this device.
      */
     public static boolean isSystemIdSupported(int CA_system_id) {
-        IMediaCasService service = sService.get();
+        IMediaCasService service = getService();
         if (service != null) {
             try {
                 return service.isSystemIdSupported(CA_system_id);
@@ -765,7 +801,7 @@
             }
         }
 
-        android.hardware.cas.V1_0.IMediaCasService serviceHidl = sServiceHidl.get();
+        android.hardware.cas.V1_0.IMediaCasService serviceHidl = getServiceHidl();
         if (serviceHidl != null) {
             try {
                 return serviceHidl.isSystemIdSupported(CA_system_id);
@@ -781,7 +817,7 @@
      * @return an array of descriptors for the available CA plugins.
      */
     public static PluginDescriptor[] enumeratePlugins() {
-        IMediaCasService service = sService.get();
+        IMediaCasService service = getService();
         if (service != null) {
             try {
                 AidlCasPluginDescriptor[] descriptors = service.enumeratePlugins();
@@ -794,10 +830,11 @@
                 }
                 return results;
             } catch (RemoteException e) {
+                Log.e(TAG, "Some exception while enumerating plugins");
             }
         }
 
-        android.hardware.cas.V1_0.IMediaCasService serviceHidl = sServiceHidl.get();
+        android.hardware.cas.V1_0.IMediaCasService serviceHidl = getServiceHidl();
         if (serviceHidl != null) {
             try {
                 ArrayList<HidlCasPluginDescriptor> descriptors = serviceHidl.enumeratePlugins();
diff --git a/native/android/surface_control_input_receiver.cpp b/native/android/surface_control_input_receiver.cpp
index da0defd..d178abc 100644
--- a/native/android/surface_control_input_receiver.cpp
+++ b/native/android/surface_control_input_receiver.cpp
@@ -45,6 +45,8 @@
             mClientToken(clientToken),
             mInputTransferToken(inputTransferToken) {}
 
+    // The InputConsumer does not keep the InputReceiver alive so the receiver is cleared once the
+    // owner releases it.
     ~InputReceiver() {
         remove();
     }
diff --git a/nfc/api/current.txt b/nfc/api/current.txt
index da292a81..80b2be2 100644
--- a/nfc/api/current.txt
+++ b/nfc/api/current.txt
@@ -268,10 +268,9 @@
   }
 
   @FlaggedApi("android.nfc.nfc_read_polling_loop") public final class PollingFrame implements android.os.Parcelable {
-    ctor public PollingFrame(int, @Nullable byte[], int, int, boolean);
     method public int describeContents();
     method @NonNull public byte[] getData();
-    method public int getTimestamp();
+    method public long getTimestamp();
     method public boolean getTriggeredAutoTransact();
     method public int getType();
     method public int getVendorSpecificGain();
diff --git a/nfc/java/android/nfc/cardemulation/PollingFrame.java b/nfc/java/android/nfc/cardemulation/PollingFrame.java
index af63a6e..654e8cc 100644
--- a/nfc/java/android/nfc/cardemulation/PollingFrame.java
+++ b/nfc/java/android/nfc/cardemulation/PollingFrame.java
@@ -16,6 +16,7 @@
 
 package android.nfc.cardemulation;
 
+import android.annotation.DurationMillisLong;
 import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
 import android.annotation.NonNull;
@@ -148,7 +149,8 @@
     private final int mType;
     private final byte[] mData;
     private final int mGain;
-    private final int mTimestamp;
+    @DurationMillisLong
+    private final long mTimestamp;
     private final boolean mTriggeredAutoTransact;
 
     public static final @NonNull Parcelable.Creator<PollingFrame> CREATOR =
@@ -180,16 +182,18 @@
      * @param type the type of the frame
      * @param data a byte array of the data contained in the frame
      * @param gain the vendor-specific gain of the field
-     * @param timestamp the timestamp in millisecones
+     * @param timestampMillis the timestamp in millisecones
      * @param triggeredAutoTransact whether or not this frame triggered the device to start a
      * transaction automatically
+     *
+     * @hide
      */
     public PollingFrame(@PollingFrameType int type, @Nullable byte[] data,
-            int gain, int timestamp, boolean triggeredAutoTransact) {
+            int gain, @DurationMillisLong long timestampMillis, boolean triggeredAutoTransact) {
         mType = type;
         mData = data == null ? new byte[0] : data;
         mGain = gain;
-        mTimestamp = timestamp;
+        mTimestamp = timestampMillis;
         mTriggeredAutoTransact = triggeredAutoTransact;
     }
 
@@ -230,7 +234,7 @@
      * frames relative to each other.
      * @return the timestamp in milliseconds
      */
-    public int getTimestamp() {
+    public @DurationMillisLong long getTimestamp() {
         return mTimestamp;
     }
 
@@ -264,7 +268,7 @@
             frame.putInt(KEY_POLLING_LOOP_GAIN, (byte) getVendorSpecificGain());
         }
         frame.putByteArray(KEY_POLLING_LOOP_DATA, getData());
-        frame.putInt(KEY_POLLING_LOOP_TIMESTAMP, getTimestamp());
+        frame.putLong(KEY_POLLING_LOOP_TIMESTAMP, getTimestamp());
         frame.putBoolean(KEY_POLLING_LOOP_TRIGGERED_AUTOTRANSACT, getTriggeredAutoTransact());
         return frame;
     }
@@ -273,7 +277,7 @@
     public String toString() {
         return "PollingFrame { Type: " + (char) getType()
                 + ", gain: " + getVendorSpecificGain()
-                + ", timestamp: " + Integer.toUnsignedString(getTimestamp())
+                + ", timestamp: " + Long.toUnsignedString(getTimestamp())
                 + ", data: [" + HexFormat.ofDelimiter(" ").formatHex(getData()) + "] }";
     }
 }
diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt
new file mode 100644
index 0000000..b52586c
--- /dev/null
+++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.datastore
+
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicInteger
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.any
+import org.mockito.kotlin.never
+import org.mockito.kotlin.reset
+import org.mockito.kotlin.verify
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class KeyedObserverTest {
+    @get:Rule
+    val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+    @Mock
+    private lateinit var observer1: KeyedObserver<Any?>
+
+    @Mock
+    private lateinit var observer2: KeyedObserver<Any?>
+
+    @Mock
+    private lateinit var keyedObserver1: KeyedObserver<Any>
+
+    @Mock
+    private lateinit var keyedObserver2: KeyedObserver<Any>
+
+    @Mock
+    private lateinit var key1: Any
+
+    @Mock
+    private lateinit var key2: Any
+
+    @Mock
+    private lateinit var executor: Executor
+
+    private val keyedObservable = KeyedDataObservable<Any>()
+
+    @Test
+    fun addObserver_sameExecutor() {
+        keyedObservable.addObserver(observer1, executor)
+        keyedObservable.addObserver(observer1, executor)
+    }
+
+    @Test
+    fun addObserver_keyedObserver_sameExecutor() {
+        keyedObservable.addObserver(key1, keyedObserver1, executor)
+        keyedObservable.addObserver(key1, keyedObserver1, executor)
+    }
+
+    @Test
+    fun addObserver_differentExecutor() {
+        keyedObservable.addObserver(observer1, executor)
+        Assert.assertThrows(IllegalStateException::class.java) {
+            keyedObservable.addObserver(observer1, directExecutor())
+        }
+    }
+
+    @Test
+    fun addObserver_keyedObserver_differentExecutor() {
+        keyedObservable.addObserver(key1, keyedObserver1, executor)
+        Assert.assertThrows(IllegalStateException::class.java) {
+            keyedObservable.addObserver(key1, keyedObserver1, directExecutor())
+        }
+    }
+
+    @Test
+    fun addObserver_weaklyReferenced() {
+        val counter = AtomicInteger()
+        var observer: KeyedObserver<Any?>? = KeyedObserver { _, _ -> counter.incrementAndGet() }
+        keyedObservable.addObserver(observer!!, directExecutor())
+
+        keyedObservable.notifyChange(ChangeReason.UPDATE)
+        assertThat(counter.get()).isEqualTo(1)
+
+        // trigger GC, the observer callback should not be invoked
+        null.also { observer = it }
+        System.gc()
+        System.runFinalization()
+
+        keyedObservable.notifyChange(ChangeReason.UPDATE)
+        assertThat(counter.get()).isEqualTo(1)
+    }
+
+    @Test
+    fun addObserver_keyedObserver_weaklyReferenced() {
+        val counter = AtomicInteger()
+        var keyObserver: KeyedObserver<Any>? = KeyedObserver { _, _ -> counter.incrementAndGet() }
+        keyedObservable.addObserver(key1, keyObserver!!, directExecutor())
+
+        keyedObservable.notifyChange(key1, ChangeReason.UPDATE)
+        assertThat(counter.get()).isEqualTo(1)
+
+        // trigger GC, the observer callback should not be invoked
+        null.also { keyObserver = it }
+        System.gc()
+        System.runFinalization()
+
+        keyedObservable.notifyChange(key1, ChangeReason.UPDATE)
+        assertThat(counter.get()).isEqualTo(1)
+    }
+
+    @Test
+    fun addObserver_notifyObservers_removeObserver() {
+        keyedObservable.addObserver(observer1, directExecutor())
+        keyedObservable.addObserver(observer2, executor)
+
+        keyedObservable.notifyChange(ChangeReason.UPDATE)
+        verify(observer1).onKeyChanged(null, ChangeReason.UPDATE)
+        verify(observer2, never()).onKeyChanged(any(), any())
+        verify(executor).execute(any())
+
+        reset(observer1, executor)
+        keyedObservable.removeObserver(observer2)
+
+        keyedObservable.notifyChange(ChangeReason.DELETE)
+        verify(observer1).onKeyChanged(null, ChangeReason.DELETE)
+        verify(executor, never()).execute(any())
+    }
+
+    @Test
+    fun addObserver_keyedObserver_notifyObservers_removeObserver() {
+        keyedObservable.addObserver(key1, keyedObserver1, directExecutor())
+        keyedObservable.addObserver(key2, keyedObserver2, executor)
+
+        keyedObservable.notifyChange(key1, ChangeReason.UPDATE)
+        verify(keyedObserver1).onKeyChanged(key1, ChangeReason.UPDATE)
+        verify(keyedObserver2, never()).onKeyChanged(any(), any())
+        verify(executor, never()).execute(any())
+
+        reset(keyedObserver1, executor)
+        keyedObservable.removeObserver(key2, keyedObserver2)
+
+        keyedObservable.notifyChange(key1, ChangeReason.DELETE)
+        verify(keyedObserver1).onKeyChanged(key1, ChangeReason.DELETE)
+        verify(executor, never()).execute(any())
+    }
+
+    @Test
+    fun notifyChange_addMoreTypeObservers_checkOnKeyChanged() {
+        keyedObservable.addObserver(observer1, directExecutor())
+        keyedObservable.addObserver(key1, keyedObserver1, directExecutor())
+        keyedObservable.addObserver(key2, keyedObserver2, directExecutor())
+
+        keyedObservable.notifyChange(ChangeReason.UPDATE)
+        verify(observer1).onKeyChanged(null, ChangeReason.UPDATE)
+        verify(keyedObserver1).onKeyChanged(key1, ChangeReason.UPDATE)
+        verify(keyedObserver2).onKeyChanged(key2, ChangeReason.UPDATE)
+
+        reset(observer1, keyedObserver1, keyedObserver2)
+        keyedObservable.notifyChange(key1, ChangeReason.UPDATE)
+
+        verify(observer1).onKeyChanged(key1, ChangeReason.UPDATE)
+        verify(keyedObserver1).onKeyChanged(key1, ChangeReason.UPDATE)
+        verify(keyedObserver2, never()).onKeyChanged(key1, ChangeReason.UPDATE)
+
+        reset(observer1, keyedObserver1, keyedObserver2)
+        keyedObservable.notifyChange(key2, ChangeReason.UPDATE)
+
+        verify(observer1).onKeyChanged(key2, ChangeReason.UPDATE)
+        verify(keyedObserver1, never()).onKeyChanged(key2, ChangeReason.UPDATE)
+        verify(keyedObserver2).onKeyChanged(key2, ChangeReason.UPDATE)
+    }
+
+    @Test
+    fun notifyChange_addObserverWithinCallback() {
+        // ConcurrentModificationException is raised if it is not implemented correctly
+        val observer: KeyedObserver<Any?> = KeyedObserver { _, _ ->
+            keyedObservable.addObserver(observer1, executor)
+        }
+
+        keyedObservable.addObserver(observer, directExecutor())
+
+        keyedObservable.notifyChange(ChangeReason.UPDATE)
+        keyedObservable.removeObserver(observer)
+    }
+
+    @Test
+    fun notifyChange_KeyedObserver_addObserverWithinCallback() {
+        // ConcurrentModificationException is raised if it is not implemented correctly
+        val keyObserver: KeyedObserver<Any?> = KeyedObserver { _, _ ->
+            keyedObservable.addObserver(key1, keyedObserver1, executor)
+        }
+
+        keyedObservable.addObserver(key1, keyObserver, directExecutor())
+
+        keyedObservable.notifyChange(key1, ChangeReason.UPDATE)
+        keyedObservable.removeObserver(key1, keyObserver)
+    }
+}
\ No newline at end of file
diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt
index bb791dc..f065829 100644
--- a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt
+++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt
@@ -69,8 +69,7 @@
         assertThat(counter.get()).isEqualTo(1)
 
         // trigger GC, the observer callback should not be invoked
-        @Suppress("unused")
-        observer = null
+        null.also { observer = it }
         System.gc()
         System.runFinalization()
 
@@ -100,10 +99,12 @@
     @Test
     fun notifyChange_addObserverWithinCallback() {
         // ConcurrentModificationException is raised if it is not implemented correctly
+        val observer = Observer { observable.addObserver(observer1, executor) }
         observable.addObserver(
-            { observable.addObserver(observer1, executor) },
+            observer,
             MoreExecutors.directExecutor()
         )
         observable.notifyChange(ChangeReason.UPDATE)
+        observable.removeObserver(observer)
     }
 }
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java
index b8624fd..4777b0d 100644
--- a/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java
@@ -1315,8 +1315,7 @@
                 boolean isActiveAshaHearingAid = mIsActiveDeviceHearingAid;
                 boolean isActiveLeAudioHearingAid = mIsActiveDeviceLeAudio
                         && isConnectedHapClientDevice();
-                if ((isActiveAshaHearingAid || isActiveLeAudioHearingAid)
-                        && stringRes == R.string.bluetooth_active_no_battery_level) {
+                if (isActiveAshaHearingAid || isActiveLeAudioHearingAid) {
                     final Set<CachedBluetoothDevice> memberDevices = getMemberDevice();
                     final CachedBluetoothDevice subDevice = getSubDevice();
                     if (memberDevices.stream().anyMatch(m -> m.isConnected())) {
diff --git a/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt b/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
index cda6b8b..68f471d 100644
--- a/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
@@ -17,6 +17,7 @@
 package com.android.settingslib.media.session
 
 import android.media.session.MediaController
+import android.media.session.MediaSession
 import android.media.session.MediaSessionManager
 import android.os.UserHandle
 import androidx.concurrent.futures.DirectExecutor
@@ -28,7 +29,7 @@
 import kotlinx.coroutines.launch
 
 /** [Flow] for [MediaSessionManager.OnActiveSessionsChangedListener]. */
-val MediaSessionManager.activeMediaChanges: Flow<Collection<MediaController>?>
+val MediaSessionManager.activeMediaChanges: Flow<List<MediaController>?>
     get() =
         callbackFlow {
                 val listener =
@@ -42,3 +43,24 @@
                 awaitClose { removeOnActiveSessionsChangedListener(listener) }
             }
             .buffer(capacity = Channel.CONFLATED)
+
+/** [Flow] for [MediaSessionManager.RemoteSessionCallback]. */
+val MediaSessionManager.remoteSessionChanges: Flow<MediaSession.Token?>
+    get() =
+        callbackFlow {
+                val callback =
+                    object : MediaSessionManager.RemoteSessionCallback {
+                        override fun onVolumeChanged(sessionToken: MediaSession.Token, flags: Int) {
+                            launch { send(sessionToken) }
+                        }
+
+                        override fun onDefaultRemoteSessionChanged(
+                            sessionToken: MediaSession.Token?
+                        ) {
+                            launch { send(sessionToken) }
+                        }
+                    }
+                registerRemoteSessionCallback(DirectExecutor.INSTANCE, callback)
+                awaitClose { unregisterRemoteSessionCallback(callback) }
+            }
+            .buffer(capacity = Channel.CONFLATED)
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/LocalMediaRepository.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/LocalMediaRepository.kt
index 298dd71e..724dd51 100644
--- a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/LocalMediaRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/LocalMediaRepository.kt
@@ -15,14 +15,10 @@
  */
 package com.android.settingslib.volume.data.repository
 
-import android.media.MediaRouter2Manager
-import android.media.RoutingSessionInfo
 import com.android.settingslib.media.LocalMediaManager
 import com.android.settingslib.media.MediaDevice
-import com.android.settingslib.volume.data.model.RoutingSession
 import com.android.settingslib.volume.shared.AudioManagerEventsReceiver
 import com.android.settingslib.volume.shared.model.AudioManagerEvent
-import kotlin.coroutines.CoroutineContext
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
@@ -30,35 +26,23 @@
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.shareIn
 import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.withContext
 
 /** Repository providing data about connected media devices. */
 interface LocalMediaRepository {
 
-    /** Available devices list */
-    val mediaDevices: StateFlow<Collection<MediaDevice>>
-
     /** Currently connected media device */
     val currentConnectedDevice: StateFlow<MediaDevice?>
-
-    val remoteRoutingSessions: StateFlow<Collection<RoutingSession>>
-
-    suspend fun adjustSessionVolume(sessionId: String?, volume: Int)
 }
 
 class LocalMediaRepositoryImpl(
     audioManagerEventsReceiver: AudioManagerEventsReceiver,
     private val localMediaManager: LocalMediaManager,
-    private val mediaRouter2Manager: MediaRouter2Manager,
     coroutineScope: CoroutineScope,
-    private val backgroundContext: CoroutineContext,
 ) : LocalMediaRepository {
 
     private val devicesChanges =
@@ -94,18 +78,6 @@
             }
             .shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 0)
 
-    override val mediaDevices: StateFlow<Collection<MediaDevice>> =
-        mediaDevicesUpdates
-            .mapNotNull {
-                if (it is DevicesUpdate.DeviceListUpdate) {
-                    it.newDevices ?: emptyList()
-                } else {
-                    null
-                }
-            }
-            .flowOn(backgroundContext)
-            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())
-
     override val currentConnectedDevice: StateFlow<MediaDevice?> =
         merge(devicesChanges, mediaDevicesUpdates)
             .map { localMediaManager.currentConnectedDevice }
@@ -116,30 +88,6 @@
                 localMediaManager.currentConnectedDevice
             )
 
-    override val remoteRoutingSessions: StateFlow<Collection<RoutingSession>> =
-        merge(devicesChanges, mediaDevicesUpdates)
-            .onStart { emit(Unit) }
-            .map { localMediaManager.remoteRoutingSessions.map(::toRoutingSession) }
-            .flowOn(backgroundContext)
-            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())
-
-    override suspend fun adjustSessionVolume(sessionId: String?, volume: Int) {
-        withContext(backgroundContext) {
-            if (sessionId == null) {
-                localMediaManager.adjustSessionVolume(volume)
-            } else {
-                localMediaManager.adjustSessionVolume(sessionId, volume)
-            }
-        }
-    }
-
-    private fun toRoutingSession(info: RoutingSessionInfo): RoutingSession =
-        RoutingSession(
-            info,
-            isMediaOutputDisabled = mediaRouter2Manager.getTransferableRoutes(info).isEmpty(),
-            isVolumeSeekBarEnabled = localMediaManager.shouldEnableVolumeSeekBar(info)
-        )
-
     private sealed interface DevicesUpdate {
 
         data class DeviceListUpdate(val newDevices: List<MediaDevice>?) : DevicesUpdate
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
index 7c231d1..e4ac9fe 100644
--- a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
@@ -27,18 +27,26 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.emptyFlow
 import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.stateIn
 
 /** Provides controllers for currently active device media sessions. */
 interface MediaControllerRepository {
 
-    /** Current [MediaController]. Null is emitted when there is no active [MediaController]. */
-    val activeLocalMediaController: StateFlow<MediaController?>
+    /**
+     * Get a list of controllers for all ongoing sessions. The controllers will be provided in
+     * priority order with the most important controller at index 0.
+     *
+     * This requires the [android.Manifest.permission.MEDIA_CONTENT_CONTROL] permission be held by
+     * the calling app.
+     */
+    val activeSessions: StateFlow<List<MediaController>>
 }
 
 class MediaControllerRepositoryImpl(
@@ -49,51 +57,17 @@
     backgroundContext: CoroutineContext,
 ) : MediaControllerRepository {
 
-    private val devicesChanges =
-        audioManagerEventsReceiver.events.filterIsInstance(
-            AudioManagerEvent.StreamDevicesChanged::class
-        )
-
-    override val activeLocalMediaController: StateFlow<MediaController?> =
-        combine(
-                mediaSessionManager.activeMediaChanges.onStart {
-                    emit(mediaSessionManager.getActiveSessions(null))
-                },
-                localBluetoothManager?.headsetAudioModeChanges?.onStart { emit(Unit) }
-                    ?: flowOf(null),
-                devicesChanges.onStart { emit(AudioManagerEvent.StreamDevicesChanged) },
-            ) { controllers, _, _ ->
-                controllers?.let(::findLocalMediaController)
-            }
+    override val activeSessions: StateFlow<List<MediaController>> =
+        merge(
+                mediaSessionManager.activeMediaChanges.filterNotNull(),
+                localBluetoothManager?.headsetAudioModeChanges?.map {
+                    mediaSessionManager.getActiveSessions(null)
+                } ?: emptyFlow(),
+                audioManagerEventsReceiver.events
+                    .filterIsInstance(AudioManagerEvent.StreamDevicesChanged::class)
+                    .map { mediaSessionManager.getActiveSessions(null) },
+            )
+            .onStart { emit(mediaSessionManager.getActiveSessions(null)) }
             .flowOn(backgroundContext)
-            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null)
-
-    private fun findLocalMediaController(
-        controllers: Collection<MediaController>,
-    ): MediaController? {
-        var localController: MediaController? = null
-        val remoteMediaSessionLists: MutableList<String> = ArrayList()
-        for (controller in controllers) {
-            val playbackInfo: MediaController.PlaybackInfo = controller.playbackInfo ?: continue
-            when (playbackInfo.playbackType) {
-                MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE -> {
-                    if (localController?.packageName.equals(controller.packageName)) {
-                        localController = null
-                    }
-                    if (!remoteMediaSessionLists.contains(controller.packageName)) {
-                        remoteMediaSessionLists.add(controller.packageName)
-                    }
-                }
-                MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL -> {
-                    if (
-                        localController == null &&
-                            !remoteMediaSessionLists.contains(controller.packageName)
-                    ) {
-                        localController = controller
-                    }
-                }
-            }
-        }
-        return localController
-    }
+            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())
 }
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/LocalMediaInteractor.kt b/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/LocalMediaInteractor.kt
deleted file mode 100644
index f621335..0000000
--- a/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/LocalMediaInteractor.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- *  Licensed under the Apache License, Version 2.0 (the "License");
- *  you may not use this file except in compliance with the License.
- *  You may obtain a copy of the License at
- *
- *       http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
- */
-
-package com.android.settingslib.volume.domain.interactor
-
-import com.android.settingslib.media.MediaDevice
-import com.android.settingslib.volume.data.repository.LocalMediaRepository
-import com.android.settingslib.volume.domain.model.RoutingSession
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-
-class LocalMediaInteractor(
-    private val repository: LocalMediaRepository,
-    coroutineScope: CoroutineScope,
-) {
-
-    /** Available devices list */
-    val mediaDevices: StateFlow<Collection<MediaDevice>>
-        get() = repository.mediaDevices
-
-    /** Currently connected media device */
-    val currentConnectedDevice: StateFlow<MediaDevice?>
-        get() = repository.currentConnectedDevice
-
-    val remoteRoutingSessions: StateFlow<List<RoutingSession>> =
-        repository.remoteRoutingSessions
-            .map { sessions ->
-                sessions.map {
-                    RoutingSession(
-                        routingSessionInfo = it.routingSessionInfo,
-                        isMediaOutputDisabled = it.isMediaOutputDisabled,
-                        isVolumeSeekBarEnabled =
-                            it.isVolumeSeekBarEnabled && it.routingSessionInfo.volumeMax > 0
-                    )
-                }
-            }
-            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())
-
-    suspend fun adjustSessionVolume(sessionId: String?, volume: Int) =
-        repository.adjustSessionVolume(sessionId, volume)
-}
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/LocalMediaRepositoryImplTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/LocalMediaRepositoryImplTest.kt
index 2d12dae..caf41f2 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/LocalMediaRepositoryImplTest.kt
+++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/LocalMediaRepositoryImplTest.kt
@@ -15,17 +15,12 @@
  */
 package com.android.settingslib.volume.data.repository
 
-import android.media.MediaRoute2Info
-import android.media.MediaRouter2Manager
-import android.media.RoutingSessionInfo
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.android.settingslib.media.LocalMediaManager
 import com.android.settingslib.media.MediaDevice
-import com.android.settingslib.volume.data.model.RoutingSession
 import com.android.settingslib.volume.shared.FakeAudioManagerEventsReceiver
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.test.TestScope
@@ -37,15 +32,10 @@
 import org.mockito.ArgumentCaptor
 import org.mockito.Captor
 import org.mockito.Mock
-import org.mockito.Mockito.any
-import org.mockito.Mockito.anyInt
-import org.mockito.Mockito.anyString
-import org.mockito.Mockito.mock
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
 
-@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
 class LocalMediaRepositoryImplTest {
@@ -53,7 +43,6 @@
     @Mock private lateinit var localMediaManager: LocalMediaManager
     @Mock private lateinit var mediaDevice1: MediaDevice
     @Mock private lateinit var mediaDevice2: MediaDevice
-    @Mock private lateinit var mediaRouter2Manager: MediaRouter2Manager
 
     @Captor
     private lateinit var deviceCallbackCaptor: ArgumentCaptor<LocalMediaManager.DeviceCallback>
@@ -71,29 +60,11 @@
             LocalMediaRepositoryImpl(
                 eventsReceiver,
                 localMediaManager,
-                mediaRouter2Manager,
                 testScope.backgroundScope,
-                testScope.testScheduler,
             )
     }
 
     @Test
-    fun mediaDevices_areUpdated() {
-        testScope.runTest {
-            var mediaDevices: Collection<MediaDevice>? = null
-            underTest.mediaDevices.onEach { mediaDevices = it }.launchIn(backgroundScope)
-            runCurrent()
-            verify(localMediaManager).registerCallback(deviceCallbackCaptor.capture())
-            deviceCallbackCaptor.value.onDeviceListUpdate(listOf(mediaDevice1, mediaDevice2))
-            runCurrent()
-
-            assertThat(mediaDevices).hasSize(2)
-            assertThat(mediaDevices).contains(mediaDevice1)
-            assertThat(mediaDevices).contains(mediaDevice2)
-        }
-    }
-
-    @Test
     fun deviceListUpdated_currentConnectedDeviceUpdated() {
         testScope.runTest {
             var currentConnectedDevice: MediaDevice? = null
@@ -110,78 +81,4 @@
             assertThat(currentConnectedDevice).isEqualTo(mediaDevice1)
         }
     }
-
-    @Test
-    fun kek() {
-        testScope.runTest {
-            `when`(localMediaManager.remoteRoutingSessions)
-                .thenReturn(
-                    listOf(
-                        testRoutingSessionInfo1,
-                        testRoutingSessionInfo2,
-                        testRoutingSessionInfo3,
-                    )
-                )
-            `when`(localMediaManager.shouldEnableVolumeSeekBar(any())).then {
-                (it.arguments[0] as RoutingSessionInfo) == testRoutingSessionInfo1
-            }
-            `when`(mediaRouter2Manager.getTransferableRoutes(any<RoutingSessionInfo>())).then {
-                if ((it.arguments[0] as RoutingSessionInfo) == testRoutingSessionInfo2) {
-                    return@then listOf(mock(MediaRoute2Info::class.java))
-                }
-                emptyList<MediaRoute2Info>()
-            }
-            var remoteRoutingSessions: Collection<RoutingSession>? = null
-            underTest.remoteRoutingSessions
-                .onEach { remoteRoutingSessions = it }
-                .launchIn(backgroundScope)
-
-            runCurrent()
-
-            assertThat(remoteRoutingSessions)
-                .containsExactlyElementsIn(
-                    listOf(
-                        RoutingSession(
-                            routingSessionInfo = testRoutingSessionInfo1,
-                            isVolumeSeekBarEnabled = true,
-                            isMediaOutputDisabled = true,
-                        ),
-                        RoutingSession(
-                            routingSessionInfo = testRoutingSessionInfo2,
-                            isVolumeSeekBarEnabled = false,
-                            isMediaOutputDisabled = false,
-                        ),
-                        RoutingSession(
-                            routingSessionInfo = testRoutingSessionInfo3,
-                            isVolumeSeekBarEnabled = false,
-                            isMediaOutputDisabled = true,
-                        )
-                    )
-                )
-        }
-    }
-
-    @Test
-    fun adjustSessionVolume_adjusts() {
-        testScope.runTest {
-            var volume = 0
-            `when`(localMediaManager.adjustSessionVolume(anyString(), anyInt())).then {
-                volume = it.arguments[1] as Int
-                Unit
-            }
-
-            underTest.adjustSessionVolume("test_session", 10)
-
-            assertThat(volume).isEqualTo(10)
-        }
-    }
-
-    private companion object {
-        val testRoutingSessionInfo1 =
-            RoutingSessionInfo.Builder("id_1", "test.pkg.1").addSelectedRoute("route_1").build()
-        val testRoutingSessionInfo2 =
-            RoutingSessionInfo.Builder("id_2", "test.pkg.2").addSelectedRoute("route_2").build()
-        val testRoutingSessionInfo3 =
-            RoutingSessionInfo.Builder("id_3", "test.pkg.3").addSelectedRoute("route_3").build()
-    }
 }
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
index f3d1714..964c3f7 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
+++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
@@ -22,13 +22,10 @@
 import android.media.session.PlaybackState
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.settingslib.bluetooth.BluetoothCallback
 import com.android.settingslib.bluetooth.BluetoothEventManager
 import com.android.settingslib.bluetooth.LocalBluetoothManager
 import com.android.settingslib.volume.shared.FakeAudioManagerEventsReceiver
-import com.android.settingslib.volume.shared.model.AudioManagerEvent
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.test.TestScope
@@ -37,21 +34,15 @@
 import org.junit.Before
 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.any
-import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
 
-@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
 class MediaControllerRepositoryImplTest {
 
-    @Captor private lateinit var callbackCaptor: ArgumentCaptor<BluetoothCallback>
-
     @Mock private lateinit var mediaSessionManager: MediaSessionManager
     @Mock private lateinit var localBluetoothManager: LocalBluetoothManager
     @Mock private lateinit var eventManager: BluetoothEventManager
@@ -103,7 +94,7 @@
     }
 
     @Test
-    fun playingMediaDevicesAvailable_sessionIsActive() {
+    fun mediaDevicesAvailable_returnsAllActiveOnes() {
         testScope.runTest {
             `when`(mediaSessionManager.getActiveSessions(any()))
                 .thenReturn(
@@ -112,53 +103,25 @@
                         statelessMediaController,
                         errorMediaController,
                         remoteMediaController,
-                        localMediaController
+                        localMediaController,
                     )
                 )
-            var mediaController: MediaController? = null
-            underTest.activeLocalMediaController
-                .onEach { mediaController = it }
-                .launchIn(backgroundScope)
+
+            var mediaControllers: Collection<MediaController>? = null
+            underTest.activeSessions.onEach { mediaControllers = it }.launchIn(backgroundScope)
             runCurrent()
 
-            eventsReceiver.triggerEvent(AudioManagerEvent.StreamDevicesChanged)
-            triggerOnAudioModeChanged()
-            runCurrent()
-
-            assertThat(mediaController).isSameInstanceAs(localMediaController)
-        }
-    }
-
-    @Test
-    fun noPlayingMediaDevicesAvailable_sessionIsInactive() {
-        testScope.runTest {
-            `when`(mediaSessionManager.getActiveSessions(any()))
-                .thenReturn(
-                    listOf(
-                        stoppedMediaController,
-                        statelessMediaController,
-                        errorMediaController,
-                    )
+            assertThat(mediaControllers)
+                .containsExactly(
+                    stoppedMediaController,
+                    statelessMediaController,
+                    errorMediaController,
+                    remoteMediaController,
+                    localMediaController,
                 )
-            var mediaController: MediaController? = null
-            underTest.activeLocalMediaController
-                .onEach { mediaController = it }
-                .launchIn(backgroundScope)
-            runCurrent()
-
-            eventsReceiver.triggerEvent(AudioManagerEvent.StreamDevicesChanged)
-            triggerOnAudioModeChanged()
-            runCurrent()
-
-            assertThat(mediaController).isNull()
         }
     }
 
-    private fun triggerOnAudioModeChanged() {
-        verify(eventManager).registerCallback(callbackCaptor.capture())
-        callbackCaptor.value.onAudioModeChanged()
-    }
-
     private companion object {
         val statePlaying: PlaybackState =
             PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 0, 0f).build()
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerContent.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerContent.kt
index 621ddf7..1da6c1e 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerContent.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerContent.kt
@@ -53,6 +53,7 @@
 import androidx.compose.material3.Text
 import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -71,6 +72,7 @@
 import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import androidx.compose.ui.unit.times
 import com.android.compose.PlatformButton
 import com.android.compose.animation.scene.ElementKey
@@ -84,7 +86,9 @@
 import com.android.systemui.bouncer.ui.BouncerDialogFactory
 import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout
 import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
+import com.android.systemui.bouncer.ui.viewmodel.BouncerMessageViewModel
 import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
+import com.android.systemui.bouncer.ui.viewmodel.MessageViewModel
 import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
 import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
 import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
@@ -166,7 +170,7 @@
                 modifier = Modifier.fillMaxWidth(),
             ) {
                 StatusMessage(
-                    viewModel = viewModel,
+                    viewModel = viewModel.message,
                     modifier = Modifier,
                 )
 
@@ -228,7 +232,7 @@
             when (authMethod) {
                 is PinBouncerViewModel -> {
                     StatusMessage(
-                        viewModel = viewModel,
+                        viewModel = viewModel.message,
                         modifier = Modifier.align(Alignment.TopCenter),
                     )
 
@@ -241,7 +245,7 @@
                 }
                 is PatternBouncerViewModel -> {
                     StatusMessage(
-                        viewModel = viewModel,
+                        viewModel = viewModel.message,
                         modifier = Modifier.align(Alignment.TopCenter),
                     )
 
@@ -280,7 +284,7 @@
                         modifier = Modifier.fillMaxWidth().align(Alignment.Center),
                     ) {
                         StatusMessage(
-                            viewModel = viewModel,
+                            viewModel = viewModel.message,
                         )
 
                         OutputArea(viewModel = viewModel, modifier = Modifier.padding(top = 24.dp))
@@ -376,7 +380,7 @@
                     modifier = Modifier.fillMaxWidth()
                 ) {
                     StatusMessage(
-                        viewModel = viewModel,
+                        viewModel = viewModel.message,
                     )
 
                     OutputArea(viewModel = viewModel, modifier = Modifier.padding(top = 24.dp))
@@ -441,7 +445,7 @@
                 modifier = Modifier.fillMaxWidth(),
             ) {
                 StatusMessage(
-                    viewModel = viewModel,
+                    viewModel = viewModel.message,
                 )
 
                 OutputArea(viewModel = viewModel, modifier = Modifier.padding(top = 24.dp))
@@ -548,26 +552,44 @@
 
 @Composable
 private fun StatusMessage(
-    viewModel: BouncerViewModel,
+    viewModel: BouncerMessageViewModel,
     modifier: Modifier = Modifier,
 ) {
-    val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
+    val message: MessageViewModel? by viewModel.message.collectAsState()
+
+    DisposableEffect(Unit) {
+        viewModel.onShown()
+        onDispose {}
+    }
 
     Crossfade(
         targetState = message,
         label = "Bouncer message",
-        animationSpec = if (message.isUpdateAnimated) tween() else snap(),
+        animationSpec = if (message?.isUpdateAnimated == true) tween() else snap(),
         modifier = modifier.fillMaxWidth(),
-    ) {
-        Box(
-            contentAlignment = Alignment.Center,
+    ) { msg ->
+        Column(
+            horizontalAlignment = Alignment.CenterHorizontally,
             modifier = Modifier.fillMaxWidth(),
         ) {
-            Text(
-                text = it.text,
-                color = MaterialTheme.colorScheme.onSurface,
-                style = MaterialTheme.typography.bodyLarge,
-            )
+            msg?.let {
+                Text(
+                    text = it.text,
+                    color = MaterialTheme.colorScheme.onSurface,
+                    fontSize = 18.sp,
+                    lineHeight = 24.sp,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                Spacer(modifier = Modifier.size(10.dp))
+                Text(
+                    text = it.secondaryText ?: "",
+                    color = MaterialTheme.colorScheme.onSurface,
+                    fontSize = 14.sp,
+                    lineHeight = 20.sp,
+                    overflow = TextOverflow.Ellipsis,
+                    maxLines = 2
+                )
+            }
         }
     }
 }
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt
index 2a13d49..c34f2fd 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt
@@ -74,10 +74,7 @@
     val isImeSwitcherButtonVisible by viewModel.isImeSwitcherButtonVisible.collectAsState()
     val selectedUserId by viewModel.selectedUserId.collectAsState()
 
-    DisposableEffect(Unit) {
-        viewModel.onShown()
-        onDispose { viewModel.onHidden() }
-    }
+    DisposableEffect(Unit) { onDispose { viewModel.onHidden() } }
 
     LaunchedEffect(animateFailure) {
         if (animateFailure) {
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt
index 0a5f5d2..a78c2c0 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt
@@ -72,10 +72,7 @@
     centerDotsVertically: Boolean,
     modifier: Modifier = Modifier,
 ) {
-    DisposableEffect(Unit) {
-        viewModel.onShown()
-        onDispose { viewModel.onHidden() }
-    }
+    DisposableEffect(Unit) { onDispose { viewModel.onHidden() } }
 
     val colCount = viewModel.columnCount
     val rowCount = viewModel.rowCount
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt
index f505b90..5651a46 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt
@@ -72,10 +72,7 @@
     verticalSpacing: Dp,
     modifier: Modifier = Modifier,
 ) {
-    DisposableEffect(Unit) {
-        viewModel.onShown()
-        onDispose { viewModel.onHidden() }
-    }
+    DisposableEffect(Unit) { onDispose { viewModel.onHidden() } }
 
     val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
     val backspaceButtonAppearance by viewModel.backspaceButtonAppearance.collectAsState()
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt
index 5c9b271..525ad16 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt
@@ -16,45 +16,33 @@
 
 package com.android.systemui.keyguard.ui.composable.section
 
-import android.content.Context
 import android.view.ViewGroup
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import com.android.compose.animation.scene.SceneScope
 import com.android.systemui.Flags.migrateClocksToBlueprint
 import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.dagger.qualifiers.Application
-import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.notifications.ui.composable.NotificationStack
 import com.android.systemui.scene.shared.flag.SceneContainerFlags
-import com.android.systemui.statusbar.notification.stack.AmbientState
 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout
-import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
-import com.android.systemui.statusbar.notification.stack.NotificationStackSizeCalculator
 import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer
-import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackAppearanceViewBinder
+import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackViewBinder
 import com.android.systemui.statusbar.notification.stack.ui.viewbinder.SharedNotificationContainerBinder
-import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.SharedNotificationContainerViewModel
 import javax.inject.Inject
-import kotlinx.coroutines.CoroutineDispatcher
 
 @SysUISingleton
 class NotificationSection
 @Inject
 constructor(
-    @Application private val context: Context,
     private val viewModel: NotificationsPlaceholderViewModel,
-    controller: NotificationStackScrollLayoutController,
     sceneContainerFlags: SceneContainerFlags,
     sharedNotificationContainer: SharedNotificationContainer,
     sharedNotificationContainerViewModel: SharedNotificationContainerViewModel,
     stackScrollLayout: NotificationStackScrollLayout,
-    notificationStackAppearanceViewModel: NotificationStackAppearanceViewModel,
-    ambientState: AmbientState,
-    notificationStackSizeCalculator: NotificationStackSizeCalculator,
-    @Main private val mainImmediateDispatcher: CoroutineDispatcher,
+    sharedNotificationContainerBinder: SharedNotificationContainerBinder,
+    notificationStackViewBinder: NotificationStackViewBinder,
 ) {
 
     init {
@@ -73,24 +61,13 @@
             sharedNotificationContainer.addNotificationStackScrollLayout(stackScrollLayout)
         }
 
-        SharedNotificationContainerBinder.bind(
+        sharedNotificationContainerBinder.bind(
             sharedNotificationContainer,
             sharedNotificationContainerViewModel,
-            sceneContainerFlags,
-            controller,
-            notificationStackSizeCalculator,
-            mainImmediateDispatcher = mainImmediateDispatcher,
         )
 
         if (sceneContainerFlags.isEnabled()) {
-            NotificationStackAppearanceViewBinder.bind(
-                context,
-                sharedNotificationContainer,
-                notificationStackAppearanceViewModel,
-                ambientState,
-                controller,
-                mainImmediateDispatcher = mainImmediateDispatcher,
-            )
+            notificationStackViewBinder.bindWhileAttached()
         }
     }
 
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt
index d780978..9ba5e3b 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt
@@ -57,6 +57,7 @@
 import androidx.compose.ui.layout.positionInWindow
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.dimensionResource
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
@@ -70,9 +71,10 @@
 import com.android.systemui.notifications.ui.composable.Notifications.Form
 import com.android.systemui.notifications.ui.composable.Notifications.TransitionThresholds.EXPANSION_FOR_MAX_CORNER_RADIUS
 import com.android.systemui.notifications.ui.composable.Notifications.TransitionThresholds.EXPANSION_FOR_MAX_SCRIM_ALPHA
+import com.android.systemui.res.R
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.shade.ui.composable.ShadeHeader
-import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackAppearanceViewBinder.SCRIM_CORNER_RADIUS
+import com.android.systemui.statusbar.notification.stack.shared.model.StackRounding
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel
 import kotlin.math.roundToInt
 
@@ -139,6 +141,7 @@
 ) {
     val density = LocalDensity.current
     val screenCornerRadius = LocalScreenCornerRadius.current
+    val scrimCornerRadius = dimensionResource(R.dimen.notification_scrim_corner_radius)
     val scrollState = rememberScrollState()
     val syntheticScroll = viewModel.syntheticScroll.collectAsState(0f)
     val expansionFraction by viewModel.expandFraction.collectAsState(0f)
@@ -156,6 +159,8 @@
 
     val contentHeight = viewModel.intrinsicContentHeight.collectAsState()
 
+    val stackRounding = viewModel.stackRounding.collectAsState(StackRounding())
+
     // the offset for the notifications scrim. Its upper bound is 0, and its lower bound is
     // calculated in minScrimOffset. The scrim is the same height as the screen minus the
     // height of the Shade Header, and at rest (scrimOffset = 0) its top bound is at maxScrimStartY.
@@ -222,16 +227,12 @@
                 .graphicsLayer {
                     shape =
                         calculateCornerRadius(
+                                scrimCornerRadius,
                                 screenCornerRadius,
                                 { expansionFraction },
                                 layoutState.isTransitioningBetween(Scenes.Gone, Scenes.Shade)
                             )
-                            .let {
-                                RoundedCornerShape(
-                                    topStart = it,
-                                    topEnd = it,
-                                )
-                            }
+                            .let { stackRounding.value.toRoundedCornerShape(it) }
                     clip = true
                 }
     ) {
@@ -359,6 +360,7 @@
 }
 
 private fun calculateCornerRadius(
+    scrimCornerRadius: Dp,
     screenCornerRadius: Dp,
     expansionFraction: () -> Float,
     transitioning: Boolean,
@@ -366,12 +368,12 @@
     return if (transitioning) {
         lerp(
                 start = screenCornerRadius.value,
-                stop = SCRIM_CORNER_RADIUS,
-                fraction = (expansionFraction() / EXPANSION_FOR_MAX_CORNER_RADIUS).coerceAtMost(1f),
+                stop = scrimCornerRadius.value,
+                fraction = (expansionFraction() / EXPANSION_FOR_MAX_CORNER_RADIUS).coerceIn(0f, 1f),
             )
             .dp
     } else {
-        SCRIM_CORNER_RADIUS.dp
+        scrimCornerRadius
     }
 }
 
@@ -394,5 +396,16 @@
         this
     }
 
+fun StackRounding.toRoundedCornerShape(radius: Dp): RoundedCornerShape {
+    val topRadius = if (roundTop) radius else 0.dp
+    val bottomRadius = if (roundBottom) radius else 0.dp
+    return RoundedCornerShape(
+        topStart = topRadius,
+        topEnd = topRadius,
+        bottomStart = bottomRadius,
+        bottomEnd = bottomRadius,
+    )
+}
+
 private const val TAG = "FlexiNotifs"
 private val DEBUG_COLOR = Color(1f, 0f, 0f, 0.2f)
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt
index bc48dd1..244861c 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
@@ -36,7 +37,8 @@
 import com.android.systemui.qs.ui.adapter.QSSceneAdapter
 import com.android.systemui.qs.ui.adapter.QSSceneAdapter.State.Companion.Collapsing
 import com.android.systemui.qs.ui.adapter.QSSceneAdapter.State.Expanding
-import com.android.systemui.qs.ui.adapter.QSSceneAdapter.State.Unsquishing
+import com.android.systemui.qs.ui.adapter.QSSceneAdapter.State.UnsquishingQQS
+import com.android.systemui.qs.ui.adapter.QSSceneAdapter.State.UnsquishingQS
 import com.android.systemui.scene.shared.model.Scenes
 
 object QuickSettings {
@@ -49,6 +51,8 @@
     object Elements {
         val Content =
             ElementKey("QuickSettingsContent", scenePicker = MovableElementScenePicker(SCENES))
+        val QuickQuickSettings = ElementKey("QuickQuickSettings")
+        val SplitShadeQuickSettings = ElementKey("SplitShadeQuickSettings")
         val FooterActions = ElementKey("QuickSettingsFooterActions")
     }
 
@@ -78,12 +82,16 @@
         is TransitionState.Transition ->
             with(transitionState) {
                 when {
-                    isSplitShade -> QSSceneAdapter.State.QS
-                    fromScene == Scenes.Shade && toScene == Scenes.QuickSettings ->
+                    isSplitShade -> UnsquishingQS(squishiness)
+                    fromScene == Scenes.Shade && toScene == Scenes.QuickSettings -> {
                         Expanding(progress)
-                    fromScene == Scenes.QuickSettings && toScene == Scenes.Shade ->
+                    }
+                    fromScene == Scenes.QuickSettings && toScene == Scenes.Shade -> {
                         Collapsing(progress)
-                    fromScene == Scenes.Shade || toScene == Scenes.Shade -> Unsquishing(squishiness)
+                    }
+                    fromScene == Scenes.Shade || toScene == Scenes.Shade -> {
+                        UnsquishingQQS(squishiness)
+                    }
                     fromScene == Scenes.QuickSettings || toScene == Scenes.QuickSettings -> {
                         QSSceneAdapter.State.QS
                     }
@@ -119,6 +127,18 @@
     squishiness: Float = QuickSettings.SharedValues.SquishinessValues.Default,
 ) {
     val contentState = stateForQuickSettingsContent(isSplitShade, squishiness)
+    val transitionState = layoutState.transitionState
+    val isClosing =
+        transitionState is TransitionState.Transition &&
+            transitionState.progress >= 0.9f && // almost done closing
+            !(layoutState.isTransitioning(to = Scenes.Shade) ||
+                layoutState.isTransitioning(to = Scenes.QuickSettings))
+
+    if (isClosing) {
+        DisposableEffect(Unit) {
+            onDispose { qsSceneAdapter.setState(QSSceneAdapter.State.CLOSED) }
+        }
+    }
 
     MovableElement(
         key = QuickSettings.Elements.Content,
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToShadeTransition.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToShadeTransition.kt
index 5c6e1c8..9b59708 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToShadeTransition.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToShadeTransition.kt
@@ -13,11 +13,18 @@
 ) {
     spec = tween(durationMillis = DefaultDuration.times(durationScale).inWholeMilliseconds.toInt())
 
-    fractionRange(start = .58f) { fade(ShadeHeader.Elements.Clock) }
-    fractionRange(start = .58f) { fade(ShadeHeader.Elements.CollapsedContentStart) }
-    fractionRange(start = .58f) { fade(ShadeHeader.Elements.CollapsedContentEnd) }
-    fractionRange(start = .58f) { fade(ShadeHeader.Elements.PrivacyChip) }
-    translate(QuickSettings.Elements.Content, y = -ShadeHeader.Dimensions.CollapsedHeight * .66f)
+    fractionRange(start = .58f) {
+        fade(ShadeHeader.Elements.Clock)
+        fade(ShadeHeader.Elements.CollapsedContentStart)
+        fade(ShadeHeader.Elements.CollapsedContentEnd)
+        fade(ShadeHeader.Elements.PrivacyChip)
+        fade(QuickSettings.Elements.SplitShadeQuickSettings)
+        fade(QuickSettings.Elements.FooterActions)
+    }
+    translate(
+        QuickSettings.Elements.QuickQuickSettings,
+        y = -ShadeHeader.Dimensions.CollapsedHeight * .66f
+    )
     translate(Notifications.Elements.NotificationScrim, Edge.Top, false)
 }
 
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt
index 15e7b51..85798ac 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt
@@ -55,6 +55,7 @@
 import com.android.compose.animation.scene.ElementKey
 import com.android.compose.animation.scene.LowestZIndexScenePicker
 import com.android.compose.animation.scene.SceneScope
+import com.android.compose.animation.scene.TransitionState
 import com.android.compose.animation.scene.UserAction
 import com.android.compose.animation.scene.UserActionResult
 import com.android.compose.animation.scene.animateSceneFloatAsState
@@ -222,15 +223,17 @@
                                         horizontal = Shade.Dimensions.HorizontalPadding
                                     )
                             )
-                            QuickSettings(
-                                viewModel.qsSceneAdapter,
-                                {
-                                    (viewModel.qsSceneAdapter.qqsHeight * tileSquishiness)
-                                        .roundToInt()
-                                },
-                                isSplitShade = false,
-                                squishiness = tileSquishiness,
-                            )
+                            Box(Modifier.element(QuickSettings.Elements.QuickQuickSettings)) {
+                                QuickSettings(
+                                    viewModel.qsSceneAdapter,
+                                    {
+                                        (viewModel.qsSceneAdapter.qqsHeight * tileSquishiness)
+                                            .roundToInt()
+                                    },
+                                    isSplitShade = false,
+                                    squishiness = tileSquishiness,
+                                )
+                            }
 
                             MediaIfVisible(
                                 viewModel = viewModel,
@@ -280,6 +283,8 @@
     val lifecycleOwner = LocalLifecycleOwner.current
     val footerActionsViewModel =
         remember(lifecycleOwner, viewModel) { viewModel.getFooterActionsViewModel(lifecycleOwner) }
+    val tileSquishiness by
+        animateSceneFloatAsState(value = 1f, key = QuickSettings.SharedValues.TilesSquishiness)
 
     val navBarBottomHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
     val density = LocalDensity.current
@@ -290,6 +295,7 @@
     }
 
     val quickSettingsScrollState = rememberScrollState()
+    val isScrollable = layoutState.transitionState is TransitionState.Idle
     LaunchedEffect(isCustomizing, quickSettingsScrollState) {
         if (isCustomizing) {
             quickSettingsScrollState.scrollTo(0)
@@ -318,31 +324,41 @@
                 Column(
                     verticalArrangement = Arrangement.Top,
                     modifier =
-                        Modifier.weight(1f).fillMaxHeight().thenIf(!isCustomizing) {
-                            Modifier.verticalNestedScrollToScene()
-                                .verticalScroll(quickSettingsScrollState)
-                                .clipScrollableContainer(Orientation.Horizontal)
-                                .padding(bottom = navBarBottomHeight)
-                        }
+                        Modifier.weight(1f).fillMaxSize().thenIf(!isCustomizing) {
+                            Modifier.padding(bottom = navBarBottomHeight)
+                        },
                 ) {
-                    QuickSettings(
-                        qsSceneAdapter = viewModel.qsSceneAdapter,
-                        heightProvider = { viewModel.qsSceneAdapter.qsHeight },
-                        isSplitShade = true,
-                        modifier = Modifier.fillMaxWidth(),
-                    )
+                    Column(
+                        modifier =
+                            Modifier.fillMaxSize().weight(1f).thenIf(!isCustomizing) {
+                                Modifier.verticalNestedScrollToScene()
+                                    .verticalScroll(
+                                        quickSettingsScrollState,
+                                        enabled = isScrollable
+                                    )
+                                    .clipScrollableContainer(Orientation.Horizontal)
+                            }
+                    ) {
+                        Box(
+                            modifier =
+                                Modifier.element(QuickSettings.Elements.SplitShadeQuickSettings)
+                        ) {
+                            QuickSettings(
+                                qsSceneAdapter = viewModel.qsSceneAdapter,
+                                heightProvider = { viewModel.qsSceneAdapter.qsHeight },
+                                isSplitShade = true,
+                                modifier = Modifier.fillMaxWidth(),
+                                squishiness = tileSquishiness,
+                            )
+                        }
 
-                    MediaIfVisible(
-                        viewModel = viewModel,
-                        mediaCarouselController = mediaCarouselController,
-                        mediaHost = mediaHost,
-                        modifier = Modifier.fillMaxWidth(),
-                    )
-
-                    Spacer(
-                        modifier = Modifier.weight(1f),
-                    )
-
+                        MediaIfVisible(
+                            viewModel = viewModel,
+                            mediaCarouselController = mediaCarouselController,
+                            mediaHost = mediaHost,
+                            modifier = Modifier.fillMaxWidth(),
+                        )
+                    }
                     FooterActionsWithAnimatedVisibility(
                         viewModel = footerActionsViewModel,
                         isCustomizing = isCustomizing,
@@ -354,7 +370,8 @@
                 NotificationScrollingStack(
                     viewModel = viewModel.notifications,
                     maxScrimTop = { 0f },
-                    modifier = Modifier.weight(1f).fillMaxHeight(),
+                    modifier =
+                        Modifier.weight(1f).fillMaxHeight().padding(bottom = navBarBottomHeight),
                 )
             }
         }
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
index af51cee..dc3b612 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
@@ -73,7 +73,7 @@
 internal class SceneScopeImpl(
     private val layoutImpl: SceneTransitionLayoutImpl,
     private val scene: Scene,
-) : SceneScope {
+) : SceneScope, ElementStateScope by layoutImpl.elementStateScope {
     override val layoutState: SceneTransitionLayoutState = layoutImpl.state
 
     override fun Modifier.element(key: ElementKey): Modifier {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index b7e2dd1..ebc9099 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -131,9 +131,30 @@
  */
 @DslMarker annotation class ElementDsl
 
+/** A scope that can be used to query the target state of an element or scene. */
+interface ElementStateScope {
+    /**
+     * Return the *target* size of [this] element in the given [scene], i.e. the size of the element
+     * when idle, or `null` if the element is not composed and measured in that scene (yet).
+     */
+    fun ElementKey.targetSize(scene: SceneKey): IntSize?
+
+    /**
+     * Return the *target* offset of [this] element in the given [scene], i.e. the size of the
+     * element when idle, or `null` if the element is not composed and placed in that scene (yet).
+     */
+    fun ElementKey.targetOffset(scene: SceneKey): Offset?
+
+    /**
+     * Return the *target* size of [this] scene, i.e. the size of the scene when idle, or `null` if
+     * the scene was never composed.
+     */
+    fun SceneKey.targetSize(): IntSize?
+}
+
 @Stable
 @ElementDsl
-interface BaseSceneScope {
+interface BaseSceneScope : ElementStateScope {
     /** The state of the [SceneTransitionLayout] in which this scene is contained. */
     val layoutState: SceneTransitionLayoutState
 
@@ -415,25 +436,7 @@
     ): Float
 }
 
-interface UserActionDistanceScope : Density {
-    /**
-     * Return the *target* size of [this] element in the given [scene], i.e. the size of the element
-     * when idle, or `null` if the element is not composed and measured in that scene (yet).
-     */
-    fun ElementKey.targetSize(scene: SceneKey): IntSize?
-
-    /**
-     * Return the *target* offset of [this] element in the given [scene], i.e. the size of the
-     * element when idle, or `null` if the element is not composed and placed in that scene (yet).
-     */
-    fun ElementKey.targetOffset(scene: SceneKey): Offset?
-
-    /**
-     * Return the *target* size of [this] scene, i.e. the size of the scene when idle, or `null` if
-     * the scene was never composed.
-     */
-    fun SceneKey.targetSize(): IntSize?
-}
+interface UserActionDistanceScope : Density, ElementStateScope
 
 /** The user action has a fixed [absoluteDistance]. */
 class FixedDistance(private val distance: Dp) : UserActionDistance {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
index 25b0895..b1cfdcf 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -98,6 +98,7 @@
     private val horizontalDraggableHandler: DraggableHandlerImpl
     private val verticalDraggableHandler: DraggableHandlerImpl
 
+    internal val elementStateScope = ElementStateScopeImpl(this)
     private var _userActionDistanceScope: UserActionDistanceScope? = null
     internal val userActionDistanceScope: UserActionDistanceScope
         get() =
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt
index 228d19f..b7abb33 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt
@@ -19,15 +19,9 @@
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.unit.IntSize
 
-internal class UserActionDistanceScopeImpl(
+internal class ElementStateScopeImpl(
     private val layoutImpl: SceneTransitionLayoutImpl,
-) : UserActionDistanceScope {
-    override val density: Float
-        get() = layoutImpl.density.density
-
-    override val fontScale: Float
-        get() = layoutImpl.density.fontScale
-
+) : ElementStateScope {
     override fun ElementKey.targetSize(scene: SceneKey): IntSize? {
         return layoutImpl.elements[this]?.sceneStates?.get(scene)?.targetSize.takeIf {
             it != Element.SizeUnspecified
@@ -44,3 +38,13 @@
         return layoutImpl.scenes[this]?.targetSize.takeIf { it != IntSize.Zero }
     }
 }
+
+internal class UserActionDistanceScopeImpl(
+    private val layoutImpl: SceneTransitionLayoutImpl,
+) : UserActionDistanceScope, ElementStateScope by layoutImpl.elementStateScope {
+    override val density: Float
+        get() = layoutImpl.density.density
+
+    override val fontScale: Float
+        get() = layoutImpl.density.fontScale
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt
index 707777b..b0d03b1 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt
@@ -71,34 +71,6 @@
     }
 
     @Test
-    fun pinAuthMethod() =
-        testScope.runTest {
-            val message by collectLastValue(underTest.message)
-
-            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
-                AuthenticationMethodModel.Pin
-            )
-            runCurrent()
-            underTest.clearMessage()
-            assertThat(message).isNull()
-
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
-
-            // Wrong input.
-            assertThat(underTest.authenticate(listOf(9, 8, 7)))
-                .isEqualTo(AuthenticationResult.FAILED)
-            assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
-
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
-
-            // Correct input.
-            assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN))
-                .isEqualTo(AuthenticationResult.SUCCEEDED)
-        }
-
-    @Test
     fun pinAuthMethod_sim_skipsAuthentication() =
         testScope.runTest {
             kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
@@ -146,8 +118,6 @@
     @Test
     fun pinAuthMethod_tryAutoConfirm_withoutAutoConfirmPin() =
         testScope.runTest {
-            val message by collectLastValue(underTest.message)
-
             kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                 AuthenticationMethodModel.Pin
             )
@@ -156,7 +126,6 @@
             // Incomplete input.
             assertThat(underTest.authenticate(listOf(1, 2), tryAutoConfirm = true))
                 .isEqualTo(AuthenticationResult.SKIPPED)
-            assertThat(message).isNull()
 
             // Correct input.
             assertThat(
@@ -166,28 +135,19 @@
                     )
                 )
                 .isEqualTo(AuthenticationResult.SKIPPED)
-            assertThat(message).isNull()
         }
 
     @Test
     fun passwordAuthMethod() =
         testScope.runTest {
-            val message by collectLastValue(underTest.message)
             kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                 AuthenticationMethodModel.Password
             )
             runCurrent()
 
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
-
             // Wrong input.
             assertThat(underTest.authenticate("alohamora".toList()))
                 .isEqualTo(AuthenticationResult.FAILED)
-            assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
-
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
 
             // Too short input.
             assertThat(
@@ -201,7 +161,6 @@
                     )
                 )
                 .isEqualTo(AuthenticationResult.SKIPPED)
-            assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
 
             // Correct input.
             assertThat(underTest.authenticate("password".toList()))
@@ -211,13 +170,10 @@
     @Test
     fun patternAuthMethod() =
         testScope.runTest {
-            val message by collectLastValue(underTest.message)
             kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                 AuthenticationMethodModel.Pattern
             )
             runCurrent()
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
 
             // Wrong input.
             val wrongPattern =
@@ -231,10 +187,6 @@
             assertThat(wrongPattern.size)
                 .isAtLeast(kosmos.fakeAuthenticationRepository.minPatternLength)
             assertThat(underTest.authenticate(wrongPattern)).isEqualTo(AuthenticationResult.FAILED)
-            assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
-
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
 
             // Too short input.
             val tooShortPattern =
@@ -244,10 +196,6 @@
                 )
             assertThat(underTest.authenticate(tooShortPattern))
                 .isEqualTo(AuthenticationResult.SKIPPED)
-            assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
-
-            underTest.resetMessage()
-            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
 
             // Correct input.
             assertThat(underTest.authenticate(FakeAuthenticationRepository.PATTERN))
@@ -258,7 +206,6 @@
     fun lockoutStarted() =
         testScope.runTest {
             val lockoutStartedEvents by collectValues(underTest.onLockoutStarted)
-            val message by collectLastValue(underTest.message)
 
             kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                 AuthenticationMethodModel.Pin
@@ -272,17 +219,14 @@
                     .isEqualTo(AuthenticationResult.FAILED)
                 if (times < FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) {
                     assertThat(lockoutStartedEvents).isEmpty()
-                    assertThat(message).isNotEmpty()
                 }
             }
             assertThat(authenticationInteractor.lockoutEndTimestamp).isNotNull()
             assertThat(lockoutStartedEvents.size).isEqualTo(1)
-            assertThat(message).isNull()
 
             // Advance the time to finish the lockout:
             advanceTimeBy(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS.seconds)
             assertThat(authenticationInteractor.lockoutEndTimestamp).isNull()
-            assertThat(message).isNull()
             assertThat(lockoutStartedEvents.size).isEqualTo(1)
 
             // Trigger lockout again:
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractorTest.kt
similarity index 83%
rename from packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractorTest.kt
index 701b703..c878e0b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractorTest.kt
@@ -17,7 +17,6 @@
 package com.android.systemui.bouncer.domain.interactor
 
 import android.content.pm.UserInfo
-import android.os.Handler
 import android.testing.TestableLooper
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
@@ -28,27 +27,25 @@
 import com.android.systemui.Flags
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.biometrics.data.repository.FaceSensorInfo
-import com.android.systemui.biometrics.data.repository.FakeFacePropertyRepository
+import com.android.systemui.biometrics.data.repository.fakeFacePropertyRepository
+import com.android.systemui.biometrics.data.repository.fakeFingerprintPropertyRepository
 import com.android.systemui.biometrics.shared.model.SensorStrength
 import com.android.systemui.bouncer.data.repository.BouncerMessageRepositoryImpl
-import com.android.systemui.bouncer.data.repository.FakeKeyguardBouncerRepository
+import com.android.systemui.bouncer.data.repository.fakeKeyguardBouncerRepository
 import com.android.systemui.bouncer.shared.model.BouncerMessageModel
-import com.android.systemui.bouncer.ui.BouncerView
-import com.android.systemui.classifier.FalsingCollector
 import com.android.systemui.coroutines.collectLastValue
-import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFaceAuthInteractor
+import com.android.systemui.deviceentry.domain.interactor.deviceEntryFingerprintAuthInteractor
 import com.android.systemui.flags.SystemPropertiesHelper
-import com.android.systemui.keyguard.DismissCallbackRegistry
-import com.android.systemui.keyguard.data.repository.FakeBiometricSettingsRepository
-import com.android.systemui.keyguard.data.repository.FakeDeviceEntryFaceAuthRepository
-import com.android.systemui.keyguard.data.repository.FakeDeviceEntryFingerprintAuthRepository
-import com.android.systemui.keyguard.data.repository.FakeTrustRepository
+import com.android.systemui.keyguard.data.repository.fakeBiometricSettingsRepository
+import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFaceAuthRepository
+import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository
+import com.android.systemui.keyguard.data.repository.fakeTrustRepository
 import com.android.systemui.keyguard.shared.model.AuthenticationFlags
+import com.android.systemui.kosmos.testScope
 import com.android.systemui.res.R.string.kg_too_many_failed_attempts_countdown
 import com.android.systemui.res.R.string.kg_trust_agent_disabled
-import com.android.systemui.statusbar.policy.KeyguardStateController
-import com.android.systemui.user.data.repository.FakeUserRepository
-import com.android.systemui.user.domain.interactor.SelectedUserInteractor
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.fakeUserRepository
 import com.android.systemui.util.mockito.KotlinArgumentCaptor
 import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
@@ -61,7 +58,6 @@
 import org.junit.runner.RunWith
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mock
-import org.mockito.Mockito.mock
 import org.mockito.Mockito.verify
 import org.mockito.MockitoAnnotations
 
@@ -70,34 +66,22 @@
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
 @RunWith(AndroidJUnit4::class)
 class BouncerMessageInteractorTest : SysuiTestCase() {
-
+    private val kosmos = testKosmos()
     private val countDownTimerCallback = KotlinArgumentCaptor(CountDownTimerCallback::class.java)
     private val repository = BouncerMessageRepositoryImpl()
-    private val userRepository = FakeUserRepository()
-    private val fakeTrustRepository = FakeTrustRepository()
-    private val fakeFacePropertyRepository = FakeFacePropertyRepository()
-    private val bouncerRepository = FakeKeyguardBouncerRepository()
-    private val fakeDeviceEntryFingerprintAuthRepository =
-        FakeDeviceEntryFingerprintAuthRepository()
-    private val fakeDeviceEntryFaceAuthRepository = FakeDeviceEntryFaceAuthRepository()
-    private val biometricSettingsRepository: FakeBiometricSettingsRepository =
-        FakeBiometricSettingsRepository()
+    private val biometricSettingsRepository = kosmos.fakeBiometricSettingsRepository
+    private val testScope = kosmos.testScope
     @Mock private lateinit var updateMonitor: KeyguardUpdateMonitor
     @Mock private lateinit var securityModel: KeyguardSecurityModel
     @Mock private lateinit var countDownTimerUtil: CountDownTimerUtil
     @Mock private lateinit var systemPropertiesHelper: SystemPropertiesHelper
-    @Mock private lateinit var keyguardUpdateMonitor: KeyguardUpdateMonitor
-    @Mock private lateinit var mSelectedUserInteractor: SelectedUserInteractor
 
-    private lateinit var primaryBouncerInteractor: PrimaryBouncerInteractor
-    private lateinit var testScope: TestScope
     private lateinit var underTest: BouncerMessageInteractor
 
     @Before
     fun setUp() {
         MockitoAnnotations.initMocks(this)
-        userRepository.setUserInfos(listOf(PRIMARY_USER))
-        testScope = TestScope()
+        kosmos.fakeUserRepository.setUserInfos(listOf(PRIMARY_USER))
         allowTestableLooperAsMainThread()
         whenever(securityModel.getSecurityMode(PRIMARY_USER_ID)).thenReturn(PIN)
         biometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
@@ -105,44 +89,28 @@
     }
 
     suspend fun TestScope.init() {
-        userRepository.setSelectedUserInfo(PRIMARY_USER)
+        kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
         mSetFlagsRule.enableFlags(Flags.FLAG_REVAMPED_BOUNCER_MESSAGES)
-        primaryBouncerInteractor =
-            PrimaryBouncerInteractor(
-                bouncerRepository,
-                mock(BouncerView::class.java),
-                mock(Handler::class.java),
-                mock(KeyguardStateController::class.java),
-                mock(KeyguardSecurityModel::class.java),
-                mock(PrimaryBouncerCallbackInteractor::class.java),
-                mock(FalsingCollector::class.java),
-                mock(DismissCallbackRegistry::class.java),
-                context,
-                keyguardUpdateMonitor,
-                fakeTrustRepository,
-                testScope.backgroundScope,
-                mSelectedUserInteractor,
-                mock(DeviceEntryFaceAuthInteractor::class.java),
-            )
         underTest =
             BouncerMessageInteractor(
                 repository = repository,
-                userRepository = userRepository,
+                userRepository = kosmos.fakeUserRepository,
                 countDownTimerUtil = countDownTimerUtil,
                 updateMonitor = updateMonitor,
                 biometricSettingsRepository = biometricSettingsRepository,
-                applicationScope = this.backgroundScope,
-                trustRepository = fakeTrustRepository,
+                applicationScope = testScope.backgroundScope,
+                trustRepository = kosmos.fakeTrustRepository,
                 systemPropertiesHelper = systemPropertiesHelper,
-                primaryBouncerInteractor = primaryBouncerInteractor,
-                facePropertyRepository = fakeFacePropertyRepository,
-                deviceEntryFingerprintAuthRepository = fakeDeviceEntryFingerprintAuthRepository,
-                faceAuthRepository = fakeDeviceEntryFaceAuthRepository,
+                primaryBouncerInteractor = kosmos.primaryBouncerInteractor,
+                facePropertyRepository = kosmos.fakeFacePropertyRepository,
+                deviceEntryFingerprintAuthInteractor = kosmos.deviceEntryFingerprintAuthInteractor,
+                faceAuthRepository = kosmos.fakeDeviceEntryFaceAuthRepository,
                 securityModel = securityModel
             )
         biometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
-        fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
-        bouncerRepository.setPrimaryShow(true)
+        kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
+        kosmos.fakeFingerprintPropertyRepository.supportsSideFps()
+        kosmos.fakeKeyguardBouncerRepository.setPrimaryShow(true)
         runCurrent()
     }
 
@@ -268,7 +236,7 @@
             init()
             val lockoutMessage by collectLastValue(underTest.bouncerMessage)
 
-            fakeDeviceEntryFaceAuthRepository.setLockedOut(true)
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(true)
             runCurrent()
 
             assertThat(primaryResMessage(lockoutMessage))
@@ -276,7 +244,7 @@
             assertThat(secondaryResMessage(lockoutMessage))
                 .isEqualTo("Can’t unlock with face. Too many attempts.")
 
-            fakeDeviceEntryFaceAuthRepository.setLockedOut(false)
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(false)
             runCurrent()
 
             assertThat(primaryResMessage(lockoutMessage))
@@ -289,15 +257,17 @@
         testScope.runTest {
             init()
             val lockoutMessage by collectLastValue(underTest.bouncerMessage)
-            fakeFacePropertyRepository.setSensorInfo(FaceSensorInfo(1, SensorStrength.STRONG))
-            fakeDeviceEntryFaceAuthRepository.setLockedOut(true)
+            kosmos.fakeFacePropertyRepository.setSensorInfo(
+                FaceSensorInfo(1, SensorStrength.STRONG)
+            )
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(true)
             runCurrent()
 
             assertThat(primaryResMessage(lockoutMessage)).isEqualTo("Enter PIN")
             assertThat(secondaryResMessage(lockoutMessage))
                 .isEqualTo("PIN is required after too many attempts")
 
-            fakeDeviceEntryFaceAuthRepository.setLockedOut(false)
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(false)
             runCurrent()
 
             assertThat(primaryResMessage(lockoutMessage))
@@ -311,14 +281,14 @@
             init()
             val lockedOutMessage by collectLastValue(underTest.bouncerMessage)
 
-            fakeDeviceEntryFingerprintAuthRepository.setLockedOut(true)
+            kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(true)
             runCurrent()
 
             assertThat(primaryResMessage(lockedOutMessage)).isEqualTo("Enter PIN")
             assertThat(secondaryResMessage(lockedOutMessage))
                 .isEqualTo("PIN is required after too many attempts")
 
-            fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
+            kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
             runCurrent()
 
             assertThat(primaryResMessage(lockedOutMessage))
@@ -327,6 +297,19 @@
         }
 
     @Test
+    fun onUdfpsFingerprint_DoesNotShowFingerprintMessage() =
+        testScope.runTest {
+            init()
+            kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
+            kosmos.fakeFingerprintPropertyRepository.supportsUdfps()
+            val lockedOutMessage by collectLastValue(underTest.bouncerMessage)
+
+            runCurrent()
+
+            assertThat(primaryResMessage(lockedOutMessage)).isEqualTo("Enter PIN")
+        }
+
+    @Test
     fun onRestartForMainlineUpdate_shouldProvideRelevantMessage() =
         testScope.runTest {
             init()
@@ -344,9 +327,10 @@
     fun onAuthFlagsChanged_withTrustNotManagedAndNoBiometrics_isANoop() =
         testScope.runTest {
             init()
-            fakeTrustRepository.setTrustUsuallyManaged(false)
+            kosmos.fakeTrustRepository.setTrustUsuallyManaged(false)
             biometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(false)
             biometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(false)
+            runCurrent()
 
             val defaultMessage = Pair("Enter PIN", null)
 
@@ -377,12 +361,13 @@
         testScope.runTest {
             init()
 
-            userRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
             biometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(false)
             biometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(false)
+            runCurrent()
 
-            fakeTrustRepository.setCurrentUserTrustManaged(true)
-            fakeTrustRepository.setTrustUsuallyManaged(true)
+            kosmos.fakeTrustRepository.setCurrentUserTrustManaged(true)
+            kosmos.fakeTrustRepository.setTrustUsuallyManaged(true)
 
             val defaultMessage = Pair("Enter PIN", null)
 
@@ -415,8 +400,8 @@
     fun authFlagsChanges_withFaceEnrolled_providesDifferentMessages() =
         testScope.runTest {
             init()
-            userRepository.setSelectedUserInfo(PRIMARY_USER)
-            fakeTrustRepository.setTrustUsuallyManaged(false)
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeTrustRepository.setTrustUsuallyManaged(false)
             biometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(false)
 
             biometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(true)
@@ -453,12 +438,13 @@
     fun authFlagsChanges_withFingerprintEnrolled_providesDifferentMessages() =
         testScope.runTest {
             init()
-            userRepository.setSelectedUserInfo(PRIMARY_USER)
-            fakeTrustRepository.setCurrentUserTrustManaged(false)
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeTrustRepository.setCurrentUserTrustManaged(false)
             biometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(false)
 
             biometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
             biometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+            runCurrent()
 
             verifyMessagesForAuthFlag(
                 LockPatternUtils.StrongAuthTracker.STRONG_AUTH_NOT_REQUIRED to
@@ -466,6 +452,7 @@
             )
 
             biometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(false)
+            runCurrent()
 
             verifyMessagesForAuthFlag(
                 LockPatternUtils.StrongAuthTracker.SOME_AUTH_REQUIRED_AFTER_USER_REQUEST to
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt
index d30e333..c9fa671 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt
@@ -48,6 +48,7 @@
             isInputEnabled = MutableStateFlow(true),
             simBouncerInteractor = kosmos.simBouncerInteractor,
             authenticationMethod = AuthenticationMethodModel.Pin,
+            onIntentionalUserInput = {},
         )
     }
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModelTest.kt
new file mode 100644
index 0000000..16ec9aa
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModelTest.kt
@@ -0,0 +1,455 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bouncer.ui.viewmodel
+
+import android.content.pm.UserInfo
+import android.hardware.biometrics.BiometricFaceConstants
+import android.hardware.fingerprint.FingerprintManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.widget.LockPatternUtils
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
+import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
+import com.android.systemui.authentication.domain.interactor.authenticationInteractor
+import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
+import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pattern
+import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pin
+import com.android.systemui.biometrics.data.repository.FaceSensorInfo
+import com.android.systemui.biometrics.data.repository.fakeFacePropertyRepository
+import com.android.systemui.biometrics.data.repository.fakeFingerprintPropertyRepository
+import com.android.systemui.biometrics.shared.model.SensorStrength
+import com.android.systemui.bouncer.domain.interactor.bouncerInteractor
+import com.android.systemui.bouncer.shared.flag.fakeComposeBouncerFlags
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
+import com.android.systemui.deviceentry.shared.model.ErrorFaceAuthenticationStatus
+import com.android.systemui.deviceentry.shared.model.FailedFaceAuthenticationStatus
+import com.android.systemui.deviceentry.shared.model.HelpFaceAuthenticationStatus
+import com.android.systemui.flags.fakeSystemPropertiesHelper
+import com.android.systemui.keyguard.data.repository.deviceEntryFingerprintAuthRepository
+import com.android.systemui.keyguard.data.repository.fakeBiometricSettingsRepository
+import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFaceAuthRepository
+import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository
+import com.android.systemui.keyguard.data.repository.fakeTrustRepository
+import com.android.systemui.keyguard.shared.model.AuthenticationFlags
+import com.android.systemui.keyguard.shared.model.ErrorFingerprintAuthenticationStatus
+import com.android.systemui.keyguard.shared.model.FailFingerprintAuthenticationStatus
+import com.android.systemui.keyguard.shared.model.HelpFingerprintAuthenticationStatus
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.res.R
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.fakeUserRepository
+import com.google.common.truth.Truth.assertThat
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.currentTime
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BouncerMessageViewModelTest : SysuiTestCase() {
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+    private val authenticationInteractor by lazy { kosmos.authenticationInteractor }
+    private val bouncerInteractor by lazy { kosmos.bouncerInteractor }
+    private lateinit var underTest: BouncerMessageViewModel
+
+    @Before
+    fun setUp() {
+        kosmos.fakeUserRepository.setUserInfos(listOf(PRIMARY_USER))
+        kosmos.fakeComposeBouncerFlags.composeBouncerEnabled = true
+        underTest = kosmos.bouncerMessageViewModel
+        overrideResource(R.string.kg_trust_agent_disabled, "Trust agent is unavailable")
+        kosmos.fakeSystemPropertiesHelper.set(
+            DeviceEntryInteractor.SYS_BOOT_REASON_PROP,
+            "not mainline reboot"
+        )
+    }
+
+    @Test
+    fun message_defaultMessage_basedOnAuthMethod() =
+        testScope.runTest {
+            val message by collectLastValue(underTest.message)
+
+            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pin)
+            kosmos.fakeFingerprintPropertyRepository.supportsSideFps()
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+            runCurrent()
+
+            assertThat(message!!.text).isEqualTo("Unlock with PIN or fingerprint")
+
+            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pattern)
+            runCurrent()
+            assertThat(message!!.text).isEqualTo("Unlock with pattern or fingerprint")
+
+            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
+                AuthenticationMethodModel.Password
+            )
+            runCurrent()
+            assertThat(message!!.text).isEqualTo("Unlock with password or fingerprint")
+        }
+
+    @Test
+    fun message() =
+        testScope.runTest {
+            val message by collectLastValue(underTest.message)
+            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pin)
+            assertThat(message?.isUpdateAnimated).isTrue()
+
+            repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) {
+                bouncerInteractor.authenticate(WRONG_PIN)
+            }
+            assertThat(message?.isUpdateAnimated).isFalse()
+
+            val lockoutEndMs = authenticationInteractor.lockoutEndTimestamp ?: 0
+            advanceTimeBy(lockoutEndMs - testScope.currentTime)
+            assertThat(message?.isUpdateAnimated).isTrue()
+        }
+
+    @Test
+    fun lockoutMessage() =
+        testScope.runTest {
+            val message by collectLastValue(underTest.message)
+            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pin)
+            assertThat(kosmos.fakeAuthenticationRepository.lockoutEndTimestamp).isNull()
+            runCurrent()
+
+            repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { times ->
+                bouncerInteractor.authenticate(WRONG_PIN)
+                runCurrent()
+                if (times < FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) {
+                    assertThat(message?.text).isEqualTo("Wrong PIN. Try again.")
+                    assertThat(message?.isUpdateAnimated).isTrue()
+                }
+            }
+            val lockoutSeconds = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS
+            assertTryAgainMessage(message?.text, lockoutSeconds)
+            assertThat(message?.isUpdateAnimated).isFalse()
+
+            repeat(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS) { time ->
+                advanceTimeBy(1.seconds)
+                val remainingSeconds = lockoutSeconds - time - 1
+                if (remainingSeconds > 0) {
+                    assertTryAgainMessage(message?.text, remainingSeconds)
+                }
+            }
+            assertThat(message?.text).isEqualTo("Enter PIN")
+            assertThat(message?.isUpdateAnimated).isTrue()
+        }
+
+    @Test
+    fun defaultMessage_mapsToDeviceEntryRestrictionReason_whenTrustAgentIsEnabled() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeBiometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(false)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(false)
+            kosmos.fakeTrustRepository.setTrustUsuallyManaged(true)
+            kosmos.fakeTrustRepository.setCurrentUserTrustManaged(false)
+            runCurrent()
+
+            val defaultMessage = Pair("Enter PIN", null)
+
+            verifyMessagesForAuthFlags(
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_NOT_REQUIRED to defaultMessage,
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_BOOT to
+                    Pair("Enter PIN", "PIN is required after device restarts"),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_TIMEOUT to
+                    Pair("Enter PIN", "Added security required. PIN not used for a while."),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_DPM_LOCK_NOW to
+                    Pair("Enter PIN", "For added security, device was locked by work policy"),
+                LockPatternUtils.StrongAuthTracker.SOME_AUTH_REQUIRED_AFTER_USER_REQUEST to
+                    Pair("Enter PIN", "Trust agent is unavailable"),
+                LockPatternUtils.StrongAuthTracker.SOME_AUTH_REQUIRED_AFTER_TRUSTAGENT_EXPIRED to
+                    Pair("Enter PIN", "Trust agent is unavailable"),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_USER_LOCKDOWN to
+                    Pair("Enter PIN", "PIN is required after lockdown"),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_FOR_UNATTENDED_UPDATE to
+                    Pair("Enter PIN", "PIN required for additional security"),
+                LockPatternUtils.StrongAuthTracker
+                    .STRONG_AUTH_REQUIRED_AFTER_NON_STRONG_BIOMETRICS_TIMEOUT to
+                    Pair(
+                        "Enter PIN",
+                        "Added security required. Device wasn’t unlocked for a while."
+                    ),
+            )
+        }
+
+    @Test
+    fun defaultMessage_mapsToDeviceEntryRestrictionReason_whenFingerprintIsAvailable() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeFingerprintPropertyRepository.supportsSideFps()
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+            kosmos.fakeTrustRepository.setCurrentUserTrustManaged(false)
+            kosmos.fakeBiometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(false)
+            runCurrent()
+
+            verifyMessagesForAuthFlags(
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_NOT_REQUIRED to
+                    Pair("Unlock with PIN or fingerprint", null),
+                LockPatternUtils.StrongAuthTracker.SOME_AUTH_REQUIRED_AFTER_USER_REQUEST to
+                    Pair("Unlock with PIN or fingerprint", null),
+                LockPatternUtils.StrongAuthTracker.SOME_AUTH_REQUIRED_AFTER_TRUSTAGENT_EXPIRED to
+                    Pair("Unlock with PIN or fingerprint", null),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_BOOT to
+                    Pair("Enter PIN", "PIN is required after device restarts"),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_TIMEOUT to
+                    Pair("Enter PIN", "Added security required. PIN not used for a while."),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_DPM_LOCK_NOW to
+                    Pair("Enter PIN", "For added security, device was locked by work policy"),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_USER_LOCKDOWN to
+                    Pair("Enter PIN", "PIN is required after lockdown"),
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_FOR_UNATTENDED_UPDATE to
+                    Pair("Enter PIN", "PIN required for additional security"),
+                LockPatternUtils.StrongAuthTracker
+                    .STRONG_AUTH_REQUIRED_AFTER_NON_STRONG_BIOMETRICS_TIMEOUT to
+                    Pair(
+                        "Unlock with PIN or fingerprint",
+                        "Added security required. Device wasn’t unlocked for a while."
+                    ),
+            )
+        }
+
+    @Test
+    fun onFingerprintLockout_messageUpdated() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeFingerprintPropertyRepository.supportsSideFps()
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+
+            val lockedOutMessage by collectLastValue(underTest.message)
+
+            kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(true)
+            runCurrent()
+
+            assertThat(lockedOutMessage?.text).isEqualTo("Enter PIN")
+            assertThat(lockedOutMessage?.secondaryText)
+                .isEqualTo("PIN is required after too many attempts")
+
+            kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
+            runCurrent()
+
+            assertThat(lockedOutMessage?.text).isEqualTo("Unlock with PIN or fingerprint")
+            assertThat(lockedOutMessage?.secondaryText.isNullOrBlank()).isTrue()
+        }
+
+    @Test
+    fun onUdfpsFingerprint_DoesNotShowFingerprintMessage() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeFingerprintPropertyRepository.supportsUdfps()
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+            kosmos.fakeDeviceEntryFingerprintAuthRepository.setLockedOut(false)
+            val message by collectLastValue(underTest.message)
+
+            runCurrent()
+
+            assertThat(message?.text).isEqualTo("Enter PIN")
+        }
+
+    @Test
+    fun onRestartForMainlineUpdate_shouldProvideRelevantMessage() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeSystemPropertiesHelper.set("sys.boot.reason.last", "reboot,mainline_update")
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
+            runCurrent()
+
+            verifyMessagesForAuthFlags(
+                LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_BOOT to
+                    Pair("Enter PIN", "Device updated. Enter PIN to continue.")
+            )
+        }
+
+    @Test
+    fun onFaceLockout_whenItIsClass3_shouldProvideRelevantMessage() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeBiometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(true)
+            val lockoutMessage by collectLastValue(underTest.message)
+            kosmos.fakeFacePropertyRepository.setSensorInfo(
+                FaceSensorInfo(1, SensorStrength.STRONG)
+            )
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(true)
+            runCurrent()
+
+            assertThat(lockoutMessage?.text).isEqualTo("Enter PIN")
+            assertThat(lockoutMessage?.secondaryText)
+                .isEqualTo("PIN is required after too many attempts")
+
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(false)
+            runCurrent()
+
+            assertThat(lockoutMessage?.text).isEqualTo("Enter PIN")
+            assertThat(lockoutMessage?.secondaryText.isNullOrBlank()).isTrue()
+        }
+
+    @Test
+    fun onFaceLockout_whenItIsNotStrong_shouldProvideRelevantMessage() =
+        testScope.runTest {
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeBiometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(true)
+            val lockoutMessage by collectLastValue(underTest.message)
+            kosmos.fakeFacePropertyRepository.setSensorInfo(FaceSensorInfo(1, SensorStrength.WEAK))
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(true)
+            runCurrent()
+
+            assertThat(lockoutMessage?.text).isEqualTo("Enter PIN")
+            assertThat(lockoutMessage?.secondaryText)
+                .isEqualTo("Can’t unlock with face. Too many attempts.")
+
+            kosmos.fakeDeviceEntryFaceAuthRepository.setLockedOut(false)
+            runCurrent()
+
+            assertThat(lockoutMessage?.text).isEqualTo("Enter PIN")
+            assertThat(lockoutMessage?.secondaryText.isNullOrBlank()).isTrue()
+        }
+
+    @Test
+    fun setFingerprintMessage_propagateValue() =
+        testScope.runTest {
+            val bouncerMessage by collectLastValue(underTest.message)
+
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)
+            kosmos.fakeBiometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+            kosmos.fakeFingerprintPropertyRepository.supportsSideFps()
+            runCurrent()
+
+            kosmos.deviceEntryFingerprintAuthRepository.setAuthenticationStatus(
+                HelpFingerprintAuthenticationStatus(1, "some helpful message")
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Unlock with PIN or fingerprint")
+            assertThat(bouncerMessage?.secondaryText).isEqualTo("some helpful message")
+
+            kosmos.deviceEntryFingerprintAuthRepository.setAuthenticationStatus(
+                FailFingerprintAuthenticationStatus
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Fingerprint not recognized")
+            assertThat(bouncerMessage?.secondaryText).isEqualTo("Try again or enter PIN")
+
+            kosmos.deviceEntryFingerprintAuthRepository.setAuthenticationStatus(
+                ErrorFingerprintAuthenticationStatus(
+                    FingerprintManager.FINGERPRINT_ERROR_LOCKOUT,
+                    "locked out"
+                )
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Enter PIN")
+            assertThat(bouncerMessage?.secondaryText)
+                .isEqualTo("PIN is required after too many attempts")
+        }
+
+    @Test
+    fun setFaceMessage_propagateValue() =
+        testScope.runTest {
+            val bouncerMessage by collectLastValue(underTest.message)
+
+            kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+            kosmos.fakeBiometricSettingsRepository.setIsFaceAuthEnrolledAndEnabled(true)
+            kosmos.fakeBiometricSettingsRepository.setIsFaceAuthCurrentlyAllowed(true)
+            runCurrent()
+
+            kosmos.fakeDeviceEntryFaceAuthRepository.setAuthenticationStatus(
+                HelpFaceAuthenticationStatus(1, "some helpful message")
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Enter PIN")
+            assertThat(bouncerMessage?.secondaryText).isEqualTo("some helpful message")
+
+            kosmos.fakeDeviceEntryFaceAuthRepository.setAuthenticationStatus(
+                ErrorFaceAuthenticationStatus(
+                    BiometricFaceConstants.FACE_ERROR_TIMEOUT,
+                    "Try again"
+                )
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Enter PIN")
+            assertThat(bouncerMessage?.secondaryText).isEqualTo("Try again")
+
+            kosmos.fakeDeviceEntryFaceAuthRepository.setAuthenticationStatus(
+                FailedFaceAuthenticationStatus()
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Face not recognized")
+            assertThat(bouncerMessage?.secondaryText).isEqualTo("Try again or enter PIN")
+
+            kosmos.fakeDeviceEntryFaceAuthRepository.setAuthenticationStatus(
+                ErrorFaceAuthenticationStatus(
+                    BiometricFaceConstants.FACE_ERROR_LOCKOUT,
+                    "locked out"
+                )
+            )
+            runCurrent()
+            assertThat(bouncerMessage?.text).isEqualTo("Enter PIN")
+            assertThat(bouncerMessage?.secondaryText)
+                .isEqualTo("Can’t unlock with face. Too many attempts.")
+        }
+
+    private fun TestScope.verifyMessagesForAuthFlags(
+        vararg authFlagToMessagePair: Pair<Int, Pair<String, String?>>
+    ) {
+        val actualMessage by collectLastValue(underTest.message)
+
+        authFlagToMessagePair.forEach { (flag, expectedMessagePair) ->
+            kosmos.fakeBiometricSettingsRepository.setAuthenticationFlags(
+                AuthenticationFlags(userId = PRIMARY_USER_ID, flag = flag)
+            )
+            runCurrent()
+
+            assertThat(actualMessage?.text).isEqualTo(expectedMessagePair.first)
+
+            if (expectedMessagePair.second == null) {
+                assertThat(actualMessage?.secondaryText.isNullOrBlank()).isTrue()
+            } else {
+                assertThat(actualMessage?.secondaryText).isEqualTo(expectedMessagePair.second)
+            }
+        }
+    }
+
+    private fun assertTryAgainMessage(
+        message: String?,
+        time: Int,
+    ) {
+        assertThat(message).contains("Try again in $time second")
+    }
+
+    companion object {
+        private val WRONG_PIN = FakeAuthenticationRepository.DEFAULT_PIN.map { it + 1 }
+        private const val PRIMARY_USER_ID = 0
+        private val PRIMARY_USER =
+            UserInfo(
+                /* id= */ PRIMARY_USER_ID,
+                /* name= */ "primary user",
+                /* flags= */ UserInfo.FLAG_PRIMARY
+            )
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
index 73db175..3afca96 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
@@ -37,7 +37,6 @@
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
 import com.google.common.truth.Truth.assertWithMessage
-import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.emptyFlow
 import kotlinx.coroutines.flow.flatMapLatest
@@ -142,54 +141,6 @@
     }
 
     @Test
-    fun message() =
-        testScope.runTest {
-            val message by collectLastValue(underTest.message)
-            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pin)
-            assertThat(message?.isUpdateAnimated).isTrue()
-
-            repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) {
-                bouncerInteractor.authenticate(WRONG_PIN)
-            }
-            assertThat(message?.isUpdateAnimated).isFalse()
-
-            val lockoutEndMs = authenticationInteractor.lockoutEndTimestamp ?: 0
-            advanceTimeBy(lockoutEndMs - testScope.currentTime)
-            assertThat(message?.isUpdateAnimated).isTrue()
-        }
-
-    @Test
-    fun lockoutMessage() =
-        testScope.runTest {
-            val authMethodViewModel by collectLastValue(underTest.authMethodViewModel)
-            val message by collectLastValue(underTest.message)
-            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pin)
-            assertThat(kosmos.fakeAuthenticationRepository.lockoutEndTimestamp).isNull()
-            assertThat(authMethodViewModel?.lockoutMessageId).isNotNull()
-
-            repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { times ->
-                bouncerInteractor.authenticate(WRONG_PIN)
-                if (times < FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) {
-                    assertThat(message?.text).isEqualTo(bouncerInteractor.message.value)
-                    assertThat(message?.isUpdateAnimated).isTrue()
-                }
-            }
-            val lockoutSeconds = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS
-            assertTryAgainMessage(message?.text, lockoutSeconds)
-            assertThat(message?.isUpdateAnimated).isFalse()
-
-            repeat(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS) { time ->
-                advanceTimeBy(1.seconds)
-                val remainingSeconds = lockoutSeconds - time - 1
-                if (remainingSeconds > 0) {
-                    assertTryAgainMessage(message?.text, remainingSeconds)
-                }
-            }
-            assertThat(message?.text).isEmpty()
-            assertThat(message?.isUpdateAnimated).isTrue()
-        }
-
-    @Test
     fun isInputEnabled() =
         testScope.runTest {
             val isInputEnabled by
@@ -212,25 +163,6 @@
         }
 
     @Test
-    fun dialogViewModel() =
-        testScope.runTest {
-            val authMethodViewModel by collectLastValue(underTest.authMethodViewModel)
-            val dialogViewModel by collectLastValue(underTest.dialogViewModel)
-            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(Pin)
-            assertThat(authMethodViewModel?.lockoutMessageId).isNotNull()
-
-            repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) {
-                assertThat(dialogViewModel).isNull()
-                bouncerInteractor.authenticate(WRONG_PIN)
-            }
-            assertThat(dialogViewModel).isNotNull()
-            assertThat(dialogViewModel?.text).isNotEmpty()
-
-            dialogViewModel?.onDismiss?.invoke()
-            assertThat(dialogViewModel).isNull()
-        }
-
-    @Test
     fun isSideBySideSupported() =
         testScope.runTest {
             val isSideBySideSupported by collectLastValue(underTest.isSideBySideSupported)
@@ -265,13 +197,6 @@
         return listOf(None, Pin, Password, Pattern, Sim)
     }
 
-    private fun assertTryAgainMessage(
-        message: String?,
-        time: Int,
-    ) {
-        assertThat(message).isEqualTo("Try again in $time seconds.")
-    }
-
     companion object {
         private val WRONG_PIN = FakeAuthenticationRepository.DEFAULT_PIN.map { it + 1 }
     }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt
index df50eb6..71c5785 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt
@@ -66,7 +66,6 @@
     private val bouncerInteractor by lazy { kosmos.bouncerInteractor }
     private val selectedUserInteractor by lazy { kosmos.selectedUserInteractor }
     private val inputMethodInteractor by lazy { kosmos.inputMethodInteractor }
-    private val bouncerViewModel by lazy { kosmos.bouncerViewModel }
     private val isInputEnabled = MutableStateFlow(true)
 
     private val underTest =
@@ -76,6 +75,7 @@
             interactor = bouncerInteractor,
             inputMethodInteractor = inputMethodInteractor,
             selectedUserInteractor = selectedUserInteractor,
+            onIntentionalUserInput = {},
         )
 
     @Before
@@ -88,11 +88,9 @@
     fun onShown() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val password by collectLastValue(underTest.password)
             lockDeviceAndOpenPasswordBouncer()
 
-            assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD)
             assertThat(password).isEmpty()
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
             assertThat(underTest.authenticationMethod).isEqualTo(AuthenticationMethodModel.Password)
@@ -101,16 +99,13 @@
     @Test
     fun onHidden_resetsPasswordInputAndMessage() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val password by collectLastValue(underTest.password)
             lockDeviceAndOpenPasswordBouncer()
 
             underTest.onPasswordInputChanged("password")
-            assertThat(message?.text).isNotEqualTo(ENTER_YOUR_PASSWORD)
             assertThat(password).isNotEmpty()
 
             underTest.onHidden()
-            assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD)
             assertThat(password).isEmpty()
         }
 
@@ -118,13 +113,11 @@
     fun onPasswordInputChanged() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val password by collectLastValue(underTest.password)
             lockDeviceAndOpenPasswordBouncer()
 
             underTest.onPasswordInputChanged("password")
 
-            assertThat(message?.text).isEmpty()
             assertThat(password).isEqualTo("password")
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
         }
@@ -144,7 +137,6 @@
     @Test
     fun onAuthenticateKeyPressed_whenWrong() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val password by collectLastValue(underTest.password)
             lockDeviceAndOpenPasswordBouncer()
 
@@ -152,13 +144,11 @@
             underTest.onAuthenticateKeyPressed()
 
             assertThat(password).isEmpty()
-            assertThat(message?.text).isEqualTo(WRONG_PASSWORD)
         }
 
     @Test
     fun onAuthenticateKeyPressed_whenEmpty() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val password by collectLastValue(underTest.password)
             kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                 AuthenticationMethodModel.Password
@@ -171,14 +161,12 @@
             underTest.onAuthenticateKeyPressed()
 
             assertThat(password).isEmpty()
-            assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD)
         }
 
     @Test
     fun onAuthenticateKeyPressed_correctAfterWrong() =
         testScope.runTest {
             val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult)
-            val message by collectLastValue(bouncerViewModel.message)
             val password by collectLastValue(underTest.password)
             lockDeviceAndOpenPasswordBouncer()
 
@@ -186,12 +174,10 @@
             underTest.onPasswordInputChanged("wrong")
             underTest.onAuthenticateKeyPressed()
             assertThat(password).isEqualTo("")
-            assertThat(message?.text).isEqualTo(WRONG_PASSWORD)
             assertThat(authResult).isFalse()
 
             // Enter the correct password:
             underTest.onPasswordInputChanged("password")
-            assertThat(message?.text).isEmpty()
 
             underTest.onAuthenticateKeyPressed()
 
@@ -331,10 +317,8 @@
 
     private fun TestScope.switchToScene(toScene: SceneKey) {
         val currentScene by collectLastValue(sceneInteractor.currentScene)
-        val bouncerShown = currentScene != Scenes.Bouncer && toScene == Scenes.Bouncer
         val bouncerHidden = currentScene == Scenes.Bouncer && toScene != Scenes.Bouncer
         sceneInteractor.changeScene(toScene, "reason")
-        if (bouncerShown) underTest.onShown()
         if (bouncerHidden) underTest.onHidden()
         runCurrent()
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt
index 91a056d..51b73ee9 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt
@@ -63,6 +63,7 @@
             viewModelScope = testScope.backgroundScope,
             interactor = bouncerInteractor,
             isInputEnabled = MutableStateFlow(true).asStateFlow(),
+            onIntentionalUserInput = {},
         )
     }
 
@@ -79,12 +80,10 @@
     fun onShown() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val selectedDots by collectLastValue(underTest.selectedDots)
             val currentDot by collectLastValue(underTest.currentDot)
             lockDeviceAndOpenPatternBouncer()
 
-            assertThat(message?.text).isEqualTo(ENTER_YOUR_PATTERN)
             assertThat(selectedDots).isEmpty()
             assertThat(currentDot).isNull()
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
@@ -95,14 +94,12 @@
     fun onDragStart() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val selectedDots by collectLastValue(underTest.selectedDots)
             val currentDot by collectLastValue(underTest.currentDot)
             lockDeviceAndOpenPatternBouncer()
 
             underTest.onDragStart()
 
-            assertThat(message?.text).isEmpty()
             assertThat(selectedDots).isEmpty()
             assertThat(currentDot).isNull()
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
@@ -148,7 +145,6 @@
     fun onDragEnd_whenWrong() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val selectedDots by collectLastValue(underTest.selectedDots)
             val currentDot by collectLastValue(underTest.currentDot)
             lockDeviceAndOpenPatternBouncer()
@@ -159,7 +155,6 @@
 
             assertThat(selectedDots).isEmpty()
             assertThat(currentDot).isNull()
-            assertThat(message?.text).isEqualTo(WRONG_PATTERN)
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
         }
 
@@ -302,7 +297,6 @@
     @Test
     fun onDragEnd_whenPatternTooShort() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val dialogViewModel by collectLastValue(bouncerViewModel.dialogViewModel)
             lockDeviceAndOpenPatternBouncer()
 
@@ -325,7 +319,6 @@
 
                 underTest.onDragEnd()
 
-                assertWithMessage("Attempt #$attempt").that(message?.text).isEqualTo(WRONG_PATTERN)
                 assertWithMessage("Attempt #$attempt").that(dialogViewModel).isNull()
             }
         }
@@ -334,7 +327,6 @@
     fun onDragEnd_correctAfterWrong() =
         testScope.runTest {
             val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult)
-            val message by collectLastValue(bouncerViewModel.message)
             val selectedDots by collectLastValue(underTest.selectedDots)
             val currentDot by collectLastValue(underTest.currentDot)
             lockDeviceAndOpenPatternBouncer()
@@ -344,7 +336,6 @@
             underTest.onDragEnd()
             assertThat(selectedDots).isEmpty()
             assertThat(currentDot).isNull()
-            assertThat(message?.text).isEqualTo(WRONG_PATTERN)
             assertThat(authResult).isFalse()
 
             // Enter the correct pattern:
@@ -370,10 +361,8 @@
 
     private fun TestScope.switchToScene(toScene: SceneKey) {
         val currentScene by collectLastValue(sceneInteractor.currentScene)
-        val bouncerShown = currentScene != Scenes.Bouncer && toScene == Scenes.Bouncer
         val bouncerHidden = currentScene == Scenes.Bouncer && toScene != Scenes.Bouncer
         sceneInteractor.changeScene(toScene, "reason")
-        if (bouncerShown) underTest.onShown()
         if (bouncerHidden) underTest.onHidden()
         runCurrent()
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt
index 7b75a37..5647954 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt
@@ -56,7 +56,6 @@
     private val sceneInteractor by lazy { kosmos.sceneInteractor }
     private val authenticationInteractor by lazy { kosmos.authenticationInteractor }
     private val bouncerInteractor by lazy { kosmos.bouncerInteractor }
-    private val bouncerViewModel by lazy { kosmos.bouncerViewModel }
     private lateinit var underTest: PinBouncerViewModel
 
     @Before
@@ -69,6 +68,7 @@
                 isInputEnabled = MutableStateFlow(true).asStateFlow(),
                 simBouncerInteractor = kosmos.simBouncerInteractor,
                 authenticationMethod = AuthenticationMethodModel.Pin,
+                onIntentionalUserInput = {},
             )
 
         overrideResource(R.string.keyguard_enter_your_pin, ENTER_YOUR_PIN)
@@ -78,11 +78,9 @@
     @Test
     fun onShown() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             lockDeviceAndOpenPinBouncer()
 
-            assertThat(message?.text).ignoringCase().isEqualTo(ENTER_YOUR_PIN)
             assertThat(pin).isEmpty()
             assertThat(underTest.authenticationMethod).isEqualTo(AuthenticationMethodModel.Pin)
         }
@@ -98,6 +96,7 @@
                     isInputEnabled = MutableStateFlow(true).asStateFlow(),
                     simBouncerInteractor = kosmos.simBouncerInteractor,
                     authenticationMethod = AuthenticationMethodModel.Sim,
+                    onIntentionalUserInput = {},
                 )
 
             assertThat(underTest.isSimAreaVisible).isTrue()
@@ -126,6 +125,7 @@
                     isInputEnabled = MutableStateFlow(true).asStateFlow(),
                     simBouncerInteractor = kosmos.simBouncerInteractor,
                     authenticationMethod = AuthenticationMethodModel.Sim,
+                    onIntentionalUserInput = {},
                 )
             kosmos.fakeAuthenticationRepository.setAutoConfirmFeatureEnabled(true)
             val hintedPinLength by collectLastValue(underTest.hintedPinLength)
@@ -136,20 +136,17 @@
     @Test
     fun onPinButtonClicked() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             lockDeviceAndOpenPinBouncer()
 
             underTest.onPinButtonClicked(1)
 
-            assertThat(message?.text).isEmpty()
             assertThat(pin).containsExactly(1)
         }
 
     @Test
     fun onBackspaceButtonClicked() =
         testScope.runTest {
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             lockDeviceAndOpenPinBouncer()
 
@@ -158,7 +155,6 @@
 
             underTest.onBackspaceButtonClicked()
 
-            assertThat(message?.text).isEmpty()
             assertThat(pin).isEmpty()
         }
 
@@ -183,7 +179,6 @@
     fun onBackspaceButtonLongPressed() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             lockDeviceAndOpenPinBouncer()
 
@@ -195,7 +190,6 @@
 
             underTest.onBackspaceButtonLongPressed()
 
-            assertThat(message?.text).isEmpty()
             assertThat(pin).isEmpty()
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
         }
@@ -217,7 +211,6 @@
     fun onAuthenticateButtonClicked_whenWrong() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             lockDeviceAndOpenPinBouncer()
 
@@ -230,7 +223,6 @@
             underTest.onAuthenticateButtonClicked()
 
             assertThat(pin).isEmpty()
-            assertThat(message?.text).ignoringCase().isEqualTo(WRONG_PIN)
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
         }
 
@@ -238,7 +230,6 @@
     fun onAuthenticateButtonClicked_correctAfterWrong() =
         testScope.runTest {
             val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult)
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             lockDeviceAndOpenPinBouncer()
 
@@ -248,13 +239,11 @@
             underTest.onPinButtonClicked(4)
             underTest.onPinButtonClicked(5) // PIN is now wrong!
             underTest.onAuthenticateButtonClicked()
-            assertThat(message?.text).ignoringCase().isEqualTo(WRONG_PIN)
             assertThat(pin).isEmpty()
             assertThat(authResult).isFalse()
 
             // Enter the correct PIN:
             FakeAuthenticationRepository.DEFAULT_PIN.forEach(underTest::onPinButtonClicked)
-            assertThat(message?.text).isEmpty()
 
             underTest.onAuthenticateButtonClicked()
 
@@ -277,7 +266,6 @@
     fun onAutoConfirm_whenWrong() =
         testScope.runTest {
             val currentScene by collectLastValue(sceneInteractor.currentScene)
-            val message by collectLastValue(bouncerViewModel.message)
             val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
             kosmos.fakeAuthenticationRepository.setAutoConfirmFeatureEnabled(true)
             lockDeviceAndOpenPinBouncer()
@@ -290,7 +278,6 @@
             ) // PIN is now wrong!
 
             assertThat(pin).isEmpty()
-            assertThat(message?.text).ignoringCase().isEqualTo(WRONG_PIN)
             assertThat(currentScene).isEqualTo(Scenes.Bouncer)
         }
 
@@ -390,10 +377,8 @@
 
     private fun TestScope.switchToScene(toScene: SceneKey) {
         val currentScene by collectLastValue(sceneInteractor.currentScene)
-        val bouncerShown = currentScene != Scenes.Bouncer && toScene == Scenes.Bouncer
         val bouncerHidden = currentScene == Scenes.Bouncer && toScene != Scenes.Bouncer
         sceneInteractor.changeScene(toScene, "reason")
-        if (bouncerShown) underTest.onShown()
         if (bouncerHidden) underTest.onHidden()
         runCurrent()
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalEditModeViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalEditModeViewModelTest.kt
index 8e2e947..a7e98ea 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalEditModeViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalEditModeViewModelTest.kt
@@ -18,10 +18,16 @@
 
 import android.app.smartspace.SmartspaceTarget
 import android.appwidget.AppWidgetProviderInfo
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
 import android.content.pm.UserInfo
 import android.os.UserHandle
 import android.provider.Settings
 import android.widget.RemoteViews
+import androidx.activity.result.ActivityResultLauncher
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.android.internal.logging.UiEventLogger
@@ -39,6 +45,7 @@
 import com.android.systemui.communal.shared.model.CommunalWidgetContentModel
 import com.android.systemui.communal.ui.viewmodel.CommunalEditModeViewModel
 import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testDispatcher
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.log.logcatLogBuffer
 import com.android.systemui.media.controls.ui.view.MediaHost
@@ -46,15 +53,19 @@
 import com.android.systemui.smartspace.data.repository.FakeSmartspaceRepository
 import com.android.systemui.smartspace.data.repository.fakeSmartspaceRepository
 import com.android.systemui.testKosmos
-import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.any
 import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
 import org.mockito.Mock
 import org.mockito.Mockito
+import org.mockito.Mockito.never
 import org.mockito.Mockito.verify
 import org.mockito.MockitoAnnotations
 
@@ -64,6 +75,8 @@
     @Mock private lateinit var mediaHost: MediaHost
     @Mock private lateinit var uiEventLogger: UiEventLogger
     @Mock private lateinit var providerInfo: AppWidgetProviderInfo
+    @Mock private lateinit var packageManager: PackageManager
+    @Mock private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
 
     private val kosmos = testKosmos()
     private val testScope = kosmos.testScope
@@ -73,6 +86,8 @@
     private lateinit var smartspaceRepository: FakeSmartspaceRepository
     private lateinit var mediaRepository: FakeCommunalMediaRepository
 
+    private val testableResources = context.orCreateTestableResources
+
     private lateinit var underTest: CommunalEditModeViewModel
 
     @Before
@@ -96,6 +111,7 @@
                 mediaHost,
                 uiEventLogger,
                 logcatLogBuffer("CommunalEditModeViewModelTest"),
+                kosmos.testDispatcher,
             )
     }
 
@@ -217,7 +233,69 @@
         verify(uiEventLogger).log(CommunalUiEvent.COMMUNAL_HUB_REORDER_WIDGET_CANCEL)
     }
 
+    @Test
+    fun onOpenWidgetPicker_launchesWidgetPickerActivity() {
+        testScope.runTest {
+            whenever(packageManager.resolveActivity(any(), anyInt())).then {
+                ResolveInfo().apply {
+                    activityInfo = ActivityInfo().apply { packageName = WIDGET_PICKER_PACKAGE_NAME }
+                }
+            }
+
+            val success =
+                underTest.onOpenWidgetPicker(
+                    testableResources.resources,
+                    packageManager,
+                    activityResultLauncher
+                )
+
+            verify(activityResultLauncher).launch(any())
+            assertTrue(success)
+        }
+    }
+
+    @Test
+    fun onOpenWidgetPicker_launcherActivityNotResolved_doesNotLaunchWidgetPickerActivity() {
+        testScope.runTest {
+            whenever(packageManager.resolveActivity(any(), anyInt())).thenReturn(null)
+
+            val success =
+                underTest.onOpenWidgetPicker(
+                    testableResources.resources,
+                    packageManager,
+                    activityResultLauncher
+                )
+
+            verify(activityResultLauncher, never()).launch(any())
+            assertFalse(success)
+        }
+    }
+
+    @Test
+    fun onOpenWidgetPicker_activityLaunchThrowsException_failure() {
+        testScope.runTest {
+            whenever(packageManager.resolveActivity(any(), anyInt())).then {
+                ResolveInfo().apply {
+                    activityInfo = ActivityInfo().apply { packageName = WIDGET_PICKER_PACKAGE_NAME }
+                }
+            }
+
+            whenever(activityResultLauncher.launch(any()))
+                .thenThrow(ActivityNotFoundException::class.java)
+
+            val success =
+                underTest.onOpenWidgetPicker(
+                    testableResources.resources,
+                    packageManager,
+                    activityResultLauncher,
+                )
+
+            assertFalse(success)
+        }
+    }
+
     private companion object {
         val MAIN_USER_INFO = UserInfo(0, "primary", UserInfo.FLAG_MAIN)
+        const val WIDGET_PICKER_PACKAGE_NAME = "widget_picker_package_name"
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractorTest.kt
similarity index 83%
rename from packages/SystemUI/tests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractorTest.kt
index decbdaf..51f9957 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractorTest.kt
@@ -26,12 +26,10 @@
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
-import kotlin.test.Test
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.runTest
+import org.junit.Test
 import org.junit.runner.RunWith
 
-@OptIn(ExperimentalCoroutinesApi::class)
 @SmallTest
 @RunWith(AndroidJUnit4::class)
 class DeviceEntryFingerprintAuthInteractorTest : SysuiTestCase() {
@@ -59,17 +57,20 @@
         }
 
     @Test
-    fun isSensorUnderDisplay_trueForUdfpsSensorTypes() =
+    fun isFingerprintCurrentlyAllowedInBouncer_trueForNonUdfpsSensorTypes() =
         testScope.runTest {
-            val isSensorUnderDisplay by collectLastValue(underTest.isSensorUnderDisplay)
+            biometricSettingsRepository.setIsFingerprintAuthCurrentlyAllowed(true)
+
+            val isFingerprintCurrentlyAllowedInBouncer by
+                collectLastValue(underTest.isFingerprintCurrentlyAllowedOnBouncer)
 
             fingerprintPropertyRepository.supportsUdfps()
-            assertThat(isSensorUnderDisplay).isTrue()
+            assertThat(isFingerprintCurrentlyAllowedInBouncer).isFalse()
 
             fingerprintPropertyRepository.supportsRearFps()
-            assertThat(isSensorUnderDisplay).isFalse()
+            assertThat(isFingerprintCurrentlyAllowedInBouncer).isTrue()
 
             fingerprintPropertyRepository.supportsSideFps()
-            assertThat(isSensorUnderDisplay).isFalse()
+            assertThat(isFingerprintCurrentlyAllowedInBouncer).isTrue()
         }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterImplTest.kt
index 3c0ab24..27c4ec1 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterImplTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterImplTest.kt
@@ -27,9 +27,17 @@
 import com.android.systemui.common.ui.data.repository.FakeConfigurationRepository
 import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor
 import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
 import com.android.systemui.qs.QSImpl
 import com.android.systemui.qs.dagger.QSComponent
 import com.android.systemui.qs.dagger.QSSceneComponent
+import com.android.systemui.shade.data.repository.fakeShadeRepository
+import com.android.systemui.shade.domain.interactor.shadeInteractor
+import com.android.systemui.shade.shared.model.ShadeMode
 import com.android.systemui.util.mockito.any
 import com.android.systemui.util.mockito.argumentCaptor
 import com.android.systemui.util.mockito.capture
@@ -41,8 +49,6 @@
 import java.util.Locale
 import javax.inject.Provider
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
@@ -57,8 +63,9 @@
 @OptIn(ExperimentalCoroutinesApi::class)
 class QSSceneAdapterImplTest : SysuiTestCase() {
 
-    private val testDispatcher = StandardTestDispatcher()
-    private val testScope = TestScope(testDispatcher)
+    private val kosmos = Kosmos().apply { testCase = this@QSSceneAdapterImplTest }
+    private val testDispatcher = kosmos.testDispatcher
+    private val testScope = kosmos.testScope
 
     private val qsImplProvider =
         object : Provider<QSImpl> {
@@ -107,10 +114,15 @@
             }
         }
 
+    private val shadeInteractor = kosmos.shadeInteractor
+    private val dumpManager = mock<DumpManager>()
+
     private val underTest =
         QSSceneAdapterImpl(
             qsSceneComponentFactory,
             qsImplProvider,
+            shadeInteractor,
+            dumpManager,
             testDispatcher,
             testScope.backgroundScope,
             configurationInteractor,
@@ -158,12 +170,6 @@
                     )
                 verify(this).setListening(false)
                 verify(this).setExpanded(false)
-                verify(this)
-                    .setTransitionToFullShadeProgress(
-                        /* isTransitioningToFullShade= */ false,
-                        /* qsTransitionFraction= */ 1f,
-                        /* qsSquishinessFraction = */ 1f,
-                    )
             }
         }
 
@@ -187,13 +193,7 @@
                         /* squishinessFraction= */ 1f,
                     )
                 verify(this).setListening(true)
-                verify(this).setExpanded(true)
-                verify(this)
-                    .setTransitionToFullShadeProgress(
-                        /* isTransitioningToFullShade= */ false,
-                        /* qsTransitionFraction= */ 1f,
-                        /* qsSquishinessFraction = */ 1f,
-                    )
+                verify(this).setExpanded(false)
             }
         }
 
@@ -218,12 +218,6 @@
                     )
                 verify(this).setListening(true)
                 verify(this).setExpanded(true)
-                verify(this)
-                    .setTransitionToFullShadeProgress(
-                        /* isTransitioningToFullShade= */ false,
-                        /* qsTransitionFraction= */ 1f,
-                        /* qsSquishinessFraction = */ 1f,
-                    )
             }
         }
 
@@ -249,12 +243,6 @@
                     )
                 verify(this).setListening(true)
                 verify(this).setExpanded(true)
-                verify(this)
-                    .setTransitionToFullShadeProgress(
-                        /* isTransitioningToFullShade= */ false,
-                        /* qsTransitionFraction= */ 1f,
-                        /* qsSquishinessFraction = */ 1f,
-                    )
             }
         }
 
@@ -268,7 +256,7 @@
             runCurrent()
             clearInvocations(qsImpl!!)
 
-            underTest.setState(QSSceneAdapter.State.Unsquishing(squishiness))
+            underTest.setState(QSSceneAdapter.State.UnsquishingQQS(squishiness))
             with(qsImpl!!) {
                 verify(this).setQsVisible(true)
                 verify(this)
@@ -279,13 +267,7 @@
                         /* squishinessFraction= */ squishiness,
                     )
                 verify(this).setListening(true)
-                verify(this).setExpanded(true)
-                verify(this)
-                    .setTransitionToFullShadeProgress(
-                        /* isTransitioningToFullShade= */ false,
-                        /* qsTransitionFraction= */ 1f,
-                        /* qsSquishinessFraction = */ squishiness,
-                    )
+                verify(this).setExpanded(false)
             }
         }
 
@@ -497,4 +479,21 @@
 
             verify(qsImpl!!).applyBottomNavBarToCustomizerPadding(navBarHeight)
         }
+
+    @Test
+    fun dispatchSplitShade() =
+        testScope.runTest {
+            val shadeRepository = kosmos.fakeShadeRepository
+            shadeRepository.setShadeMode(ShadeMode.Single)
+            val qsImpl by collectLastValue(underTest.qsImpl)
+
+            underTest.inflate(context)
+            runCurrent()
+
+            verify(qsImpl!!).setInSplitShade(false)
+
+            shadeRepository.setShadeMode(ShadeMode.Split)
+            runCurrent()
+            verify(qsImpl!!).setInSplitShade(true)
+        }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterTest.kt
index e281383..ebd65fd 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/adapter/QSSceneAdapterTest.kt
@@ -49,9 +49,16 @@
     }
 
     @Test
-    fun unsquishing_expansionSameAsQQS() {
+    fun unsquishingQQS_expansionSameAsQQS() {
         val squishiness = 0.6f
-        assertThat(QSSceneAdapter.State.Unsquishing(squishiness).expansion)
+        assertThat(QSSceneAdapter.State.UnsquishingQQS(squishiness).expansion)
             .isEqualTo(QSSceneAdapter.State.QQS.expansion)
     }
+
+    @Test
+    fun unsquishingQS_expansionSameAsQS() {
+        val squishiness = 0.6f
+        assertThat(QSSceneAdapter.State.UnsquishingQS(squishiness).expansion)
+            .isEqualTo(QSSceneAdapter.State.QS.expansion)
+    }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt
index 1c54961..d1c4ec3 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt
@@ -95,7 +95,7 @@
             scope = testScope.backgroundScope,
         )
 
-    private val qsFlexiglassAdapter = FakeQSSceneAdapter({ mock() })
+    private val qsSceneAdapter = FakeQSSceneAdapter({ mock() })
 
     private lateinit var shadeHeaderViewModel: ShadeHeaderViewModel
 
@@ -122,7 +122,7 @@
                 applicationScope = testScope.backgroundScope,
                 deviceEntryInteractor = deviceEntryInteractor,
                 shadeHeaderViewModel = shadeHeaderViewModel,
-                qsSceneAdapter = qsFlexiglassAdapter,
+                qsSceneAdapter = qsSceneAdapter,
                 notifications = kosmos.notificationsPlaceholderViewModel,
                 mediaDataManager = mediaDataManager,
                 shadeInteractor = kosmos.shadeInteractor,
@@ -279,6 +279,20 @@
         }
 
     @Test
+    fun upTransitionSceneKey_customizing_noTransition() =
+            testScope.runTest {
+                val destinationScenes by collectLastValue(underTest.destinationScenes)
+
+                qsSceneAdapter.setCustomizing(true)
+                assertThat(
+                        destinationScenes!!
+                                .keys
+                                .filterIsInstance<Swipe>()
+                                .filter { it.direction == SwipeDirection.Up }
+                ).isEmpty()
+            }
+
+    @Test
     fun shadeMode() =
         testScope.runTest {
             val shadeMode by collectLastValue(underTest.shadeMode)
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt
index 2689fc1..94539a3 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt
@@ -22,7 +22,6 @@
 import androidx.test.filters.SmallTest
 import com.android.compose.animation.scene.ObservableTransitionState
 import com.android.systemui.SysuiTestCase
-import com.android.systemui.common.shared.model.NotificationContainerBounds
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.flags.Flags
 import com.android.systemui.flags.fakeFeatureFlagsClassic
@@ -31,6 +30,7 @@
 import com.android.systemui.scene.shared.flag.fakeSceneContainerFlags
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.scene.shared.model.fakeSceneDataSource
+import com.android.systemui.statusbar.notification.stack.shared.model.StackBounds
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.notificationStackAppearanceViewModel
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.notificationsPlaceholderViewModel
 import com.android.systemui.testKosmos
@@ -64,7 +64,7 @@
     @Test
     fun updateBounds() =
         testScope.runTest {
-            val bounds by collectLastValue(appearanceViewModel.stackBounds)
+            val clipping by collectLastValue(appearanceViewModel.stackClipping)
 
             val top = 200f
             val left = 0f
@@ -76,15 +76,8 @@
                 right = right,
                 bottom = bottom
             )
-            assertThat(bounds)
-                .isEqualTo(
-                    NotificationContainerBounds(
-                        left = left,
-                        top = top,
-                        right = right,
-                        bottom = bottom
-                    )
-                )
+            assertThat(clipping?.bounds)
+                .isEqualTo(StackBounds(left = left, top = top, right = right, bottom = bottom))
         }
 
     @Test
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorTest.kt
index ffe6e6d..e3fa89c 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorTest.kt
@@ -19,10 +19,13 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.android.systemui.SysuiTestCase
-import com.android.systemui.common.shared.model.NotificationContainerBounds
 import com.android.systemui.coroutines.collectLastValue
-import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.testScope
+import com.android.systemui.shade.data.repository.shadeRepository
+import com.android.systemui.shade.shared.model.ShadeMode
+import com.android.systemui.statusbar.notification.stack.shared.model.StackBounds
+import com.android.systemui.statusbar.notification.stack.shared.model.StackRounding
+import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
@@ -30,10 +33,9 @@
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
-@android.platform.test.annotations.EnabledOnRavenwood
 class NotificationStackAppearanceInteractorTest : SysuiTestCase() {
 
-    private val kosmos = Kosmos()
+    private val kosmos = testKosmos()
     private val testScope = kosmos.testScope
     private val underTest = kosmos.notificationStackAppearanceInteractor
 
@@ -43,29 +45,39 @@
             val stackBounds by collectLastValue(underTest.stackBounds)
 
             val bounds1 =
-                NotificationContainerBounds(
+                StackBounds(
                     top = 100f,
                     bottom = 200f,
-                    isAnimated = true,
                 )
             underTest.setStackBounds(bounds1)
             assertThat(stackBounds).isEqualTo(bounds1)
 
             val bounds2 =
-                NotificationContainerBounds(
+                StackBounds(
                     top = 200f,
                     bottom = 300f,
-                    isAnimated = false,
                 )
             underTest.setStackBounds(bounds2)
             assertThat(stackBounds).isEqualTo(bounds2)
         }
 
+    @Test
+    fun stackRounding() =
+        testScope.runTest {
+            val stackRounding by collectLastValue(underTest.stackRounding)
+
+            kosmos.shadeRepository.setShadeMode(ShadeMode.Single)
+            assertThat(stackRounding).isEqualTo(StackRounding(roundTop = true, roundBottom = false))
+
+            kosmos.shadeRepository.setShadeMode(ShadeMode.Split)
+            assertThat(stackRounding).isEqualTo(StackRounding(roundTop = true, roundBottom = true))
+        }
+
     @Test(expected = IllegalStateException::class)
     fun setStackBounds_withImproperBounds_throwsException() =
         testScope.runTest {
             underTest.setStackBounds(
-                NotificationContainerBounds(
+                StackBounds(
                     top = 100f,
                     bottom = 99f,
                 )
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModelTest.kt
index 693de55..2ccc8b4 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModelTest.kt
@@ -22,6 +22,7 @@
 import com.android.systemui.common.shared.model.NotificationContainerBounds
 import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
 import com.android.systemui.statusbar.notification.stack.domain.interactor.notificationStackAppearanceInteractor
+import com.android.systemui.statusbar.notification.stack.shared.model.StackBounds
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
@@ -36,9 +37,9 @@
     fun onBoundsChanged_setsNotificationContainerBounds() {
         underTest.onBoundsChanged(left = 5f, top = 5f, right = 5f, bottom = 5f)
         assertThat(kosmos.keyguardInteractor.notificationContainerBounds.value)
-            .isEqualTo(NotificationContainerBounds(left = 5f, top = 5f, right = 5f, bottom = 5f))
+            .isEqualTo(NotificationContainerBounds(top = 5f, bottom = 5f))
         assertThat(kosmos.notificationStackAppearanceInteractor.stackBounds.value)
-            .isEqualTo(NotificationContainerBounds(left = 5f, top = 5f, right = 5f, bottom = 5f))
+            .isEqualTo(StackBounds(left = 5f, top = 5f, right = 5f, bottom = 5f))
     }
     @Test
     fun onContentTopChanged_setsContentTop() {
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/util/kotlin/DisposableHandlesTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/util/kotlin/DisposableHandlesTest.kt
new file mode 100644
index 0000000..a5ad3c3
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/util/kotlin/DisposableHandlesTest.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.util.kotlin
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.DisposableHandle
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class DisposableHandlesTest : SysuiTestCase() {
+    @Test
+    fun disposeWorksOnce() {
+        var handleDisposeCount = 0
+        val underTest = DisposableHandles()
+
+        // Add a handle
+        underTest += DisposableHandle { handleDisposeCount++ }
+
+        // dispose() calls dispose() on children
+        underTest.dispose()
+        assertThat(handleDisposeCount).isEqualTo(1)
+
+        // Once disposed, children are not disposed again
+        underTest.dispose()
+        assertThat(handleDisposeCount).isEqualTo(1)
+    }
+
+    @Test
+    fun replaceCallsDispose() {
+        var handleDisposeCount1 = 0
+        var handleDisposeCount2 = 0
+        val underTest = DisposableHandles()
+        val handle1 = DisposableHandle { handleDisposeCount1++ }
+        val handle2 = DisposableHandle { handleDisposeCount2++ }
+
+        // First add handle1
+        underTest += handle1
+
+        // replace() calls dispose() on existing children
+        underTest.replaceAll(handle2)
+        assertThat(handleDisposeCount1).isEqualTo(1)
+        assertThat(handleDisposeCount2).isEqualTo(0)
+
+        // Once disposed, replaced children are not disposed again
+        underTest.dispose()
+        assertThat(handleDisposeCount1).isEqualTo(1)
+        assertThat(handleDisposeCount2).isEqualTo(1)
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaDeviceSessionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaDeviceSessionInteractorTest.kt
new file mode 100644
index 0000000..b5c5809
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaDeviceSessionInteractorTest.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.domain.interactor
+
+import android.os.Handler
+import android.testing.TestableLooper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.testKosmos
+import com.android.systemui.volume.localMediaController
+import com.android.systemui.volume.mediaControllerRepository
+import com.android.systemui.volume.mediaOutputInteractor
+import com.android.systemui.volume.remoteMediaController
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class MediaDeviceSessionInteractorTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+
+    private lateinit var underTest: MediaDeviceSessionInteractor
+
+    @Before
+    fun setup() {
+        with(kosmos) {
+            mediaControllerRepository.setActiveSessions(
+                listOf(localMediaController, remoteMediaController)
+            )
+
+            underTest =
+                MediaDeviceSessionInteractor(
+                    testScope.testScheduler,
+                    Handler(TestableLooper.get(kosmos.testCase).looper),
+                    mediaControllerRepository,
+                )
+        }
+    }
+
+    @Test
+    fun playbackInfo_returnsPlaybackInfo() {
+        with(kosmos) {
+            testScope.runTest {
+                val session by collectLastValue(mediaOutputInteractor.defaultActiveMediaSession)
+                runCurrent()
+                val info by collectLastValue(underTest.playbackInfo(session!!))
+                runCurrent()
+
+                assertThat(info).isEqualTo(localMediaController.playbackInfo)
+            }
+        }
+    }
+
+    @Test
+    fun playbackState_returnsPlaybackState() {
+        with(kosmos) {
+            testScope.runTest {
+                val session by collectLastValue(mediaOutputInteractor.defaultActiveMediaSession)
+                runCurrent()
+                val state by collectLastValue(underTest.playbackState(session!!))
+                runCurrent()
+
+                assertThat(state).isEqualTo(localMediaController.playbackState)
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModelTest.kt
index dcf635e..6f7f20b 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModelTest.kt
@@ -29,9 +29,10 @@
 import com.android.systemui.testKosmos
 import com.android.systemui.util.mockito.mock
 import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.localMediaController
 import com.android.systemui.volume.localMediaRepository
-import com.android.systemui.volume.mediaController
 import com.android.systemui.volume.mediaControllerRepository
+import com.android.systemui.volume.mediaDeviceSessionInteractor
 import com.android.systemui.volume.mediaOutputActionsInteractor
 import com.android.systemui.volume.mediaOutputInteractor
 import com.android.systemui.volume.panel.volumePanelViewModel
@@ -63,6 +64,7 @@
                     testScope.backgroundScope,
                     volumePanelViewModel,
                     mediaOutputActionsInteractor,
+                    mediaDeviceSessionInteractor,
                     mediaOutputInteractor,
                 )
 
@@ -74,11 +76,11 @@
                 )
             }
 
-            whenever(mediaController.packageName).thenReturn("test.pkg")
-            whenever(mediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
-            whenever(mediaController.playbackState).then { playbackStateBuilder.build() }
+            whenever(localMediaController.packageName).thenReturn("test.pkg")
+            whenever(localMediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
+            whenever(localMediaController.playbackState).then { playbackStateBuilder.build() }
 
-            mediaControllerRepository.setActiveLocalMediaController(mediaController)
+            mediaControllerRepository.setActiveSessions(listOf(localMediaController))
         }
     }
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt
index 1ed7f5d..2f69942 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt
@@ -32,8 +32,8 @@
 import com.android.systemui.testKosmos
 import com.android.systemui.util.mockito.mock
 import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.localMediaController
 import com.android.systemui.volume.localMediaRepository
-import com.android.systemui.volume.mediaController
 import com.android.systemui.volume.mediaControllerRepository
 import com.android.systemui.volume.panel.component.spatial.spatialAudioComponentInteractor
 import com.google.common.truth.Truth.assertThat
@@ -66,11 +66,11 @@
                 }
             )
 
-            whenever(mediaController.packageName).thenReturn("test.pkg")
-            whenever(mediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
-            whenever(mediaController.playbackState).thenReturn(PlaybackState.Builder().build())
+            whenever(localMediaController.packageName).thenReturn("test.pkg")
+            whenever(localMediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
+            whenever(localMediaController.playbackState).thenReturn(PlaybackState.Builder().build())
 
-            mediaControllerRepository.setActiveLocalMediaController(mediaController)
+            mediaControllerRepository.setActiveSessions(listOf(localMediaController))
 
             underTest = SpatialAudioAvailabilityCriteria(spatialAudioComponentInteractor)
         }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt
index 281b03d..e36ae60 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt
@@ -34,8 +34,8 @@
 import com.android.systemui.testKosmos
 import com.android.systemui.util.mockito.mock
 import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.localMediaController
 import com.android.systemui.volume.localMediaRepository
-import com.android.systemui.volume.mediaController
 import com.android.systemui.volume.mediaControllerRepository
 import com.android.systemui.volume.mediaOutputInteractor
 import com.android.systemui.volume.panel.component.spatial.domain.model.SpatialAudioAvailabilityModel
@@ -70,11 +70,11 @@
                 }
             )
 
-            whenever(mediaController.packageName).thenReturn("test.pkg")
-            whenever(mediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
-            whenever(mediaController.playbackState).thenReturn(PlaybackState.Builder().build())
+            whenever(localMediaController.packageName).thenReturn("test.pkg")
+            whenever(localMediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
+            whenever(localMediaController.playbackState).thenReturn(PlaybackState.Builder().build())
 
-            mediaControllerRepository.setActiveLocalMediaController(mediaController)
+            mediaControllerRepository.setActiveSessions(listOf(localMediaController))
 
             underTest =
                 SpatialAudioComponentInteractor(
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 77d1484..bf5eeb9 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -1994,8 +1994,6 @@
     <string name="group_system_cycle_back">Cycle backward through recent apps</string>
     <!-- User visible title for the keyboard shortcut that accesses list of all apps and search. [CHAR LIMIT=70] -->
     <string name="group_system_access_all_apps_search">Open apps list</string>
-    <!-- User visible title for the keyboard shortcut that hides and (re)showes taskbar. [CHAR LIMIT=70] -->
-    <string name="group_system_hide_reshow_taskbar">Show taskbar</string>
     <!-- User visible title for the keyboard shortcut that accesses [system] settings. [CHAR LIMIT=70] -->
     <string name="group_system_access_system_settings">Open settings</string>
     <!-- User visible title for the keyboard shortcut that accesses Assistant app. [CHAR LIMIT=70] -->
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt b/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt
index d849b3a..94e0854 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt
@@ -20,7 +20,6 @@
 import com.android.systemui.flags.FeatureFlagsClassic
 import com.android.systemui.flags.Flags
 import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
 
 /** Provides access to bouncer-related application state. */
 @SysUISingleton
@@ -29,9 +28,6 @@
 constructor(
     private val flags: FeatureFlagsClassic,
 ) {
-    /** The user-facing message to show in the bouncer. */
-    val message = MutableStateFlow<String?>(null)
-
     /** Whether the user switcher should be displayed within the bouncer UI on large screens. */
     val isUserSwitcherVisible: Boolean
         get() = flags.isEnabled(Flags.FULL_SCREEN_USER_SWITCHER)
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt
index d8be1af..aeb564d5 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt
@@ -16,13 +16,8 @@
 
 package com.android.systemui.bouncer.domain.interactor
 
-import android.content.Context
 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
 import com.android.systemui.authentication.domain.interactor.AuthenticationResult
-import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
-import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Password
-import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pattern
-import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pin
 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Sim
 import com.android.systemui.bouncer.data.repository.BouncerRepository
 import com.android.systemui.classifier.FalsingClassifier
@@ -31,7 +26,6 @@
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFaceAuthInteractor
 import com.android.systemui.power.domain.interactor.PowerInteractor
-import com.android.systemui.res.R
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.async
@@ -41,7 +35,6 @@
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
 
 /** Encapsulates business logic and application state accessing use-cases. */
 @SysUISingleton
@@ -49,16 +42,14 @@
 @Inject
 constructor(
     @Application private val applicationScope: CoroutineScope,
-    @Application private val applicationContext: Context,
     private val repository: BouncerRepository,
     private val authenticationInteractor: AuthenticationInteractor,
     private val deviceEntryFaceAuthInteractor: DeviceEntryFaceAuthInteractor,
     private val falsingInteractor: FalsingInteractor,
     private val powerInteractor: PowerInteractor,
-    private val simBouncerInteractor: SimBouncerInteractor,
 ) {
-    /** The user-facing message to show in the bouncer when lockout is not active. */
-    val message: StateFlow<String?> = repository.message
+    private val _onIncorrectBouncerInput = MutableSharedFlow<Unit>()
+    val onIncorrectBouncerInput: SharedFlow<Unit> = _onIncorrectBouncerInput
 
     /** Whether the auto confirm feature is enabled for the currently-selected user. */
     val isAutoConfirmEnabled: StateFlow<Boolean> = authenticationInteractor.isAutoConfirmEnabled
@@ -119,25 +110,6 @@
         )
     }
 
-    fun setMessage(message: String?) {
-        repository.message.value = message
-    }
-
-    /**
-     * Resets the user-facing message back to the default according to the current authentication
-     * method.
-     */
-    fun resetMessage() {
-        applicationScope.launch {
-            setMessage(promptMessage(authenticationInteractor.getAuthenticationMethod()))
-        }
-    }
-
-    /** Removes the user-facing message. */
-    fun clearMessage() {
-        setMessage(null)
-    }
-
     /**
      * Attempts to authenticate based on the given user input.
      *
@@ -176,50 +148,17 @@
                 .async { authenticationInteractor.authenticate(input, tryAutoConfirm) }
                 .await()
 
-        if (authenticationInteractor.lockoutEndTimestamp != null) {
-            clearMessage()
-        } else if (
+        if (
             authResult == AuthenticationResult.FAILED ||
                 (authResult == AuthenticationResult.SKIPPED && !tryAutoConfirm)
         ) {
-            showWrongInputMessage()
+            _onIncorrectBouncerInput.emit(Unit)
         }
         return authResult
     }
 
-    /**
-     * Shows the a message notifying the user that their credentials input is wrong.
-     *
-     * Callers should use this instead of [authenticate] when they know ahead of time that an auth
-     * attempt will fail but aren't interested in the other side effects like triggering lockout.
-     * For example, if the user entered a pattern that's too short, the system can show the error
-     * message without having the attempt trigger lockout.
-     */
-    private suspend fun showWrongInputMessage() {
-        setMessage(wrongInputMessage(authenticationInteractor.getAuthenticationMethod()))
-    }
-
     /** Notifies that the input method editor (software keyboard) has been hidden by the user. */
     suspend fun onImeHiddenByUser() {
         _onImeHiddenByUser.emit(Unit)
     }
-
-    private fun promptMessage(authMethod: AuthenticationMethodModel): String {
-        return when (authMethod) {
-            is Sim -> simBouncerInteractor.getDefaultMessage()
-            is Pin -> applicationContext.getString(R.string.keyguard_enter_your_pin)
-            is Password -> applicationContext.getString(R.string.keyguard_enter_your_password)
-            is Pattern -> applicationContext.getString(R.string.keyguard_enter_your_pattern)
-            else -> ""
-        }
-    }
-
-    private fun wrongInputMessage(authMethod: AuthenticationMethodModel): String {
-        return when (authMethod) {
-            is Pin -> applicationContext.getString(R.string.kg_wrong_pin)
-            is Password -> applicationContext.getString(R.string.kg_wrong_password)
-            is Pattern -> applicationContext.getString(R.string.kg_wrong_pattern)
-            else -> ""
-        }
-    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractor.kt
index 7f6fc91..d20c607 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerMessageInteractor.kt
@@ -33,15 +33,17 @@
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.deviceentry.data.repository.DeviceEntryFaceAuthRepository
+import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFingerprintAuthInteractor
 import com.android.systemui.flags.SystemPropertiesHelper
 import com.android.systemui.keyguard.data.repository.BiometricSettingsRepository
-import com.android.systemui.keyguard.data.repository.DeviceEntryFingerprintAuthRepository
 import com.android.systemui.keyguard.data.repository.TrustRepository
 import com.android.systemui.user.data.repository.UserRepository
-import com.android.systemui.util.kotlin.Quint
+import com.android.systemui.util.kotlin.Sextuple
+import com.android.systemui.util.kotlin.combine
 import javax.inject.Inject
 import kotlin.math.roundToInt
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
@@ -56,6 +58,7 @@
 private const val TAG = "BouncerMessageInteractor"
 
 /** Handles business logic for the primary bouncer message area. */
+@OptIn(ExperimentalCoroutinesApi::class)
 @SysUISingleton
 class BouncerMessageInteractor
 @Inject
@@ -63,23 +66,24 @@
     private val repository: BouncerMessageRepository,
     private val userRepository: UserRepository,
     private val countDownTimerUtil: CountDownTimerUtil,
-    private val updateMonitor: KeyguardUpdateMonitor,
+    updateMonitor: KeyguardUpdateMonitor,
     trustRepository: TrustRepository,
     biometricSettingsRepository: BiometricSettingsRepository,
     private val systemPropertiesHelper: SystemPropertiesHelper,
     primaryBouncerInteractor: PrimaryBouncerInteractor,
     @Application private val applicationScope: CoroutineScope,
     private val facePropertyRepository: FacePropertyRepository,
-    deviceEntryFingerprintAuthRepository: DeviceEntryFingerprintAuthRepository,
+    private val deviceEntryFingerprintAuthInteractor: DeviceEntryFingerprintAuthInteractor,
     faceAuthRepository: DeviceEntryFaceAuthRepository,
     private val securityModel: KeyguardSecurityModel,
 ) {
 
-    private val isFingerprintAuthCurrentlyAllowed =
-        deviceEntryFingerprintAuthRepository.isLockedOut
-            .isFalse()
-            .and(biometricSettingsRepository.isFingerprintAuthCurrentlyAllowed)
-            .stateIn(applicationScope, SharingStarted.Eagerly, false)
+    private val isFingerprintAuthCurrentlyAllowedOnBouncer =
+        deviceEntryFingerprintAuthInteractor.isFingerprintCurrentlyAllowedOnBouncer.stateIn(
+            applicationScope,
+            SharingStarted.Eagerly,
+            false
+        )
 
     private val currentSecurityMode
         get() = securityModel.getSecurityMode(currentUserId)
@@ -99,13 +103,13 @@
                         BiometricSourceType.FACE ->
                             BouncerMessageStrings.incorrectFaceInput(
                                     currentSecurityMode.toAuthModel(),
-                                    isFingerprintAuthCurrentlyAllowed.value
+                                    isFingerprintAuthCurrentlyAllowedOnBouncer.value
                                 )
                                 .toMessage()
                         else ->
                             BouncerMessageStrings.defaultMessage(
                                     currentSecurityMode.toAuthModel(),
-                                    isFingerprintAuthCurrentlyAllowed.value
+                                    isFingerprintAuthCurrentlyAllowedOnBouncer.value
                                 )
                                 .toMessage()
                     }
@@ -144,11 +148,12 @@
                 biometricSettingsRepository.authenticationFlags,
                 trustRepository.isCurrentUserTrustManaged,
                 isAnyBiometricsEnabledAndEnrolled,
-                deviceEntryFingerprintAuthRepository.isLockedOut,
+                deviceEntryFingerprintAuthInteractor.isLockedOut,
                 faceAuthRepository.isLockedOut,
-                ::Quint
+                isFingerprintAuthCurrentlyAllowedOnBouncer,
+                ::Sextuple
             )
-            .map { (flags, _, biometricsEnrolledAndEnabled, fpLockedOut, faceLockedOut) ->
+            .map { (flags, _, biometricsEnrolledAndEnabled, fpLockedOut, faceLockedOut, _) ->
                 val isTrustUsuallyManaged = trustRepository.isCurrentUserTrustUsuallyManaged.value
                 val trustOrBiometricsAvailable =
                     (isTrustUsuallyManaged || biometricsEnrolledAndEnabled)
@@ -193,14 +198,14 @@
                     } else {
                         BouncerMessageStrings.faceLockedOut(
                                 currentSecurityMode.toAuthModel(),
-                                isFingerprintAuthCurrentlyAllowed.value
+                                isFingerprintAuthCurrentlyAllowedOnBouncer.value
                             )
                             .toMessage()
                     }
                 } else if (flags.isSomeAuthRequiredAfterAdaptiveAuthRequest) {
                     BouncerMessageStrings.authRequiredAfterAdaptiveAuthRequest(
                             currentSecurityMode.toAuthModel(),
-                            isFingerprintAuthCurrentlyAllowed.value
+                            isFingerprintAuthCurrentlyAllowedOnBouncer.value
                         )
                         .toMessage()
                 } else if (
@@ -209,19 +214,19 @@
                 ) {
                     BouncerMessageStrings.nonStrongAuthTimeout(
                             currentSecurityMode.toAuthModel(),
-                            isFingerprintAuthCurrentlyAllowed.value
+                            isFingerprintAuthCurrentlyAllowedOnBouncer.value
                         )
                         .toMessage()
                 } else if (isTrustUsuallyManaged && flags.someAuthRequiredAfterUserRequest) {
                     BouncerMessageStrings.trustAgentDisabled(
                             currentSecurityMode.toAuthModel(),
-                            isFingerprintAuthCurrentlyAllowed.value
+                            isFingerprintAuthCurrentlyAllowedOnBouncer.value
                         )
                         .toMessage()
                 } else if (isTrustUsuallyManaged && flags.someAuthRequiredAfterTrustAgentExpired) {
                     BouncerMessageStrings.trustAgentDisabled(
                             currentSecurityMode.toAuthModel(),
-                            isFingerprintAuthCurrentlyAllowed.value
+                            isFingerprintAuthCurrentlyAllowedOnBouncer.value
                         )
                         .toMessage()
                 } else if (trustOrBiometricsAvailable && flags.isInUserLockdown) {
@@ -265,7 +270,7 @@
         repository.setMessage(
             BouncerMessageStrings.incorrectSecurityInput(
                     currentSecurityMode.toAuthModel(),
-                    isFingerprintAuthCurrentlyAllowed.value
+                    isFingerprintAuthCurrentlyAllowedOnBouncer.value
                 )
                 .toMessage()
         )
@@ -274,14 +279,22 @@
     fun setFingerprintAcquisitionMessage(value: String?) {
         if (!Flags.revampedBouncerMessages()) return
         repository.setMessage(
-            defaultMessage(currentSecurityMode, value, isFingerprintAuthCurrentlyAllowed.value)
+            defaultMessage(
+                currentSecurityMode,
+                value,
+                isFingerprintAuthCurrentlyAllowedOnBouncer.value
+            )
         )
     }
 
     fun setFaceAcquisitionMessage(value: String?) {
         if (!Flags.revampedBouncerMessages()) return
         repository.setMessage(
-            defaultMessage(currentSecurityMode, value, isFingerprintAuthCurrentlyAllowed.value)
+            defaultMessage(
+                currentSecurityMode,
+                value,
+                isFingerprintAuthCurrentlyAllowedOnBouncer.value
+            )
         )
     }
 
@@ -289,7 +302,11 @@
         if (!Flags.revampedBouncerMessages()) return
 
         repository.setMessage(
-            defaultMessage(currentSecurityMode, value, isFingerprintAuthCurrentlyAllowed.value)
+            defaultMessage(
+                currentSecurityMode,
+                value,
+                isFingerprintAuthCurrentlyAllowedOnBouncer.value
+            )
         )
     }
 
@@ -297,7 +314,7 @@
         get() =
             BouncerMessageStrings.defaultMessage(
                     currentSecurityMode.toAuthModel(),
-                    isFingerprintAuthCurrentlyAllowed.value
+                    isFingerprintAuthCurrentlyAllowedOnBouncer.value
                 )
                 .toMessage()
 
@@ -355,11 +372,6 @@
 private fun Flow<Boolean>.or(anotherFlow: Flow<Boolean>) =
     this.combine(anotherFlow) { a, b -> a || b }
 
-private fun Flow<Boolean>.and(anotherFlow: Flow<Boolean>) =
-    this.combine(anotherFlow) { a, b -> a && b }
-
-private fun Flow<Boolean>.isFalse() = this.map { !it }
-
 private fun defaultMessage(
     securityMode: SecurityMode,
     secondaryMessage: String?,
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt
index f3903de..aebc50f 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt
@@ -18,6 +18,7 @@
 
 import android.app.AlertDialog
 import android.content.Context
+import com.android.systemui.bouncer.ui.viewmodel.BouncerMessageViewModelModule
 import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModelModule
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
@@ -30,6 +31,7 @@
     includes =
         [
             BouncerViewModelModule::class,
+            BouncerMessageViewModelModule::class,
         ],
 )
 interface BouncerViewModule {
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt
index 0d7f6dc..4fbf735 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt
@@ -57,17 +57,11 @@
      */
     @get:StringRes abstract val lockoutMessageId: Int
 
-    /** Notifies that the UI has been shown to the user. */
-    fun onShown() {
-        interactor.resetMessage()
-    }
-
     /**
      * Notifies that the UI has been hidden from the user (after any transitions have completed).
      */
     open fun onHidden() {
         clearInput()
-        interactor.resetMessage()
     }
 
     /** Notifies that the user has placed down a pointer. */
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModel.kt
new file mode 100644
index 0000000..6cb9b16
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModel.kt
@@ -0,0 +1,436 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bouncer.ui.viewmodel
+
+import android.content.Context
+import android.util.PluralsMessageFormatter
+import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
+import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
+import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
+import com.android.systemui.bouncer.domain.interactor.SimBouncerInteractor
+import com.android.systemui.bouncer.shared.flag.ComposeBouncerFlags
+import com.android.systemui.bouncer.shared.model.BouncerMessagePair
+import com.android.systemui.bouncer.shared.model.BouncerMessageStrings
+import com.android.systemui.bouncer.shared.model.primaryMessage
+import com.android.systemui.bouncer.shared.model.secondaryMessage
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.deviceentry.domain.interactor.BiometricMessageInteractor
+import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFaceAuthInteractor
+import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFingerprintAuthInteractor
+import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
+import com.android.systemui.deviceentry.shared.model.DeviceEntryRestrictionReason
+import com.android.systemui.deviceentry.shared.model.FaceFailureMessage
+import com.android.systemui.deviceentry.shared.model.FaceLockoutMessage
+import com.android.systemui.deviceentry.shared.model.FaceTimeoutMessage
+import com.android.systemui.deviceentry.shared.model.FingerprintFailureMessage
+import com.android.systemui.deviceentry.shared.model.FingerprintLockoutMessage
+import com.android.systemui.res.R.string.kg_too_many_failed_attempts_countdown
+import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
+import com.android.systemui.user.ui.viewmodel.UserViewModel
+import com.android.systemui.util.kotlin.Utils.Companion.sample
+import com.android.systemui.util.time.SystemClock
+import dagger.Module
+import dagger.Provides
+import kotlin.math.ceil
+import kotlin.math.max
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+/** Holds UI state for the 2-line status message shown on the bouncer. */
+@OptIn(ExperimentalCoroutinesApi::class)
+class BouncerMessageViewModel(
+    @Application private val applicationContext: Context,
+    @Application private val applicationScope: CoroutineScope,
+    private val bouncerInteractor: BouncerInteractor,
+    private val simBouncerInteractor: SimBouncerInteractor,
+    private val authenticationInteractor: AuthenticationInteractor,
+    selectedUser: Flow<UserViewModel>,
+    private val clock: SystemClock,
+    private val biometricMessageInteractor: BiometricMessageInteractor,
+    private val faceAuthInteractor: DeviceEntryFaceAuthInteractor,
+    private val deviceEntryInteractor: DeviceEntryInteractor,
+    private val fingerprintInteractor: DeviceEntryFingerprintAuthInteractor,
+    flags: ComposeBouncerFlags,
+) {
+    /**
+     * A message shown when the user has attempted the wrong credential too many times and now must
+     * wait a while before attempting to authenticate again.
+     *
+     * This is updated every second (countdown) during the lockout. When lockout is not active, this
+     * is `null` and no lockout message should be shown.
+     */
+    private val lockoutMessage: MutableStateFlow<MessageViewModel?> = MutableStateFlow(null)
+
+    /** Whether there is a lockout message that is available to be shown in the status message. */
+    val isLockoutMessagePresent: Flow<Boolean> = lockoutMessage.map { it != null }
+
+    /** The user-facing message to show in the bouncer. */
+    val message: MutableStateFlow<MessageViewModel?> = MutableStateFlow(null)
+
+    /** Initializes the bouncer message to default whenever it is shown. */
+    fun onShown() {
+        showDefaultMessage()
+    }
+
+    /** Reset the message shown on the bouncer to the default message. */
+    fun showDefaultMessage() {
+        resetToDefault.tryEmit(Unit)
+    }
+
+    private val resetToDefault = MutableSharedFlow<Unit>(replay = 1)
+
+    private var lockoutCountdownJob: Job? = null
+
+    private fun defaultBouncerMessageInitializer() {
+        applicationScope.launch {
+            resetToDefault.emit(Unit)
+            authenticationInteractor.authenticationMethod
+                .flatMapLatest { authMethod ->
+                    if (authMethod == AuthenticationMethodModel.Sim) {
+                        resetToDefault.map {
+                            MessageViewModel(simBouncerInteractor.getDefaultMessage())
+                        }
+                    } else if (authMethod.isSecure) {
+                        combine(
+                            deviceEntryInteractor.deviceEntryRestrictionReason,
+                            lockoutMessage,
+                            fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer,
+                            resetToDefault,
+                        ) { deviceEntryRestrictedReason, lockoutMsg, isFpAllowedInBouncer, _ ->
+                            lockoutMsg
+                                ?: deviceEntryRestrictedReason.toMessage(
+                                    authMethod,
+                                    isFpAllowedInBouncer
+                                )
+                        }
+                    } else {
+                        emptyFlow()
+                    }
+                }
+                .collectLatest { messageViewModel -> message.value = messageViewModel }
+        }
+    }
+
+    private fun listenForSimBouncerEvents() {
+        // Listen for any events from the SIM bouncer and update the message shown on the bouncer.
+        applicationScope.launch {
+            authenticationInteractor.authenticationMethod
+                .flatMapLatest { authMethod ->
+                    if (authMethod == AuthenticationMethodModel.Sim) {
+                        simBouncerInteractor.bouncerMessageChanged.map { simMsg ->
+                            simMsg?.let { MessageViewModel(it) }
+                        }
+                    } else {
+                        emptyFlow()
+                    }
+                }
+                .collectLatest {
+                    if (it != null) {
+                        message.value = it
+                    } else {
+                        resetToDefault.emit(Unit)
+                    }
+                }
+        }
+    }
+
+    private fun listenForFaceMessages() {
+        // Listen for any events from face authentication and update the message shown on the
+        // bouncer.
+        applicationScope.launch {
+            biometricMessageInteractor.faceMessage
+                .sample(
+                    authenticationInteractor.authenticationMethod,
+                    fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer,
+                )
+                .collectLatest { (faceMessage, authMethod, fingerprintAllowedOnBouncer) ->
+                    val isFaceAuthStrong = faceAuthInteractor.isFaceAuthStrong()
+                    val defaultPrimaryMessage =
+                        BouncerMessageStrings.defaultMessage(
+                                authMethod,
+                                fingerprintAllowedOnBouncer
+                            )
+                            .primaryMessage
+                            .toResString()
+                    message.value =
+                        when (faceMessage) {
+                            is FaceTimeoutMessage ->
+                                MessageViewModel(
+                                    text = defaultPrimaryMessage,
+                                    secondaryText = faceMessage.message,
+                                    isUpdateAnimated = true
+                                )
+                            is FaceLockoutMessage ->
+                                if (isFaceAuthStrong)
+                                    BouncerMessageStrings.class3AuthLockedOut(authMethod)
+                                        .toMessage()
+                                else
+                                    BouncerMessageStrings.faceLockedOut(
+                                            authMethod,
+                                            fingerprintAllowedOnBouncer
+                                        )
+                                        .toMessage()
+                            is FaceFailureMessage ->
+                                BouncerMessageStrings.incorrectFaceInput(
+                                        authMethod,
+                                        fingerprintAllowedOnBouncer
+                                    )
+                                    .toMessage()
+                            else ->
+                                MessageViewModel(
+                                    text = defaultPrimaryMessage,
+                                    secondaryText = faceMessage.message,
+                                    isUpdateAnimated = false
+                                )
+                        }
+                    delay(MESSAGE_DURATION)
+                    resetToDefault.emit(Unit)
+                }
+        }
+    }
+
+    private fun listenForFingerprintMessages() {
+        applicationScope.launch {
+            // Listen for any events from fingerprint authentication and update the message shown
+            // on the bouncer.
+            biometricMessageInteractor.fingerprintMessage
+                .sample(
+                    authenticationInteractor.authenticationMethod,
+                    fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer
+                )
+                .collectLatest { (fingerprintMessage, authMethod, isFingerprintAllowed) ->
+                    val defaultPrimaryMessage =
+                        BouncerMessageStrings.defaultMessage(authMethod, isFingerprintAllowed)
+                            .primaryMessage
+                            .toResString()
+                    message.value =
+                        when (fingerprintMessage) {
+                            is FingerprintLockoutMessage ->
+                                BouncerMessageStrings.class3AuthLockedOut(authMethod).toMessage()
+                            is FingerprintFailureMessage ->
+                                BouncerMessageStrings.incorrectFingerprintInput(authMethod)
+                                    .toMessage()
+                            else ->
+                                MessageViewModel(
+                                    text = defaultPrimaryMessage,
+                                    secondaryText = fingerprintMessage.message,
+                                    isUpdateAnimated = false
+                                )
+                        }
+                    delay(MESSAGE_DURATION)
+                    resetToDefault.emit(Unit)
+                }
+        }
+    }
+
+    private fun listenForBouncerEvents() {
+        // Keeps the lockout message up-to-date.
+        applicationScope.launch {
+            bouncerInteractor.onLockoutStarted.collect { startLockoutCountdown() }
+        }
+
+        // Listens to relevant bouncer events
+        applicationScope.launch {
+            bouncerInteractor.onIncorrectBouncerInput
+                .sample(
+                    authenticationInteractor.authenticationMethod,
+                    fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer
+                )
+                .collectLatest { (_, authMethod, isFingerprintAllowed) ->
+                    message.emit(
+                        BouncerMessageStrings.incorrectSecurityInput(
+                                authMethod,
+                                isFingerprintAllowed
+                            )
+                            .toMessage()
+                    )
+                    delay(MESSAGE_DURATION)
+                    resetToDefault.emit(Unit)
+                }
+        }
+    }
+
+    private fun DeviceEntryRestrictionReason?.toMessage(
+        authMethod: AuthenticationMethodModel,
+        isFingerprintAllowedOnBouncer: Boolean,
+    ): MessageViewModel {
+        return when (this) {
+            DeviceEntryRestrictionReason.UserLockdown ->
+                BouncerMessageStrings.authRequiredAfterUserLockdown(authMethod)
+            DeviceEntryRestrictionReason.DeviceNotUnlockedSinceReboot ->
+                BouncerMessageStrings.authRequiredAfterReboot(authMethod)
+            DeviceEntryRestrictionReason.PolicyLockdown ->
+                BouncerMessageStrings.authRequiredAfterAdminLockdown(authMethod)
+            DeviceEntryRestrictionReason.UnattendedUpdate ->
+                BouncerMessageStrings.authRequiredForUnattendedUpdate(authMethod)
+            DeviceEntryRestrictionReason.DeviceNotUnlockedSinceMainlineUpdate ->
+                BouncerMessageStrings.authRequiredForMainlineUpdate(authMethod)
+            DeviceEntryRestrictionReason.SecurityTimeout ->
+                BouncerMessageStrings.authRequiredAfterPrimaryAuthTimeout(authMethod)
+            DeviceEntryRestrictionReason.StrongBiometricsLockedOut ->
+                BouncerMessageStrings.class3AuthLockedOut(authMethod)
+            DeviceEntryRestrictionReason.NonStrongFaceLockedOut ->
+                BouncerMessageStrings.faceLockedOut(authMethod, isFingerprintAllowedOnBouncer)
+            DeviceEntryRestrictionReason.NonStrongBiometricsSecurityTimeout ->
+                BouncerMessageStrings.nonStrongAuthTimeout(
+                    authMethod,
+                    isFingerprintAllowedOnBouncer
+                )
+            DeviceEntryRestrictionReason.TrustAgentDisabled ->
+                BouncerMessageStrings.trustAgentDisabled(authMethod, isFingerprintAllowedOnBouncer)
+            DeviceEntryRestrictionReason.AdaptiveAuthRequest ->
+                BouncerMessageStrings.authRequiredAfterAdaptiveAuthRequest(
+                    authMethod,
+                    isFingerprintAllowedOnBouncer
+                )
+            else -> BouncerMessageStrings.defaultMessage(authMethod, isFingerprintAllowedOnBouncer)
+        }.toMessage()
+    }
+
+    private fun BouncerMessagePair.toMessage(): MessageViewModel {
+        val primaryMsg = this.primaryMessage.toResString()
+        val secondaryMsg =
+            if (this.secondaryMessage == 0) "" else this.secondaryMessage.toResString()
+        return MessageViewModel(primaryMsg, secondaryText = secondaryMsg, isUpdateAnimated = true)
+    }
+
+    /** Shows the countdown message and refreshes it every second. */
+    private fun startLockoutCountdown() {
+        lockoutCountdownJob?.cancel()
+        lockoutCountdownJob =
+            applicationScope.launch {
+                authenticationInteractor.authenticationMethod.collectLatest { authMethod ->
+                    do {
+                        val remainingSeconds = remainingLockoutSeconds()
+                        val authLockedOutMsg =
+                            BouncerMessageStrings.primaryAuthLockedOut(authMethod)
+                        lockoutMessage.value =
+                            if (remainingSeconds > 0) {
+                                MessageViewModel(
+                                    text =
+                                        kg_too_many_failed_attempts_countdown.toPluralString(
+                                            mutableMapOf<String, Any>(
+                                                Pair("count", remainingSeconds)
+                                            )
+                                        ),
+                                    secondaryText = authLockedOutMsg.secondaryMessage.toResString(),
+                                    isUpdateAnimated = false
+                                )
+                            } else {
+                                null
+                            }
+                        delay(1.seconds)
+                    } while (remainingSeconds > 0)
+                    lockoutCountdownJob = null
+                }
+            }
+    }
+
+    private fun remainingLockoutSeconds(): Int {
+        val endTimestampMs = authenticationInteractor.lockoutEndTimestamp ?: 0
+        val remainingMs = max(0, endTimestampMs - clock.elapsedRealtime())
+        return ceil(remainingMs / 1000f).toInt()
+    }
+
+    private fun Int.toPluralString(formatterArgs: Map<String, Any>): String =
+        PluralsMessageFormatter.format(applicationContext.resources, formatterArgs, this)
+
+    private fun Int.toResString(): String = applicationContext.getString(this)
+
+    init {
+        if (flags.isComposeBouncerOrSceneContainerEnabled()) {
+            applicationScope.launch {
+                // Update the lockout countdown whenever the selected user is switched.
+                selectedUser.collect { startLockoutCountdown() }
+            }
+
+            defaultBouncerMessageInitializer()
+
+            listenForSimBouncerEvents()
+            listenForBouncerEvents()
+            listenForFaceMessages()
+            listenForFingerprintMessages()
+        }
+    }
+
+    companion object {
+        private const val MESSAGE_DURATION = 2000L
+    }
+}
+
+/** Data class that represents the status message show on the bouncer. */
+data class MessageViewModel(
+    val text: String,
+    val secondaryText: String? = null,
+    /**
+     * Whether updates to the message should be cross-animated from one message to another.
+     *
+     * If `false`, no animation should be applied, the message text should just be replaced
+     * instantly.
+     */
+    val isUpdateAnimated: Boolean = true,
+)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@Module
+object BouncerMessageViewModelModule {
+
+    @Provides
+    @SysUISingleton
+    fun viewModel(
+        @Application applicationContext: Context,
+        @Application applicationScope: CoroutineScope,
+        bouncerInteractor: BouncerInteractor,
+        simBouncerInteractor: SimBouncerInteractor,
+        authenticationInteractor: AuthenticationInteractor,
+        clock: SystemClock,
+        biometricMessageInteractor: BiometricMessageInteractor,
+        faceAuthInteractor: DeviceEntryFaceAuthInteractor,
+        deviceEntryInteractor: DeviceEntryInteractor,
+        fingerprintInteractor: DeviceEntryFingerprintAuthInteractor,
+        flags: ComposeBouncerFlags,
+        userSwitcherViewModel: UserSwitcherViewModel,
+    ): BouncerMessageViewModel {
+        return BouncerMessageViewModel(
+            applicationContext = applicationContext,
+            applicationScope = applicationScope,
+            bouncerInteractor = bouncerInteractor,
+            simBouncerInteractor = simBouncerInteractor,
+            authenticationInteractor = authenticationInteractor,
+            clock = clock,
+            biometricMessageInteractor = biometricMessageInteractor,
+            faceAuthInteractor = faceAuthInteractor,
+            deviceEntryInteractor = deviceEntryInteractor,
+            fingerprintInteractor = fingerprintInteractor,
+            flags = flags,
+            selectedUser = userSwitcherViewModel.selectedUser,
+        )
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt
index 6287578..5c07cc5 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt
@@ -21,7 +21,6 @@
 import android.content.Context
 import android.graphics.Bitmap
 import androidx.core.graphics.drawable.toBitmap
-import com.android.internal.R
 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
 import com.android.systemui.authentication.shared.model.AuthenticationWipeModel
@@ -40,18 +39,12 @@
 import com.android.systemui.user.ui.viewmodel.UserActionViewModel
 import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
 import com.android.systemui.user.ui.viewmodel.UserViewModel
-import com.android.systemui.util.time.SystemClock
 import dagger.Module
 import dagger.Provides
-import kotlin.math.ceil
-import kotlin.math.max
-import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -72,13 +65,13 @@
     private val simBouncerInteractor: SimBouncerInteractor,
     private val authenticationInteractor: AuthenticationInteractor,
     private val selectedUserInteractor: SelectedUserInteractor,
+    private val devicePolicyManager: DevicePolicyManager,
+    bouncerMessageViewModel: BouncerMessageViewModel,
     flags: ComposeBouncerFlags,
     selectedUser: Flow<UserViewModel>,
     users: Flow<List<UserViewModel>>,
     userSwitcherMenu: Flow<List<UserActionViewModel>>,
     actionButton: Flow<BouncerActionButtonModel?>,
-    private val clock: SystemClock,
-    private val devicePolicyManager: DevicePolicyManager,
 ) {
     val selectedUserImage: StateFlow<Bitmap?> =
         selectedUser
@@ -89,6 +82,8 @@
                 initialValue = null,
             )
 
+    val message: BouncerMessageViewModel = bouncerMessageViewModel
+
     val userSwitcherDropdown: StateFlow<List<UserSwitcherDropdownItemViewModel>> =
         combine(
                 users,
@@ -163,24 +158,6 @@
             )
 
     /**
-     * A message shown when the user has attempted the wrong credential too many times and now must
-     * wait a while before attempting to authenticate again.
-     *
-     * This is updated every second (countdown) during the lockout duration. When lockout is not
-     * active, this is `null` and no lockout message should be shown.
-     */
-    private val lockoutMessage = MutableStateFlow<String?>(null)
-
-    /** The user-facing message to show in the bouncer. */
-    val message: StateFlow<MessageViewModel> =
-        combine(bouncerInteractor.message, lockoutMessage) { _, _ -> createMessageViewModel() }
-            .stateIn(
-                scope = applicationScope,
-                started = SharingStarted.WhileSubscribed(),
-                initialValue = createMessageViewModel(),
-            )
-
-    /**
      * The bouncer action button (Return to Call / Emergency Call). If `null`, the button should not
      * be shown.
      */
@@ -222,31 +199,16 @@
             )
 
     private val isInputEnabled: StateFlow<Boolean> =
-        lockoutMessage
-            .map { it == null }
+        bouncerMessageViewModel.isLockoutMessagePresent
+            .map { lockoutMessagePresent -> !lockoutMessagePresent }
             .stateIn(
                 scope = applicationScope,
                 started = SharingStarted.WhileSubscribed(),
                 initialValue = authenticationInteractor.lockoutEndTimestamp == null,
             )
 
-    private var lockoutCountdownJob: Job? = null
-
     init {
         if (flags.isComposeBouncerOrSceneContainerEnabled()) {
-            // Keeps the lockout dialog up-to-date.
-            applicationScope.launch {
-                bouncerInteractor.onLockoutStarted.collect {
-                    showLockoutDialog()
-                    startLockoutCountdown()
-                }
-            }
-
-            applicationScope.launch {
-                // Update the lockout countdown whenever the selected user is switched.
-                selectedUser.collect { startLockoutCountdown() }
-            }
-
             // Keeps the upcoming wipe dialog up-to-date.
             applicationScope.launch {
                 authenticationInteractor.upcomingWipe.collect { wipeModel ->
@@ -256,48 +218,6 @@
         }
     }
 
-    private fun showLockoutDialog() {
-        applicationScope.launch {
-            val failedAttempts = authenticationInteractor.failedAuthenticationAttempts.value
-            lockoutDialogMessage.value =
-                authMethodViewModel.value?.lockoutMessageId?.let { messageId ->
-                    applicationContext.getString(
-                        messageId,
-                        failedAttempts,
-                        remainingLockoutSeconds()
-                    )
-                }
-        }
-    }
-
-    /** Shows the countdown message and refreshes it every second. */
-    private fun startLockoutCountdown() {
-        lockoutCountdownJob?.cancel()
-        lockoutCountdownJob =
-            applicationScope.launch {
-                do {
-                    val remainingSeconds = remainingLockoutSeconds()
-                    lockoutMessage.value =
-                        if (remainingSeconds > 0) {
-                            applicationContext.getString(
-                                R.string.lockscreen_too_many_failed_attempts_countdown,
-                                remainingSeconds,
-                            )
-                        } else {
-                            null
-                        }
-                    delay(1.seconds)
-                } while (remainingSeconds > 0)
-                lockoutCountdownJob = null
-            }
-    }
-
-    private fun remainingLockoutSeconds(): Int {
-        val endTimestampMs = authenticationInteractor.lockoutEndTimestamp ?: 0
-        val remainingMs = max(0, endTimestampMs - clock.elapsedRealtime())
-        return ceil(remainingMs / 1000f).toInt()
-    }
-
     private fun isSideBySideSupported(authMethod: AuthMethodBouncerViewModel?): Boolean {
         return isUserSwitcherVisible || authMethod !is PasswordBouncerViewModel
     }
@@ -306,15 +226,6 @@
         return authMethod !is PasswordBouncerViewModel
     }
 
-    private fun createMessageViewModel(): MessageViewModel {
-        val isLockedOut = lockoutMessage.value != null
-        return MessageViewModel(
-            // A lockout message takes precedence over the non-lockout message.
-            text = lockoutMessage.value ?: bouncerInteractor.message.value ?: "",
-            isUpdateAnimated = !isLockedOut,
-        )
-    }
-
     private fun getChildViewModel(
         authenticationMethod: AuthenticationMethodModel,
     ): AuthMethodBouncerViewModel? {
@@ -336,7 +247,8 @@
                     interactor = bouncerInteractor,
                     isInputEnabled = isInputEnabled,
                     simBouncerInteractor = simBouncerInteractor,
-                    authenticationMethod = authenticationMethod
+                    authenticationMethod = authenticationMethod,
+                    onIntentionalUserInput = ::onIntentionalUserInput
                 )
             is AuthenticationMethodModel.Sim ->
                 PinBouncerViewModel(
@@ -346,6 +258,7 @@
                     isInputEnabled = isInputEnabled,
                     simBouncerInteractor = simBouncerInteractor,
                     authenticationMethod = authenticationMethod,
+                    onIntentionalUserInput = ::onIntentionalUserInput
                 )
             is AuthenticationMethodModel.Password ->
                 PasswordBouncerViewModel(
@@ -354,6 +267,7 @@
                     interactor = bouncerInteractor,
                     inputMethodInteractor = inputMethodInteractor,
                     selectedUserInteractor = selectedUserInteractor,
+                    onIntentionalUserInput = ::onIntentionalUserInput
                 )
             is AuthenticationMethodModel.Pattern ->
                 PatternBouncerViewModel(
@@ -361,11 +275,17 @@
                     viewModelScope = newViewModelScope,
                     interactor = bouncerInteractor,
                     isInputEnabled = isInputEnabled,
+                    onIntentionalUserInput = ::onIntentionalUserInput
                 )
             else -> null
         }
     }
 
+    private fun onIntentionalUserInput() {
+        message.showDefaultMessage()
+        bouncerInteractor.onIntentionalUserInput()
+    }
+
     private fun createChildCoroutineScope(parentScope: CoroutineScope): CoroutineScope {
         return CoroutineScope(
             SupervisorJob(parent = parentScope.coroutineContext.job) + mainDispatcher
@@ -437,18 +357,6 @@
         }
     }
 
-    data class MessageViewModel(
-        val text: String,
-
-        /**
-         * Whether updates to the message should be cross-animated from one message to another.
-         *
-         * If `false`, no animation should be applied, the message text should just be replaced
-         * instantly.
-         */
-        val isUpdateAnimated: Boolean,
-    )
-
     data class DialogViewModel(
         val text: String,
 
@@ -480,8 +388,8 @@
         selectedUserInteractor: SelectedUserInteractor,
         flags: ComposeBouncerFlags,
         userSwitcherViewModel: UserSwitcherViewModel,
-        clock: SystemClock,
         devicePolicyManager: DevicePolicyManager,
+        bouncerMessageViewModel: BouncerMessageViewModel,
     ): BouncerViewModel {
         return BouncerViewModel(
             applicationContext = applicationContext,
@@ -497,8 +405,8 @@
             users = userSwitcherViewModel.users,
             userSwitcherMenu = userSwitcherViewModel.menu,
             actionButton = actionButtonInteractor.actionButton,
-            clock = clock,
             devicePolicyManager = devicePolicyManager,
+            bouncerMessageViewModel = bouncerMessageViewModel,
         )
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt
index b42eda1..052fb6b 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt
@@ -40,6 +40,7 @@
     viewModelScope: CoroutineScope,
     isInputEnabled: StateFlow<Boolean>,
     interactor: BouncerInteractor,
+    private val onIntentionalUserInput: () -> Unit,
     private val inputMethodInteractor: InputMethodInteractor,
     private val selectedUserInteractor: SelectedUserInteractor,
 ) :
@@ -96,12 +97,8 @@
 
     /** Notifies that the user has changed the password input. */
     fun onPasswordInputChanged(newPassword: String) {
-        if (this.password.value.isEmpty() && newPassword.isNotEmpty()) {
-            interactor.clearMessage()
-        }
-
         if (newPassword.isNotEmpty()) {
-            interactor.onIntentionalUserInput()
+            onIntentionalUserInput()
         }
 
         _password.value = newPassword
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt
index 69f8032..a401600 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt
@@ -40,6 +40,7 @@
     viewModelScope: CoroutineScope,
     interactor: BouncerInteractor,
     isInputEnabled: StateFlow<Boolean>,
+    private val onIntentionalUserInput: () -> Unit,
 ) :
     AuthMethodBouncerViewModel(
         viewModelScope = viewModelScope,
@@ -84,7 +85,7 @@
 
     /** Notifies that the user has started a drag gesture across the dot grid. */
     fun onDragStart() {
-        interactor.clearMessage()
+        onIntentionalUserInput()
     }
 
     /**
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt
index e910a92..62da5c0 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt
@@ -41,6 +41,7 @@
     viewModelScope: CoroutineScope,
     interactor: BouncerInteractor,
     isInputEnabled: StateFlow<Boolean>,
+    private val onIntentionalUserInput: () -> Unit,
     private val simBouncerInteractor: SimBouncerInteractor,
     authenticationMethod: AuthenticationMethodModel,
 ) :
@@ -131,11 +132,8 @@
     /** Notifies that the user clicked on a PIN button with the given digit value. */
     fun onPinButtonClicked(input: Int) {
         val pinInput = mutablePinInput.value
-        if (pinInput.isEmpty()) {
-            interactor.clearMessage()
-        }
 
-        interactor.onIntentionalUserInput()
+        onIntentionalUserInput()
 
         mutablePinInput.value = pinInput.append(input)
         tryAuthenticate(useAutoConfirm = true)
@@ -149,7 +147,6 @@
     /** Notifies that the user long-pressed the backspace button. */
     fun onBackspaceButtonLongPressed() {
         clearInput()
-        interactor.clearMessage()
     }
 
     /** Notifies that the user clicked the "enter" button. */
@@ -173,7 +170,6 @@
     /** Resets the sim screen and shows a default message. */
     private fun onResetSimFlow() {
         simBouncerInteractor.resetSimPukUserInput()
-        interactor.resetMessage()
         clearInput()
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/common/shared/model/NotificationContainerBounds.kt b/packages/SystemUI/src/com/android/systemui/common/shared/model/NotificationContainerBounds.kt
index 3063ebd..fdd98bec 100644
--- a/packages/SystemUI/src/com/android/systemui/common/shared/model/NotificationContainerBounds.kt
+++ b/packages/SystemUI/src/com/android/systemui/common/shared/model/NotificationContainerBounds.kt
@@ -18,12 +18,8 @@
 
 /** Models the bounds of the notification container. */
 data class NotificationContainerBounds(
-    /** The position of the left of the container in its window coordinate system, in pixels. */
-    val left: Float = 0f,
     /** The position of the top of the container in its window coordinate system, in pixels. */
     val top: Float = 0f,
-    /** The position of the right of the container in its window coordinate system, in pixels. */
-    val right: Float = 0f,
     /** The position of the bottom of the container in its window coordinate system, in pixels. */
     val bottom: Float = 0f,
     /** Whether any modifications to top/bottom should be smoothly animated. */
diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/ConfigurationState.kt b/packages/SystemUI/src/com/android/systemui/common/ui/ConfigurationState.kt
index 964eb6f..578389b 100644
--- a/packages/SystemUI/src/com/android/systemui/common/ui/ConfigurationState.kt
+++ b/packages/SystemUI/src/com/android/systemui/common/ui/ConfigurationState.kt
@@ -54,6 +54,18 @@
     }
 
     /**
+     * Returns a [Flow] that emits a dimension pixel size that is kept in sync with the device
+     * configuration.
+     *
+     * @see android.content.res.Resources.getDimensionPixelSize
+     */
+    fun getDimensionPixelOffset(@DimenRes id: Int): Flow<Int> {
+        return configurationController.onDensityOrFontScaleChanged.emitOnStart().map {
+            context.resources.getDimensionPixelOffset(id)
+        }
+    }
+
+    /**
      * Returns a [Flow] that emits a color that is kept in sync with the device theme.
      *
      * @see Utils.getColorAttrDefaultColor
diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalEditModeViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalEditModeViewModel.kt
index bfe751a..afa7c37 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalEditModeViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalEditModeViewModel.kt
@@ -16,24 +16,36 @@
 
 package com.android.systemui.communal.ui.viewmodel
 
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProviderInfo
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import android.util.Log
+import androidx.activity.result.ActivityResultLauncher
 import com.android.internal.logging.UiEventLogger
 import com.android.systemui.communal.domain.interactor.CommunalInteractor
 import com.android.systemui.communal.domain.interactor.CommunalSettingsInteractor
 import com.android.systemui.communal.domain.model.CommunalContentModel
 import com.android.systemui.communal.shared.log.CommunalUiEvent
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.log.LogBuffer
 import com.android.systemui.log.core.Logger
 import com.android.systemui.log.dagger.CommunalLog
 import com.android.systemui.media.controls.ui.view.MediaHost
 import com.android.systemui.media.dagger.MediaModule
+import com.android.systemui.res.R
 import javax.inject.Inject
 import javax.inject.Named
+import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
 
 /** The view model for communal hub in edit mode. */
 @SysUISingleton
@@ -45,6 +57,7 @@
     @Named(MediaModule.COMMUNAL_HUB) mediaHost: MediaHost,
     private val uiEventLogger: UiEventLogger,
     @CommunalLog logBuffer: LogBuffer,
+    @Background private val backgroundDispatcher: CoroutineDispatcher,
 ) : BaseCommunalViewModel(communalInteractor, mediaHost) {
 
     private val logger = Logger(logBuffer, "CommunalEditModeViewModel")
@@ -86,10 +99,77 @@
         uiEventLogger.log(CommunalUiEvent.COMMUNAL_HUB_REORDER_WIDGET_CANCEL)
     }
 
-    /** Returns the widget categories to show on communal hub. */
-    val getCommunalWidgetCategories: Int
-        get() = communalSettingsInteractor.communalWidgetCategories.value
+    /** Launch the widget picker activity using the given {@link ActivityResultLauncher}. */
+    suspend fun onOpenWidgetPicker(
+        resources: Resources,
+        packageManager: PackageManager,
+        activityLauncher: ActivityResultLauncher<Intent>
+    ): Boolean =
+        withContext(backgroundDispatcher) {
+            val widgets = communalInteractor.widgetContent.first()
+            val excludeList = widgets.mapTo(ArrayList()) { it.providerInfo }
+            getWidgetPickerActivityIntent(resources, packageManager, excludeList)?.let {
+                try {
+                    activityLauncher.launch(it)
+                    return@withContext true
+                } catch (e: Exception) {
+                    Log.e(TAG, "Failed to launch widget picker activity", e)
+                }
+            }
+            false
+        }
+
+    private fun getWidgetPickerActivityIntent(
+        resources: Resources,
+        packageManager: PackageManager,
+        excludeList: ArrayList<AppWidgetProviderInfo>
+    ): Intent? {
+        val packageName =
+            getLauncherPackageName(packageManager)
+                ?: run {
+                    Log.e(TAG, "Couldn't resolve launcher package name")
+                    return@getWidgetPickerActivityIntent null
+                }
+
+        return Intent(Intent.ACTION_PICK).apply {
+            setPackage(packageName)
+            putExtra(
+                EXTRA_DESIRED_WIDGET_WIDTH,
+                resources.getDimensionPixelSize(R.dimen.communal_widget_picker_desired_width)
+            )
+            putExtra(
+                EXTRA_DESIRED_WIDGET_HEIGHT,
+                resources.getDimensionPixelSize(R.dimen.communal_widget_picker_desired_height)
+            )
+            putExtra(
+                AppWidgetManager.EXTRA_CATEGORY_FILTER,
+                communalSettingsInteractor.communalWidgetCategories.value
+            )
+            putExtra(EXTRA_UI_SURFACE_KEY, EXTRA_UI_SURFACE_VALUE)
+            putParcelableArrayListExtra(EXTRA_ADDED_APP_WIDGETS_KEY, excludeList)
+        }
+    }
+
+    private fun getLauncherPackageName(packageManager: PackageManager): String? {
+        return packageManager
+            .resolveActivity(
+                Intent(Intent.ACTION_MAIN).also { it.addCategory(Intent.CATEGORY_HOME) },
+                PackageManager.MATCH_DEFAULT_ONLY
+            )
+            ?.activityInfo
+            ?.packageName
+    }
 
     /** Sets whether edit mode is currently open */
     fun setEditModeOpen(isOpen: Boolean) = communalInteractor.setEditModeOpen(isOpen)
+
+    companion object {
+        private const val TAG = "CommunalEditModeViewModel"
+
+        private const val EXTRA_DESIRED_WIDGET_WIDTH = "desired_widget_width"
+        private const val EXTRA_DESIRED_WIDGET_HEIGHT = "desired_widget_height"
+        private const val EXTRA_UI_SURFACE_KEY = "ui_surface"
+        private const val EXTRA_UI_SURFACE_VALUE = "widgets_hub"
+        const val EXTRA_ADDED_APP_WIDGETS_KEY = "added_app_widgets"
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt b/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt
index b6ad26b..ba18f01 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt
@@ -16,9 +16,7 @@
 
 package com.android.systemui.communal.widgets
 
-import android.appwidget.AppWidgetManager
 import android.content.Intent
-import android.content.pm.PackageManager
 import android.os.Bundle
 import android.os.RemoteException
 import android.util.Log
@@ -32,6 +30,8 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.android.app.tracing.coroutines.launch
 import com.android.compose.theme.LocalAndroidColorScheme
 import com.android.compose.theme.PlatformTheme
 import com.android.internal.logging.UiEventLogger
@@ -43,8 +43,8 @@
 import com.android.systemui.log.LogBuffer
 import com.android.systemui.log.core.Logger
 import com.android.systemui.log.dagger.CommunalLog
-import com.android.systemui.res.R
 import javax.inject.Inject
+import kotlinx.coroutines.launch
 
 /** An Activity for editing the widgets that appear in hub mode. */
 class EditWidgetsActivity
@@ -57,11 +57,8 @@
     @CommunalLog logBuffer: LogBuffer,
 ) : ComponentActivity() {
     companion object {
-        private const val EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag"
-        private const val EXTRA_DESIRED_WIDGET_WIDTH = "desired_widget_width"
-        private const val EXTRA_DESIRED_WIDGET_HEIGHT = "desired_widget_height"
-
         private const val TAG = "EditWidgetsActivity"
+        private const val EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag"
         const val EXTRA_PRESELECTED_KEY = "preselected_key"
     }
 
@@ -136,39 +133,13 @@
     }
 
     private fun onOpenWidgetPicker() {
-        val intent = Intent(Intent.ACTION_MAIN).also { it.addCategory(Intent.CATEGORY_HOME) }
-        packageManager
-            .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
-            ?.activityInfo
-            ?.packageName
-            ?.let { packageName ->
-                try {
-                    addWidgetActivityLauncher.launch(
-                        Intent(Intent.ACTION_PICK).apply {
-                            setPackage(packageName)
-                            putExtra(
-                                EXTRA_DESIRED_WIDGET_WIDTH,
-                                resources.getDimensionPixelSize(
-                                    R.dimen.communal_widget_picker_desired_width
-                                )
-                            )
-                            putExtra(
-                                EXTRA_DESIRED_WIDGET_HEIGHT,
-                                resources.getDimensionPixelSize(
-                                    R.dimen.communal_widget_picker_desired_height
-                                )
-                            )
-                            putExtra(
-                                AppWidgetManager.EXTRA_CATEGORY_FILTER,
-                                communalViewModel.getCommunalWidgetCategories
-                            )
-                        }
-                    )
-                } catch (e: Exception) {
-                    Log.e(TAG, "Failed to launch widget picker activity", e)
-                }
-            }
-            ?: run { Log.e(TAG, "Couldn't resolve launcher package name") }
+        lifecycleScope.launch {
+            communalViewModel.onOpenWidgetPicker(
+                resources,
+                packageManager,
+                addWidgetActivityLauncher
+            )
+        }
     }
 
     private fun onEditDone() {
diff --git a/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractor.kt b/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractor.kt
index 8059993..c4e0ef7 100644
--- a/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFingerprintAuthInteractor.kt
@@ -29,6 +29,8 @@
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 
 @OptIn(ExperimentalCoroutinesApi::class)
@@ -72,4 +74,14 @@
      */
     val isSensorUnderDisplay =
         fingerprintPropertyRepository.sensorType.map(FingerprintSensorType::isUdfps)
+
+    /** Whether fingerprint authentication is currently allowed while on the bouncer. */
+    val isFingerprintCurrentlyAllowedOnBouncer =
+        isSensorUnderDisplay.flatMapLatest { sensorBelowDisplay ->
+            if (sensorBelowDisplay) {
+                flowOf(false)
+            } else {
+                isFingerprintAuthCurrentlyAllowed
+            }
+        }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromDreamingTransitionInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromDreamingTransitionInteractor.kt
index 9a6088d..7f752b4 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromDreamingTransitionInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromDreamingTransitionInteractor.kt
@@ -231,5 +231,6 @@
         private val DEFAULT_DURATION = 500.milliseconds
         val TO_GLANCEABLE_HUB_DURATION = 1.seconds
         val TO_LOCKSCREEN_DURATION = 1167.milliseconds
+        val TO_GONE_DURATION = DEFAULT_DURATION
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/preview/KeyguardPreviewRenderer.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/preview/KeyguardPreviewRenderer.kt
index 7c76e6a..f60da0e 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/preview/KeyguardPreviewRenderer.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/preview/KeyguardPreviewRenderer.kt
@@ -90,6 +90,7 @@
 import com.android.systemui.statusbar.phone.KeyguardBottomAreaView
 import com.android.systemui.statusbar.phone.ScreenOffAnimationController
 import com.android.systemui.temporarydisplay.chipbar.ChipbarCoordinator
+import com.android.systemui.util.kotlin.DisposableHandles
 import com.android.systemui.util.settings.SecureSettings
 import dagger.assisted.Assisted
 import dagger.assisted.AssistedInject
@@ -173,7 +174,7 @@
     private lateinit var smallClockHostView: FrameLayout
     private var smartSpaceView: View? = null
 
-    private val disposables = mutableSetOf<DisposableHandle>()
+    private val disposables = DisposableHandles()
     private var isDestroyed = false
 
     private val shortcutsBindings = mutableSetOf<KeyguardQuickAffordanceViewBinder.Binding>()
@@ -183,7 +184,7 @@
 
     init {
         coroutineScope = CoroutineScope(applicationScope.coroutineContext + Job())
-        disposables.add(DisposableHandle { coroutineScope.cancel() })
+        disposables += DisposableHandle { coroutineScope.cancel() }
 
         if (keyguardBottomAreaRefactor()) {
             quickAffordancesCombinedViewModel.enablePreviewMode(
@@ -214,7 +215,7 @@
                     if (hostToken == null) null else InputTransferToken(hostToken),
                     "KeyguardPreviewRenderer"
                 )
-            disposables.add(DisposableHandle { host.release() })
+            disposables += DisposableHandle { host.release() }
         }
     }
 
@@ -284,7 +285,7 @@
     fun destroy() {
         isDestroyed = true
         lockscreenSmartspaceController.disconnect()
-        disposables.forEach { it.dispose() }
+        disposables.dispose()
         if (keyguardBottomAreaRefactor()) {
             shortcutsBindings.forEach { it.destroy() }
         }
@@ -372,7 +373,7 @@
     private fun setupKeyguardRootView(previewContext: Context, rootView: FrameLayout) {
         val keyguardRootView = KeyguardRootView(previewContext, null)
         if (!keyguardBottomAreaRefactor()) {
-            disposables.add(
+            disposables +=
                 KeyguardRootViewBinder.bind(
                     keyguardRootView,
                     keyguardRootViewModel,
@@ -387,7 +388,6 @@
                     null, // device entry haptics not required for preview mode
                     null, // falsing manager not required for preview mode
                 )
-            )
         }
         rootView.addView(
             keyguardRootView,
@@ -555,14 +555,12 @@
                     }
                 }
             clockRegistry.registerClockChangeListener(clockChangeListener)
-            disposables.add(
-                DisposableHandle {
-                    clockRegistry.unregisterClockChangeListener(clockChangeListener)
-                }
-            )
+            disposables += DisposableHandle {
+                clockRegistry.unregisterClockChangeListener(clockChangeListener)
+            }
 
             clockController.registerListeners(parentView)
-            disposables.add(DisposableHandle { clockController.unregisterListeners() })
+            disposables += DisposableHandle { clockController.unregisterListeners() }
         }
 
         val receiver =
@@ -581,7 +579,7 @@
                 addAction(Intent.ACTION_TIME_CHANGED)
             },
         )
-        disposables.add(DisposableHandle { broadcastDispatcher.unregisterReceiver(receiver) })
+        disposables += DisposableHandle { broadcastDispatcher.unregisterReceiver(receiver) }
 
         if (!migrateClocksToBlueprint()) {
             val layoutChangeListener =
@@ -602,9 +600,9 @@
                     }
                 }
             parentView.addOnLayoutChangeListener(layoutChangeListener)
-            disposables.add(
-                DisposableHandle { parentView.removeOnLayoutChangeListener(layoutChangeListener) }
-            )
+            disposables += DisposableHandle {
+                parentView.removeOnLayoutChangeListener(layoutChangeListener)
+            }
         }
 
         onClockChanged()
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/DefaultNotificationStackScrollLayoutSection.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/DefaultNotificationStackScrollLayoutSection.kt
index 6a3b920..c1b0cc6 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/DefaultNotificationStackScrollLayoutSection.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/DefaultNotificationStackScrollLayoutSection.kt
@@ -26,20 +26,16 @@
 import androidx.constraintlayout.widget.ConstraintSet.TOP
 import com.android.systemui.Flags.centralizedStatusBarHeightFix
 import com.android.systemui.Flags.migrateClocksToBlueprint
-import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.res.R
 import com.android.systemui.scene.shared.flag.SceneContainerFlags
 import com.android.systemui.shade.LargeScreenHeaderHelper
 import com.android.systemui.shade.NotificationPanelView
-import com.android.systemui.statusbar.notification.stack.AmbientState
-import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
-import com.android.systemui.statusbar.notification.stack.NotificationStackSizeCalculator
 import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer
-import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel
+import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackViewBinder
+import com.android.systemui.statusbar.notification.stack.ui.viewbinder.SharedNotificationContainerBinder
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.SharedNotificationContainerViewModel
 import dagger.Lazy
 import javax.inject.Inject
-import kotlinx.coroutines.CoroutineDispatcher
 
 /** Single column format for notifications (default for phones) */
 class DefaultNotificationStackScrollLayoutSection
@@ -50,12 +46,9 @@
     notificationPanelView: NotificationPanelView,
     sharedNotificationContainer: SharedNotificationContainer,
     sharedNotificationContainerViewModel: SharedNotificationContainerViewModel,
-    notificationStackAppearanceViewModel: NotificationStackAppearanceViewModel,
-    ambientState: AmbientState,
-    controller: NotificationStackScrollLayoutController,
-    notificationStackSizeCalculator: NotificationStackSizeCalculator,
+    sharedNotificationContainerBinder: SharedNotificationContainerBinder,
+    notificationStackViewBinder: NotificationStackViewBinder,
     private val largeScreenHeaderHelperLazy: Lazy<LargeScreenHeaderHelper>,
-    @Main mainDispatcher: CoroutineDispatcher,
 ) :
     NotificationStackScrollLayoutSection(
         context,
@@ -63,11 +56,8 @@
         notificationPanelView,
         sharedNotificationContainer,
         sharedNotificationContainerViewModel,
-        notificationStackAppearanceViewModel,
-        ambientState,
-        controller,
-        notificationStackSizeCalculator,
-        mainDispatcher,
+        sharedNotificationContainerBinder,
+        notificationStackViewBinder,
     ) {
     override fun applyConstraints(constraintSet: ConstraintSet) {
         if (!migrateClocksToBlueprint()) {
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/NotificationStackScrollLayoutSection.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/NotificationStackScrollLayoutSection.kt
index 5dea7cb..8323502 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/NotificationStackScrollLayoutSection.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/NotificationStackScrollLayoutSection.kt
@@ -31,16 +31,11 @@
 import com.android.systemui.res.R
 import com.android.systemui.scene.shared.flag.SceneContainerFlags
 import com.android.systemui.shade.NotificationPanelView
-import com.android.systemui.statusbar.notification.stack.AmbientState
-import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
-import com.android.systemui.statusbar.notification.stack.NotificationStackSizeCalculator
 import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer
-import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackAppearanceViewBinder
+import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackViewBinder
 import com.android.systemui.statusbar.notification.stack.ui.viewbinder.SharedNotificationContainerBinder
-import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.SharedNotificationContainerViewModel
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.DisposableHandle
+import com.android.systemui.util.kotlin.DisposableHandles
 
 abstract class NotificationStackScrollLayoutSection
 constructor(
@@ -49,14 +44,11 @@
     private val notificationPanelView: NotificationPanelView,
     private val sharedNotificationContainer: SharedNotificationContainer,
     private val sharedNotificationContainerViewModel: SharedNotificationContainerViewModel,
-    private val notificationStackAppearanceViewModel: NotificationStackAppearanceViewModel,
-    private val ambientState: AmbientState,
-    private val controller: NotificationStackScrollLayoutController,
-    private val notificationStackSizeCalculator: NotificationStackSizeCalculator,
-    private val mainDispatcher: CoroutineDispatcher,
+    private val sharedNotificationContainerBinder: SharedNotificationContainerBinder,
+    private val notificationStackViewBinder: NotificationStackViewBinder,
 ) : KeyguardSection() {
     private val placeHolderId = R.id.nssl_placeholder
-    private val disposableHandles: MutableList<DisposableHandle> = mutableListOf()
+    private val disposableHandles = DisposableHandles()
 
     /**
      * Align the notification placeholder bottom to the top of either the lock icon or the ambient
@@ -102,39 +94,20 @@
             return
         }
 
-        disposeHandles()
-        disposableHandles.add(
-            SharedNotificationContainerBinder.bind(
+        disposableHandles.dispose()
+        disposableHandles +=
+            sharedNotificationContainerBinder.bind(
                 sharedNotificationContainer,
                 sharedNotificationContainerViewModel,
-                sceneContainerFlags,
-                controller,
-                notificationStackSizeCalculator,
-                mainImmediateDispatcher = mainDispatcher,
             )
-        )
 
         if (sceneContainerFlags.isEnabled()) {
-            disposableHandles.add(
-                NotificationStackAppearanceViewBinder.bind(
-                    context,
-                    sharedNotificationContainer,
-                    notificationStackAppearanceViewModel,
-                    ambientState,
-                    controller,
-                    mainImmediateDispatcher = mainDispatcher,
-                )
-            )
+            disposableHandles += notificationStackViewBinder.bindWhileAttached()
         }
     }
 
     override fun removeViews(constraintLayout: ConstraintLayout) {
-        disposeHandles()
+        disposableHandles.dispose()
         constraintLayout.removeView(placeHolderId)
     }
-
-    private fun disposeHandles() {
-        disposableHandles.forEach { it.dispose() }
-        disposableHandles.clear()
-    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/SplitShadeNotificationStackScrollLayoutSection.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/SplitShadeNotificationStackScrollLayoutSection.kt
index 2545302..4a705a7 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/SplitShadeNotificationStackScrollLayoutSection.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/SplitShadeNotificationStackScrollLayoutSection.kt
@@ -24,19 +24,14 @@
 import androidx.constraintlayout.widget.ConstraintSet.START
 import androidx.constraintlayout.widget.ConstraintSet.TOP
 import com.android.systemui.Flags.migrateClocksToBlueprint
-import com.android.systemui.dagger.qualifiers.Main
-import com.android.systemui.keyguard.ui.viewmodel.KeyguardSmartspaceViewModel
 import com.android.systemui.res.R
 import com.android.systemui.scene.shared.flag.SceneContainerFlags
 import com.android.systemui.shade.NotificationPanelView
-import com.android.systemui.statusbar.notification.stack.AmbientState
-import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
-import com.android.systemui.statusbar.notification.stack.NotificationStackSizeCalculator
 import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer
-import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel
+import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackViewBinder
+import com.android.systemui.statusbar.notification.stack.ui.viewbinder.SharedNotificationContainerBinder
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.SharedNotificationContainerViewModel
 import javax.inject.Inject
-import kotlinx.coroutines.CoroutineDispatcher
 
 /** Large-screen format for notifications, shown as two columns on the device */
 class SplitShadeNotificationStackScrollLayoutSection
@@ -47,12 +42,8 @@
     notificationPanelView: NotificationPanelView,
     sharedNotificationContainer: SharedNotificationContainer,
     sharedNotificationContainerViewModel: SharedNotificationContainerViewModel,
-    notificationStackAppearanceViewModel: NotificationStackAppearanceViewModel,
-    ambientState: AmbientState,
-    controller: NotificationStackScrollLayoutController,
-    notificationStackSizeCalculator: NotificationStackSizeCalculator,
-    private val smartspaceViewModel: KeyguardSmartspaceViewModel,
-    @Main mainDispatcher: CoroutineDispatcher,
+    sharedNotificationContainerBinder: SharedNotificationContainerBinder,
+    notificationStackViewBinder: NotificationStackViewBinder,
 ) :
     NotificationStackScrollLayoutSection(
         context,
@@ -60,11 +51,8 @@
         notificationPanelView,
         sharedNotificationContainer,
         sharedNotificationContainerViewModel,
-        notificationStackAppearanceViewModel,
-        ambientState,
-        controller,
-        notificationStackSizeCalculator,
-        mainDispatcher,
+        sharedNotificationContainerBinder,
+        notificationStackViewBinder,
     ) {
     override fun applyConstraints(constraintSet: ConstraintSet) {
         if (!migrateClocksToBlueprint()) {
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToGoneTransitionViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToGoneTransitionViewModel.kt
new file mode 100644
index 0000000..ec7b931
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToGoneTransitionViewModel.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.keyguard.ui.viewmodel
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.keyguard.domain.interactor.FromDreamingTransitionInteractor
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.keyguard.ui.KeyguardTransitionAnimationFlow
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+@SysUISingleton
+class DreamingToGoneTransitionViewModel
+@Inject
+constructor(
+    animationFlow: KeyguardTransitionAnimationFlow,
+) {
+
+    private val transitionAnimation =
+            animationFlow.setup(
+                duration = FromDreamingTransitionInteractor.TO_GONE_DURATION,
+                from = KeyguardState.DREAMING,
+                to = KeyguardState.GONE,
+            )
+
+    /** Lockscreen views alpha */
+    val lockscreenAlpha: Flow<Float> = transitionAnimation.immediatelyTransitionTo(0f)
+
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModel.kt
index 55a4025..301f00e 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModel.kt
@@ -85,10 +85,12 @@
     private val dozingToLockscreenTransitionViewModel: DozingToLockscreenTransitionViewModel,
     private val dozingToOccludedTransitionViewModel: DozingToOccludedTransitionViewModel,
     private val dreamingToLockscreenTransitionViewModel: DreamingToLockscreenTransitionViewModel,
+    private val dreamingToGoneTransitionViewModel: DreamingToGoneTransitionViewModel,
     private val glanceableHubToLockscreenTransitionViewModel:
         GlanceableHubToLockscreenTransitionViewModel,
     private val goneToAodTransitionViewModel: GoneToAodTransitionViewModel,
     private val goneToDozingTransitionViewModel: GoneToDozingTransitionViewModel,
+    private val goneToDreamingTransitionViewModel: GoneToDreamingTransitionViewModel,
     private val lockscreenToAodTransitionViewModel: LockscreenToAodTransitionViewModel,
     private val lockscreenToDozingTransitionViewModel: LockscreenToDozingTransitionViewModel,
     private val lockscreenToDreamingTransitionViewModel: LockscreenToDreamingTransitionViewModel,
@@ -136,14 +138,20 @@
             }
             .distinctUntilChanged()
 
+    private val lockscreenToGoneTransitionRunning: Flow<Boolean> =
+        keyguardTransitionInteractor
+            .isInTransitionWhere { from, to -> from == LOCKSCREEN && to == GONE }
+            .onStart { emit(false) }
+
     private val alphaOnShadeExpansion: Flow<Float> =
         combineTransform(
+                lockscreenToGoneTransitionRunning,
                 isOnLockscreen,
                 shadeInteractor.qsExpansion,
                 shadeInteractor.shadeExpansion,
-            ) { isOnLockscreen, qsExpansion, shadeExpansion ->
+            ) { lockscreenToGoneTransitionRunning, isOnLockscreen, qsExpansion, shadeExpansion ->
                 // Fade out quickly as the shade expands
-                if (isOnLockscreen) {
+                if (isOnLockscreen && !lockscreenToGoneTransitionRunning) {
                     val alpha =
                         1f -
                             MathUtils.constrainedMap(
@@ -204,10 +212,12 @@
                         dozingToGoneTransitionViewModel.lockscreenAlpha(viewState),
                         dozingToLockscreenTransitionViewModel.lockscreenAlpha,
                         dozingToOccludedTransitionViewModel.lockscreenAlpha(viewState),
+                        dreamingToGoneTransitionViewModel.lockscreenAlpha,
                         dreamingToLockscreenTransitionViewModel.lockscreenAlpha,
                         glanceableHubToLockscreenTransitionViewModel.keyguardAlpha,
                         goneToAodTransitionViewModel.enterFromTopAnimationAlpha,
                         goneToDozingTransitionViewModel.lockscreenAlpha,
+                        goneToDreamingTransitionViewModel.lockscreenAlpha,
                         lockscreenToAodTransitionViewModel.lockscreenAlpha(viewState),
                         lockscreenToDozingTransitionViewModel.lockscreenAlpha,
                         lockscreenToDreamingTransitionViewModel.lockscreenAlpha,
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
index 7a7ee59..00757b7 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
@@ -127,8 +127,9 @@
 
     }
 
-    void initialize(QSLogger qsLogger) {
+    void initialize(QSLogger qsLogger, boolean usingMediaPlayer) {
         mQsLogger = qsLogger;
+        mUsingMediaPlayer = usingMediaPlayer;
         mTileLayout = getOrCreateTileLayout();
 
         if (mUsingMediaPlayer) {
@@ -163,22 +164,25 @@
     }
 
     protected void setHorizontalContentContainerClipping() {
-        mHorizontalContentContainer.setClipChildren(true);
-        mHorizontalContentContainer.setClipToPadding(false);
-        // Don't clip on the top, that way, secondary pages tiles can animate up
-        // Clipping coordinates should be relative to this view, not absolute (parent coordinates)
-        mHorizontalContentContainer.addOnLayoutChangeListener(
-                (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
-                    if ((right - left) != (oldRight - oldLeft)
-                            || ((bottom - top) != (oldBottom - oldTop))) {
-                        mClippingRect.right = right - left;
-                        mClippingRect.bottom = bottom - top;
-                        mHorizontalContentContainer.setClipBounds(mClippingRect);
-                    }
-                });
-        mClippingRect.left = 0;
-        mClippingRect.top = -1000;
-        mHorizontalContentContainer.setClipBounds(mClippingRect);
+        if (mHorizontalContentContainer != null) {
+            mHorizontalContentContainer.setClipChildren(true);
+            mHorizontalContentContainer.setClipToPadding(false);
+            // Don't clip on the top, that way, secondary pages tiles can animate up
+            // Clipping coordinates should be relative to this view, not absolute
+            // (parent coordinates)
+            mHorizontalContentContainer.addOnLayoutChangeListener(
+                    (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
+                        if ((right - left) != (oldRight - oldLeft)
+                                || ((bottom - top) != (oldBottom - oldTop))) {
+                            mClippingRect.right = right - left;
+                            mClippingRect.bottom = bottom - top;
+                            mHorizontalContentContainer.setClipBounds(mClippingRect);
+                        }
+                    });
+            mClippingRect.left = 0;
+            mClippingRect.top = -1000;
+            mHorizontalContentContainer.setClipBounds(mClippingRect);
+        }
     }
 
     /**
@@ -412,7 +416,7 @@
     }
 
     private void updateHorizontalLinearLayoutMargins() {
-        if (mHorizontalLinearLayout != null && !displayMediaMarginsOnMedia()) {
+        if (mUsingMediaPlayer && mHorizontalLinearLayout != null && !displayMediaMarginsOnMedia()) {
             LayoutParams lp = (LayoutParams) mHorizontalLinearLayout.getLayoutParams();
             lp.bottomMargin = Math.max(mMediaTotalBottomMargin - getPaddingBottom(), 0);
             mHorizontalLinearLayout.setLayoutParams(lp);
@@ -461,6 +465,11 @@
     /** Call when orientation has changed and MediaHost needs to be adjusted. */
     private void reAttachMediaHost(ViewGroup hostView, boolean horizontal) {
         if (!mUsingMediaPlayer) {
+            // If the host view was attached, detach it.
+            ViewGroup parent = (ViewGroup) hostView.getParent();
+            if (parent != null) {
+                parent.removeView(hostView);
+            }
             return;
         }
         mMediaHostView = hostView;
@@ -492,8 +501,10 @@
     public void setExpanded(boolean expanded) {
         if (mExpanded == expanded) return;
         mExpanded = expanded;
-        if (!mExpanded && mTileLayout instanceof PagedTileLayout) {
-            ((PagedTileLayout) mTileLayout).setCurrentItem(0, false);
+        if (!mExpanded && mTileLayout instanceof PagedTileLayout tilesLayout) {
+            // Use post, so it will wait until the view is attached. If the view is not attached,
+            // it will not populate corresponding views (and will not do it later when attached).
+            tilesLayout.post(() -> tilesLayout.setCurrentItem(0, false));
         }
     }
 
@@ -616,7 +627,10 @@
         if (horizontal != mUsingHorizontalLayout || force) {
             Log.d(getDumpableTag(), "setUsingHorizontalLayout: " + horizontal + ", " + force);
             mUsingHorizontalLayout = horizontal;
-            ViewGroup newParent = horizontal ? mHorizontalContentContainer : this;
+            // The tile layout should be reparented if horizontal and we are using media. If not
+            // using media, the parent should always be this.
+            ViewGroup newParent =
+                    horizontal && mUsingMediaPlayer ? mHorizontalContentContainer : this;
             switchAllContentToParent(newParent, mTileLayout);
             reAttachMediaHost(mediaHostView, horizontal);
             if (needsDynamicRowsAndColumns()) {
@@ -624,7 +638,9 @@
                 mTileLayout.setMaxColumns(horizontal ? 2 : 4);
             }
             updateMargins(mediaHostView);
-            mHorizontalLinearLayout.setVisibility(horizontal ? View.VISIBLE : View.GONE);
+            if (mHorizontalLinearLayout != null) {
+                mHorizontalLinearLayout.setVisibility(horizontal ? View.VISIBLE : View.GONE);
+            }
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java
index 5e12b9d..d8e8187 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java
@@ -167,7 +167,7 @@
 
     @Override
     protected void onInit() {
-        mView.initialize(mQSLogger);
+        mView.initialize(mQSLogger, mUsingMediaPlayer);
         mQSLogger.logAllTilesChangeListening(mView.isListening(), mView.getDumpableTag(), "");
         mHost.addCallback(mQSHostCallback);
     }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/ui/adapter/QSSceneAdapter.kt b/packages/SystemUI/src/com/android/systemui/qs/ui/adapter/QSSceneAdapter.kt
index c1b2037..6710504 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/ui/adapter/QSSceneAdapter.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/ui/adapter/QSSceneAdapter.kt
@@ -23,16 +23,21 @@
 import androidx.annotation.VisibleForTesting
 import androidx.asynclayoutinflater.view.AsyncLayoutInflater
 import com.android.settingslib.applications.InterestingConfigChanges
+import com.android.systemui.Dumpable
 import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.dump.DumpManager
 import com.android.systemui.plugins.qs.QSContainerController
 import com.android.systemui.qs.QSContainerImpl
 import com.android.systemui.qs.QSImpl
 import com.android.systemui.qs.dagger.QSSceneComponent
 import com.android.systemui.res.R
+import com.android.systemui.shade.domain.interactor.ShadeInteractor
+import com.android.systemui.shade.shared.model.ShadeMode
 import com.android.systemui.util.kotlin.sample
+import java.io.PrintWriter
 import javax.inject.Inject
 import javax.inject.Provider
 import kotlin.coroutines.resume
@@ -107,11 +112,17 @@
         }
 
         /** State for appearing QQS from Lockscreen or Gone */
-        data class Unsquishing(override val squishiness: Float) : State {
+        data class UnsquishingQQS(override val squishiness: Float) : State {
             override val isVisible = true
             override val expansion = 0f
         }
 
+        /** State for appearing QS from Lockscreen or Gone, used in Split shade */
+        data class UnsquishingQS(override val squishiness: Float) : State {
+            override val isVisible = true
+            override val expansion = 1f
+        }
+
         companion object {
             // These are special cases of the expansion.
             val QQS = Expanding(0f)
@@ -129,22 +140,28 @@
 constructor(
     private val qsSceneComponentFactory: QSSceneComponent.Factory,
     private val qsImplProvider: Provider<QSImpl>,
+    shadeInteractor: ShadeInteractor,
+    dumpManager: DumpManager,
     @Main private val mainDispatcher: CoroutineDispatcher,
     @Application applicationScope: CoroutineScope,
     private val configurationInteractor: ConfigurationInteractor,
     private val asyncLayoutInflaterFactory: (Context) -> AsyncLayoutInflater,
-) : QSContainerController, QSSceneAdapter {
+) : QSContainerController, QSSceneAdapter, Dumpable {
 
     @Inject
     constructor(
         qsSceneComponentFactory: QSSceneComponent.Factory,
         qsImplProvider: Provider<QSImpl>,
+        shadeInteractor: ShadeInteractor,
+        dumpManager: DumpManager,
         @Main dispatcher: CoroutineDispatcher,
         @Application scope: CoroutineScope,
         configurationInteractor: ConfigurationInteractor,
     ) : this(
         qsSceneComponentFactory,
         qsImplProvider,
+        shadeInteractor,
+        dumpManager,
         dispatcher,
         scope,
         configurationInteractor,
@@ -182,6 +199,7 @@
         )
 
     init {
+        dumpManager.registerDumpable(this)
         applicationScope.launch {
             launch {
                 state.sample(_isCustomizing, ::Pair).collect { (state, customizing) ->
@@ -210,6 +228,11 @@
                     it.second.applyBottomNavBarToCustomizerPadding(it.first)
                 }
             }
+            launch {
+                shadeInteractor.shadeMode.collect {
+                    qsImpl.value?.setInSplitShade(it == ShadeMode.Split)
+                }
+            }
         }
     }
 
@@ -256,9 +279,17 @@
 
     private fun QSImpl.applyState(state: QSSceneAdapter.State) {
         setQsVisible(state.isVisible)
-        setExpanded(state.isVisible)
+        setExpanded(state.isVisible && state.expansion > 0f)
         setListening(state.isVisible)
         setQsExpansion(state.expansion, 1f, 0f, state.squishiness)
-        setTransitionToFullShadeProgress(false, 1f, state.squishiness)
+    }
+
+    override fun dump(pw: PrintWriter, args: Array<out String>) {
+        pw.apply {
+            println("Last state: ${state.value}")
+            println("Customizing: ${isCustomizing.value}")
+            println("QQS height: $qqsHeight")
+            println("QS height: $qsHeight")
+        }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt
index 34f66b8..c695d4c 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt
@@ -48,6 +48,8 @@
         qsSceneAdapter.isCustomizing.map { customizing ->
             if (customizing) {
                 mapOf<UserAction, UserActionResult>(Back to UserActionResult(Scenes.QuickSettings))
+                // TODO(b/330200163) Add an Up from Bottom to be able to collapse the shade
+                // while customizing
             } else {
                 mapOf(
                     Back to UserActionResult(Scenes.Shade),
diff --git a/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java b/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
index d0ff338..7c1a2c0 100644
--- a/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
+++ b/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
@@ -86,7 +86,6 @@
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.dagger.qualifiers.Main;
 import com.android.systemui.dump.DumpManager;
-import com.android.systemui.flags.FeatureFlags;
 import com.android.systemui.keyguard.KeyguardUnlockAnimationController;
 import com.android.systemui.keyguard.KeyguardWmStateRefactor;
 import com.android.systemui.keyguard.WakefulnessLifecycle;
@@ -146,7 +145,6 @@
     private static final long MAX_BACKOFF_MILLIS = 10 * 60 * 1000;
 
     private final Context mContext;
-    private final FeatureFlags mFeatureFlags;
     private final SceneContainerFlags mSceneContainerFlags;
     private final Executor mMainExecutor;
     private final ShellInterface mShellInterface;
@@ -209,8 +207,10 @@
         @Override
         public void onStatusBarTouchEvent(MotionEvent event) {
             verifyCallerAndClearCallingIdentity("onStatusBarTouchEvent", () -> {
-                // TODO move this logic to message queue
-                if (event.getActionMasked() == ACTION_DOWN) {
+                if (mSceneContainerFlags.isEnabled()) {
+                    //TODO(b/329863123) implement latency tracking for shade scene
+                    Log.i(TAG_OPS, "Scene container enabled. Latency tracking not started.");
+                } else if (event.getActionMasked() == ACTION_DOWN) {
                     mShadeViewControllerLazy.get().startExpandLatencyTracking();
                 }
                 mHandler.post(() -> {
@@ -600,7 +600,6 @@
             KeyguardUnlockAnimationController sysuiUnlockAnimationController,
             InWindowLauncherUnlockAnimationManager inWindowLauncherUnlockAnimationManager,
             AssistUtils assistUtils,
-            FeatureFlags featureFlags,
             SceneContainerFlags sceneContainerFlags,
             DumpManager dumpManager,
             Optional<UnfoldTransitionProgressForwarder> unfoldTransitionProgressForwarder,
@@ -613,7 +612,6 @@
         }
 
         mContext = context;
-        mFeatureFlags = featureFlags;
         mSceneContainerFlags = sceneContainerFlags;
         mMainExecutor = mainExecutor;
         mShellInterface = shellInterface;
diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java
index 3a2a081..9cb920a 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java
@@ -3257,7 +3257,6 @@
         mKeyguardStatusViewController.setStatusAccessibilityImportance(mode);
     }
 
-    @Override
     public void performHapticFeedback(int constant) {
         mVibratorHelper.performHapticFeedback(mView, constant);
     }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/QuickSettingsControllerImpl.java b/packages/SystemUI/src/com/android/systemui/shade/QuickSettingsControllerImpl.java
index 8ba0544..8dbcead 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/QuickSettingsControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/QuickSettingsControllerImpl.java
@@ -1280,18 +1280,20 @@
 
         mScrimController.setScrimCornerRadius(radius);
 
-        // Convert global clipping coordinates to local ones,
-        // relative to NotificationStackScrollLayout
-        int nsslLeft = calculateNsslLeft(left);
-        int nsslRight = calculateNsslRight(right);
-        int nsslTop = getNotificationsClippingTopBounds(top);
-        int nsslBottom = bottom - mNotificationStackScrollLayoutController.getTop();
-        int bottomRadius = mSplitShadeEnabled ? radius : 0;
-        // TODO (b/265193930): remove dependency on NPVC
-        int topRadius = mSplitShadeEnabled
-                && mPanelViewControllerLazy.get().isExpandingFromHeadsUp() ? 0 : radius;
-        mNotificationStackScrollLayoutController.setRoundedClippingBounds(
-                nsslLeft, nsslTop, nsslRight, nsslBottom, topRadius, bottomRadius);
+        if (!SceneContainerFlag.isEnabled()) {
+            // Convert global clipping coordinates to local ones,
+            // relative to NotificationStackScrollLayout
+            int nsslLeft = calculateNsslLeft(left);
+            int nsslRight = calculateNsslRight(right);
+            int nsslTop = getNotificationsClippingTopBounds(top);
+            int nsslBottom = bottom - mNotificationStackScrollLayoutController.getTop();
+            int bottomRadius = mSplitShadeEnabled ? radius : 0;
+            // TODO (b/265193930): remove dependency on NPVC
+            int topRadius = mSplitShadeEnabled
+                    && mPanelViewControllerLazy.get().isExpandingFromHeadsUp() ? 0 : radius;
+            mNotificationStackScrollLayoutController.setRoundedClippingBounds(
+                    nsslLeft, nsslTop, nsslRight, nsslBottom, topRadius, bottomRadius);
+        }
     }
 
     /**
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeController.java b/packages/SystemUI/src/com/android/systemui/shade/ShadeController.java
index 0a57b64..813df11 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeController.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeController.java
@@ -232,6 +232,13 @@
     /** Called when a launch animation ends. */
     void onLaunchAnimationEnd(boolean launchIsFullScreen);
 
+    /**
+     * Performs haptic feedback from a view with a haptic feedback constant.
+     *
+     * @param constant One of android.view.HapticFeedbackConstants
+     */
+    void performHapticFeedback(int constant);
+
     /** Sets the listener for when the visibility of the shade changes. */
     default void setVisibilityListener(ShadeVisibilityListener listener) {}
 
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerEmptyImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerEmptyImpl.kt
index 093690f..d703a27 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerEmptyImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerEmptyImpl.kt
@@ -63,4 +63,5 @@
     override fun onStatusBarTouch(event: MotionEvent?) {}
     override fun onLaunchAnimationCancelled(isLaunchForActivity: Boolean) {}
     override fun onLaunchAnimationEnd(launchIsFullScreen: Boolean) {}
+    override fun performHapticFeedback(constant: Int) {}
 }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerImpl.java b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerImpl.java
index d99d607..5f5e5ce 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerImpl.java
@@ -271,6 +271,11 @@
     }
 
     @Override
+    public void performHapticFeedback(int constant) {
+        getNpvc().performHapticFeedback(constant);
+    }
+
+    @Override
     public void instantCollapseShade() {
         getNpvc().instantCollapse();
         runPostCollapseActions();
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt
index 177c3db..c20efea 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt
@@ -33,6 +33,7 @@
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.statusbar.CommandQueue
 import com.android.systemui.statusbar.NotificationShadeWindowController
+import com.android.systemui.statusbar.VibratorHelper
 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout
 import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager
 import dagger.Lazy
@@ -62,6 +63,7 @@
     private val deviceEntryInteractor: DeviceEntryInteractor,
     private val notificationStackScrollLayout: NotificationStackScrollLayout,
     @ShadeTouchLog private val touchLog: LogBuffer,
+    private val vibratorHelper: VibratorHelper,
     commandQueue: CommandQueue,
     statusBarKeyguardViewManager: StatusBarKeyguardViewManager,
     notificationShadeWindowController: NotificationShadeWindowController,
@@ -249,4 +251,8 @@
         // The only call to this doesn't happen with migrateClocksToBlueprint() enabled
         throw UnsupportedOperationException()
     }
+
+    override fun performHapticFeedback(constant: Int) {
+        vibratorHelper.performHapticFeedback(notificationStackScrollLayout, constant)
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeViewController.kt b/packages/SystemUI/src/com/android/systemui/shade/ShadeViewController.kt
index d90bb0b..9902a32 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeViewController.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeViewController.kt
@@ -44,6 +44,7 @@
     fun disableHeader(state1: Int, state2: Int, animated: Boolean)
 
     /** If the latency tracker is enabled, begins tracking expand latency. */
+    @Deprecated("No longer supported. Do not add new calls to this.")
     fun startExpandLatencyTracking()
 
     /** Sets the alpha value of the shade to a value between 0 and 255. */
@@ -57,13 +58,14 @@
     fun setAlphaChangeAnimationEndAction(r: Runnable)
 
     /** Sets Qs ScrimEnabled and updates QS state. */
+    @Deprecated("Does nothing when scene container is enabled.")
     fun setQsScrimEnabled(qsScrimEnabled: Boolean)
 
     /** Sets the top spacing for the ambient indicator. */
     fun setAmbientIndicationTop(ambientIndicationTop: Int, ambientTextVisible: Boolean)
 
     /** Updates notification panel-specific flags on [SysUiState]. */
-    fun updateSystemUiStateFlags()
+    @Deprecated("Does nothing when scene container is enabled.") fun updateSystemUiStateFlags()
 
     /** Ensures that the touchable region is updated. */
     fun updateTouchableRegion()
@@ -105,16 +107,6 @@
     @Deprecated("No longer supported. Do not add new calls to this.")
     fun finishInputFocusTransfer(velocity: Float)
 
-    /**
-     * Performs haptic feedback from a view with a haptic feedback constant.
-     *
-     * The implementation of this method should use the [android.view.View.performHapticFeedback]
-     * method with the provided constant.
-     *
-     * @param[constant] One of [android.view.HapticFeedbackConstants]
-     */
-    fun performHapticFeedback(constant: Int)
-
     /** Returns the ShadeHeadsUpTracker. */
     val shadeHeadsUpTracker: ShadeHeadsUpTracker
 
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeViewControllerEmptyImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/ShadeViewControllerEmptyImpl.kt
index 69849e8..93c3772 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeViewControllerEmptyImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeViewControllerEmptyImpl.kt
@@ -84,8 +84,6 @@
     override fun startInputFocusTransfer() {}
     override fun cancelInputFocusTransfer() {}
     override fun finishInputFocusTransfer(velocity: Float) {}
-    override fun performHapticFeedback(constant: Int) {}
-
     override val shadeHeadsUpTracker = ShadeHeadsUpTrackerEmptyImpl()
     override val shadeFoldAnimator = ShadeFoldAnimatorEmptyImpl()
     @Deprecated("Use SceneInteractor.currentScene instead.")
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt
index ea549f2..24b7533 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt
@@ -66,11 +66,13 @@
                 deviceEntryInteractor.isUnlocked,
                 deviceEntryInteractor.canSwipeToEnter,
                 shadeInteractor.shadeMode,
-            ) { isUnlocked, canSwipeToDismiss, shadeMode ->
+                qsSceneAdapter.isCustomizing
+            ) { isUnlocked, canSwipeToDismiss, shadeMode, isCustomizing ->
                 destinationScenes(
                     isUnlocked = isUnlocked,
                     canSwipeToDismiss = canSwipeToDismiss,
                     shadeMode = shadeMode,
+                    isCustomizing = isCustomizing
                 )
             }
             .stateIn(
@@ -81,6 +83,7 @@
                         isUnlocked = deviceEntryInteractor.isUnlocked.value,
                         canSwipeToDismiss = deviceEntryInteractor.canSwipeToEnter.value,
                         shadeMode = shadeInteractor.shadeMode.value,
+                        isCustomizing = qsSceneAdapter.isCustomizing.value,
                     ),
             )
 
@@ -120,6 +123,7 @@
         isUnlocked: Boolean,
         canSwipeToDismiss: Boolean?,
         shadeMode: ShadeMode,
+        isCustomizing: Boolean,
     ): Map<UserAction, UserActionResult> {
         val up =
             when {
@@ -131,7 +135,9 @@
         val down = Scenes.QuickSettings.takeIf { shadeMode is ShadeMode.Single }
 
         return buildMap {
-            this[Swipe(SwipeDirection.Up)] = UserActionResult(up)
+            if (!isCustomizing) {
+                this[Swipe(SwipeDirection.Up)] = UserActionResult(up)
+            } // TODO(b/330200163) Add an else to be able to collapse the shade while customizing
             down?.let { this[Swipe(SwipeDirection.Down)] = UserActionResult(down) }
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java b/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java
index a12b970..da8c1be 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java
@@ -560,11 +560,6 @@
                                 Pair.create(
                                         KeyEvent.KEYCODE_TAB,
                                         KeyEvent.META_SHIFT_ON | KeyEvent.META_ALT_ON))),
-                /* Hide and (re)show taskbar: Meta + T */
-                new ShortcutKeyGroupMultiMappingInfo(
-                        context.getString(R.string.group_system_hide_reshow_taskbar),
-                        Arrays.asList(
-                                Pair.create(KeyEvent.KEYCODE_T, KeyEvent.META_META_ON))),
                 /* Access notification shade: Meta + N */
                 new ShortcutKeyGroupMultiMappingInfo(
                         context.getString(R.string.group_system_access_notification_shade),
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java
index 9479762..f2c593d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java
@@ -812,6 +812,10 @@
         } else {
             mDebugTextUsedYPositions.clear();
         }
+
+        mDebugPaint.setColor(Color.DKGRAY);
+        canvas.drawPath(mRoundedClipPath, mDebugPaint);
+
         int y = 0;
         drawDebugInfo(canvas, y, Color.RED, /* label= */ "y = " + y);
 
@@ -843,14 +847,14 @@
         drawDebugInfo(canvas, y, Color.LTGRAY,
                 /* label= */ "mAmbientState.getStackY() + mAmbientState.getStackHeight() = " + y);
 
-        y = (int) mAmbientState.getStackY() + mContentHeight;
-        drawDebugInfo(canvas, y, Color.MAGENTA,
-                /* label= */ "mAmbientState.getStackY() + mContentHeight = " + y);
-
         y = (int) (mAmbientState.getStackY() + mIntrinsicContentHeight);
         drawDebugInfo(canvas, y, Color.YELLOW,
                 /* label= */ "mAmbientState.getStackY() + mIntrinsicContentHeight = " + y);
 
+        y = mContentHeight;
+        drawDebugInfo(canvas, y, Color.MAGENTA,
+                /* label= */ "mContentHeight = " + y);
+
         drawDebugInfo(canvas, mRoundedRectClippingBottom, Color.DKGRAY,
                 /* label= */ "mRoundedRectClippingBottom) = " + y);
     }
@@ -4940,6 +4944,9 @@
             println(pw, "intrinsicPadding", mIntrinsicPadding);
             println(pw, "topPadding", mTopPadding);
             println(pw, "bottomPadding", mBottomPadding);
+            dumpRoundedRectClipping(pw);
+            println(pw, "requestedClipBounds", mRequestedClipBounds);
+            println(pw, "isClipped", mIsClipped);
             println(pw, "translationX", getTranslationX());
             println(pw, "translationY", getTranslationY());
             println(pw, "translationZ", getTranslationZ());
@@ -4994,6 +5001,15 @@
                 });
     }
 
+    private void dumpRoundedRectClipping(IndentingPrintWriter pw) {
+        pw.append("roundedRectClipping{l=").print(mRoundedRectClippingLeft);
+        pw.append(" t=").print(mRoundedRectClippingTop);
+        pw.append(" r=").print(mRoundedRectClippingRight);
+        pw.append(" b=").print(mRoundedRectClippingBottom);
+        pw.append("} topRadius=").print(mBgCornerRadii[0]);
+        pw.append(" bottomRadius=").println(mBgCornerRadii[4]);
+    }
+
     private void dumpFooterViewVisibility(IndentingPrintWriter pw) {
         FooterViewRefactor.assertInLegacyMode();
         final boolean showDismissView = shouldShowDismissView();
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt
index 9efe632..79ba25e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt
@@ -17,8 +17,8 @@
 
 package com.android.systemui.statusbar.notification.stack.data.repository
 
-import com.android.systemui.common.shared.model.NotificationContainerBounds
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.statusbar.notification.stack.shared.model.StackBounds
 import javax.inject.Inject
 import kotlinx.coroutines.flow.MutableStateFlow
 
@@ -26,7 +26,7 @@
 @SysUISingleton
 class NotificationStackAppearanceRepository @Inject constructor() {
     /** The bounds of the notification stack in the current scene. */
-    val stackBounds = MutableStateFlow(NotificationContainerBounds())
+    val stackBounds = MutableStateFlow(StackBounds())
 
     /**
      * The height in px of the contents of notification stack. Depending on the number of
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt
index 08df473..f05d017 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt
@@ -17,13 +17,19 @@
 
 package com.android.systemui.statusbar.notification.stack.domain.interactor
 
-import com.android.systemui.common.shared.model.NotificationContainerBounds
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.shade.domain.interactor.ShadeInteractor
+import com.android.systemui.shade.shared.model.ShadeMode
 import com.android.systemui.statusbar.notification.stack.data.repository.NotificationStackAppearanceRepository
+import com.android.systemui.statusbar.notification.stack.shared.model.StackBounds
+import com.android.systemui.statusbar.notification.stack.shared.model.StackRounding
 import javax.inject.Inject
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOf
 
 /** An interactor which controls the appearance of the NSSL */
 @SysUISingleton
@@ -31,9 +37,30 @@
 @Inject
 constructor(
     private val repository: NotificationStackAppearanceRepository,
+    shadeInteractor: ShadeInteractor,
 ) {
     /** The bounds of the notification stack in the current scene. */
-    val stackBounds: StateFlow<NotificationContainerBounds> = repository.stackBounds.asStateFlow()
+    val stackBounds: StateFlow<StackBounds> = repository.stackBounds.asStateFlow()
+
+    /**
+     * Whether the stack is expanding from GONE-with-HUN to SHADE
+     *
+     * TODO(b/296118689): implement this to match legacy QSController logic
+     */
+    private val isExpandingFromHeadsUp: Flow<Boolean> = flowOf(false)
+
+    /** The rounding of the notification stack. */
+    val stackRounding: Flow<StackRounding> =
+        combine(
+                shadeInteractor.shadeMode,
+                isExpandingFromHeadsUp,
+            ) { shadeMode, isExpandingFromHeadsUp ->
+                StackRounding(
+                    roundTop = !(shadeMode == ShadeMode.Split && isExpandingFromHeadsUp),
+                    roundBottom = shadeMode != ShadeMode.Single,
+                )
+            }
+            .distinctUntilChanged()
 
     /**
      * The height in px of the contents of notification stack. Depending on the number of
@@ -59,7 +86,7 @@
     val syntheticScroll: Flow<Float> = repository.syntheticScroll.asStateFlow()
 
     /** Sets the position of the notification stack in the current scene. */
-    fun setStackBounds(bounds: NotificationContainerBounds) {
+    fun setStackBounds(bounds: StackBounds) {
         check(bounds.top <= bounds.bottom) { "Invalid bounds: $bounds" }
         repository.stackBounds.value = bounds
     }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackBounds.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackBounds.kt
new file mode 100644
index 0000000..1fc9a18
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackBounds.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.stack.shared.model
+
+/** Models the bounds of the notification stack. */
+data class StackBounds(
+    /** The position of the left of the stack in its window coordinate system, in pixels. */
+    val left: Float = 0f,
+    /** The position of the top of the stack in its window coordinate system, in pixels. */
+    val top: Float = 0f,
+    /** The position of the right of the stack in its window coordinate system, in pixels. */
+    val right: Float = 0f,
+    /** The position of the bottom of the stack in its window coordinate system, in pixels. */
+    val bottom: Float = 0f,
+) {
+    /** The current height of the notification container. */
+    val height: Float = bottom - top
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackClipping.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackClipping.kt
new file mode 100644
index 0000000..0c92b50
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackClipping.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.stack.shared.model
+
+/** Models the clipping rounded rectangle of the notification stack */
+data class StackClipping(val bounds: StackBounds, val rounding: StackRounding)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackRounding.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackRounding.kt
new file mode 100644
index 0000000..ddc5d7ea
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/shared/model/StackRounding.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.stack.shared.model
+
+/** Models the corner rounds of the notification stack. */
+data class StackRounding(
+    /** Whether the top corners of the notification stack should be rounded. */
+    val roundTop: Boolean = false,
+    /** Whether the bottom corners of the notification stack should be rounded. */
+    val roundBottom: Boolean = false,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt
deleted file mode 100644
index f10e5f1..0000000
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Copyright (C) 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.systemui.statusbar.notification.stack.ui.viewbinder
-
-import android.content.Context
-import android.util.TypedValue
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.repeatOnLifecycle
-import com.android.systemui.dagger.qualifiers.Main
-import com.android.systemui.lifecycle.repeatWhenAttached
-import com.android.systemui.statusbar.notification.stack.AmbientState
-import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
-import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer
-import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel
-import kotlin.math.roundToInt
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.DisposableHandle
-import kotlinx.coroutines.launch
-
-/** Binds the shared notification container to its view-model. */
-object NotificationStackAppearanceViewBinder {
-    const val SCRIM_CORNER_RADIUS = 32f
-
-    @JvmStatic
-    fun bind(
-        context: Context,
-        view: SharedNotificationContainer,
-        viewModel: NotificationStackAppearanceViewModel,
-        ambientState: AmbientState,
-        controller: NotificationStackScrollLayoutController,
-        @Main mainImmediateDispatcher: CoroutineDispatcher,
-    ): DisposableHandle {
-        return view.repeatWhenAttached(mainImmediateDispatcher) {
-            repeatOnLifecycle(Lifecycle.State.CREATED) {
-                launch {
-                    viewModel.stackBounds.collect { bounds ->
-                        val viewLeft = controller.view.left
-                        val viewTop = controller.view.top
-                        controller.setRoundedClippingBounds(
-                            bounds.left.roundToInt() - viewLeft,
-                            bounds.top.roundToInt() - viewTop,
-                            bounds.right.roundToInt() - viewLeft,
-                            bounds.bottom.roundToInt() - viewTop,
-                            SCRIM_CORNER_RADIUS.dpToPx(context),
-                            0,
-                        )
-                    }
-                }
-
-                launch {
-                    viewModel.contentTop.collect {
-                        controller.updateTopPadding(it, controller.isAddOrRemoveAnimationPending)
-                    }
-                }
-
-                launch {
-                    var wasExpanding = false
-                    viewModel.expandFraction.collect { expandFraction ->
-                        val nowExpanding = expandFraction != 0f && expandFraction != 1f
-                        if (nowExpanding && !wasExpanding) {
-                            controller.onExpansionStarted()
-                        }
-                        ambientState.expansionFraction = expandFraction
-                        controller.expandedHeight = expandFraction * controller.view.height
-                        if (!nowExpanding && wasExpanding) {
-                            controller.onExpansionStopped()
-                        }
-                        wasExpanding = nowExpanding
-                    }
-                }
-
-                launch { viewModel.isScrollable.collect { controller.setScrollingEnabled(it) } }
-            }
-        }
-    }
-
-    private fun Float.dpToPx(context: Context): Int {
-        return TypedValue.applyDimension(
-                TypedValue.COMPLEX_UNIT_DIP,
-                this,
-                context.resources.displayMetrics
-            )
-            .roundToInt()
-    }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackViewBinder.kt
new file mode 100644
index 0000000..1a34bb4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackViewBinder.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.stack.ui.viewbinder
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.common.ui.ConfigurationState
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.notification.stack.AmbientState
+import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout
+import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
+import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel
+import javax.inject.Inject
+import kotlin.math.roundToInt
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+/** Binds the NSSL/Controller/AmbientState to their ViewModel. */
+@SysUISingleton
+class NotificationStackViewBinder
+@Inject
+constructor(
+    @Main private val mainImmediateDispatcher: CoroutineDispatcher,
+    private val ambientState: AmbientState,
+    private val view: NotificationStackScrollLayout,
+    private val controller: NotificationStackScrollLayoutController,
+    private val viewModel: NotificationStackAppearanceViewModel,
+    private val configuration: ConfigurationState,
+) {
+
+    fun bindWhileAttached(): DisposableHandle {
+        return view.repeatWhenAttached(mainImmediateDispatcher) {
+            repeatOnLifecycle(Lifecycle.State.CREATED) { bind() }
+        }
+    }
+
+    suspend fun bind() = coroutineScope {
+        launch {
+            combine(viewModel.stackClipping, clipRadius, ::Pair).collect { (clipping, clipRadius) ->
+                val (bounds, rounding) = clipping
+                val viewLeft = controller.view.left
+                val viewTop = controller.view.top
+                controller.setRoundedClippingBounds(
+                    bounds.left.roundToInt() - viewLeft,
+                    bounds.top.roundToInt() - viewTop,
+                    bounds.right.roundToInt() - viewLeft,
+                    bounds.bottom.roundToInt() - viewTop,
+                    if (rounding.roundTop) clipRadius else 0,
+                    if (rounding.roundBottom) clipRadius else 0,
+                )
+            }
+        }
+
+        launch {
+            viewModel.contentTop.collect {
+                controller.updateTopPadding(it, controller.isAddOrRemoveAnimationPending)
+            }
+        }
+
+        launch {
+            var wasExpanding = false
+            viewModel.expandFraction.collect { expandFraction ->
+                val nowExpanding = expandFraction != 0f && expandFraction != 1f
+                if (nowExpanding && !wasExpanding) {
+                    controller.onExpansionStarted()
+                }
+                ambientState.expansionFraction = expandFraction
+                controller.expandedHeight = expandFraction * controller.view.height
+                if (!nowExpanding && wasExpanding) {
+                    controller.onExpansionStopped()
+                }
+                wasExpanding = nowExpanding
+            }
+        }
+
+        launch { viewModel.isScrollable.collect { controller.setScrollingEnabled(it) } }
+    }
+
+    private val clipRadius: Flow<Int>
+        get() = configuration.getDimensionPixelOffset(R.dimen.notification_scrim_corner_radius)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/SharedNotificationContainerBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/SharedNotificationContainerBinder.kt
index 7c76ddb..6db6719 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/SharedNotificationContainerBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/SharedNotificationContainerBinder.kt
@@ -20,6 +20,7 @@
 import android.view.WindowInsets
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.keyguard.ui.viewmodel.BurnInParameters
 import com.android.systemui.keyguard.ui.viewmodel.ViewStateAccessor
@@ -30,6 +31,8 @@
 import com.android.systemui.statusbar.notification.stack.NotificationStackSizeCalculator
 import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.SharedNotificationContainerViewModel
+import com.android.systemui.util.kotlin.DisposableHandles
+import javax.inject.Inject
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.DisposableHandle
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,18 +41,23 @@
 import kotlinx.coroutines.launch
 
 /** Binds the shared notification container to its view-model. */
-object SharedNotificationContainerBinder {
+@SysUISingleton
+class SharedNotificationContainerBinder
+@Inject
+constructor(
+    private val sceneContainerFlags: SceneContainerFlags,
+    private val controller: NotificationStackScrollLayoutController,
+    private val notificationStackSizeCalculator: NotificationStackSizeCalculator,
+    @Main private val mainImmediateDispatcher: CoroutineDispatcher,
+) {
 
-    @JvmStatic
     fun bind(
         view: SharedNotificationContainer,
         viewModel: SharedNotificationContainerViewModel,
-        sceneContainerFlags: SceneContainerFlags,
-        controller: NotificationStackScrollLayoutController,
-        notificationStackSizeCalculator: NotificationStackSizeCalculator,
-        @Main mainImmediateDispatcher: CoroutineDispatcher,
     ): DisposableHandle {
-        val disposableHandle =
+        val disposables = DisposableHandles()
+
+        disposables +=
             view.repeatWhenAttached {
                 repeatOnLifecycle(Lifecycle.State.CREATED) {
                     launch {
@@ -72,24 +80,6 @@
                 }
             }
 
-        // Required to capture keyguard media changes and ensure the notification count is correct
-        val layoutChangeListener =
-            object : View.OnLayoutChangeListener {
-                override fun onLayoutChange(
-                    view: View,
-                    left: Int,
-                    top: Int,
-                    right: Int,
-                    bottom: Int,
-                    oldLeft: Int,
-                    oldTop: Int,
-                    oldRight: Int,
-                    oldBottom: Int
-                ) {
-                    viewModel.notificationStackChanged()
-                }
-            }
-
         val burnInParams = MutableStateFlow(BurnInParameters())
         val viewState =
             ViewStateAccessor(
@@ -100,7 +90,7 @@
          * For animation sensitive coroutines, immediately run just like applicationScope does
          * instead of doing a post() to the main thread. This extra delay can cause visible jitter.
          */
-        val disposableHandleMainImmediate =
+        disposables +=
             view.repeatWhenAttached(mainImmediateDispatcher) {
                 repeatOnLifecycle(Lifecycle.State.CREATED) {
                     launch {
@@ -167,7 +157,8 @@
                 }
             }
 
-        controller.setOnHeightChangedRunnable(Runnable { viewModel.notificationStackChanged() })
+        controller.setOnHeightChangedRunnable { viewModel.notificationStackChanged() }
+        disposables += DisposableHandle { controller.setOnHeightChangedRunnable(null) }
 
         view.setOnApplyWindowInsetsListener { v: View, insets: WindowInsets ->
             val insetTypes = WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()
@@ -176,16 +167,16 @@
             }
             insets
         }
-        view.addOnLayoutChangeListener(layoutChangeListener)
+        disposables += DisposableHandle { view.setOnApplyWindowInsetsListener(null) }
 
-        return object : DisposableHandle {
-            override fun dispose() {
-                disposableHandle.dispose()
-                disposableHandleMainImmediate.dispose()
-                controller.setOnHeightChangedRunnable(null)
-                view.setOnApplyWindowInsetsListener(null)
-                view.removeOnLayoutChangeListener(layoutChangeListener)
+        // Required to capture keyguard media changes and ensure the notification count is correct
+        val layoutChangeListener =
+            View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+                viewModel.notificationStackChanged()
             }
-        }
+        view.addOnLayoutChangeListener(layoutChangeListener)
+        disposables += DisposableHandle { view.removeOnLayoutChangeListener(layoutChangeListener) }
+
+        return disposables
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt
index b6167e1..a7cbc33 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt
@@ -18,7 +18,6 @@
 package com.android.systemui.statusbar.notification.stack.ui.viewmodel
 
 import com.android.compose.animation.scene.ObservableTransitionState
-import com.android.systemui.common.shared.model.NotificationContainerBounds
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.dump.DumpManager
@@ -27,6 +26,7 @@
 import com.android.systemui.scene.shared.model.Scenes.Shade
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.statusbar.notification.stack.domain.interactor.NotificationStackAppearanceInteractor
+import com.android.systemui.statusbar.notification.stack.shared.model.StackClipping
 import com.android.systemui.util.kotlin.FlowDumperImpl
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
@@ -83,8 +83,13 @@
             .dumpWhileCollecting("expandFraction")
 
     /** The bounds of the notification stack in the current scene. */
-    val stackBounds: Flow<NotificationContainerBounds> =
-        stackAppearanceInteractor.stackBounds.dumpValue("stackBounds")
+    val stackClipping: Flow<StackClipping> =
+        combine(
+                stackAppearanceInteractor.stackBounds,
+                stackAppearanceInteractor.stackRounding,
+                ::StackClipping
+            )
+            .dumpWhileCollecting("stackClipping")
 
     /** The y-coordinate in px of top of the contents of the notification stack. */
     val contentTop: StateFlow<Float> = stackAppearanceInteractor.contentTop.dumpValue("contentTop")
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt
index 9e2497d..bd83121 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt
@@ -24,6 +24,8 @@
 import com.android.systemui.scene.shared.flag.SceneContainerFlags
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.statusbar.notification.stack.domain.interactor.NotificationStackAppearanceInteractor
+import com.android.systemui.statusbar.notification.stack.shared.model.StackBounds
+import com.android.systemui.statusbar.notification.stack.shared.model.StackRounding
 import javax.inject.Inject
 import kotlinx.coroutines.flow.Flow
 
@@ -61,12 +63,17 @@
         right: Float,
         bottom: Float,
     ) {
-        val notificationContainerBounds =
-            NotificationContainerBounds(top = top, bottom = bottom, left = left, right = right)
-        keyguardInteractor.setNotificationContainerBounds(notificationContainerBounds)
-        interactor.setStackBounds(notificationContainerBounds)
+        keyguardInteractor.setNotificationContainerBounds(
+            NotificationContainerBounds(top = top, bottom = bottom)
+        )
+        interactor.setStackBounds(
+            StackBounds(top = top, bottom = bottom, left = left, right = right)
+        )
     }
 
+    /** Corner rounding of the stack */
+    val stackRounding: Flow<StackRounding> = interactor.stackRounding
+
     /**
      * The height in px of the contents of notification stack. Depending on the number of
      * notifications, this can exceed the space available on screen to show notifications, at which
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacks.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacks.java
index 0db5c64..665fc0a 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacks.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacks.java
@@ -537,7 +537,7 @@
 
     @VisibleForTesting
     void vibrateOnNavigationKeyDown() {
-        mShadeViewController.performHapticFeedback(
+        mShadeController.performHapticFeedback(
                 HapticFeedbackConstants.GESTURE_START
         );
     }
diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandles.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandles.kt
new file mode 100644
index 0000000..de036ea
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandles.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.util.kotlin
+
+import kotlinx.coroutines.DisposableHandle
+
+/** A mutable collection of [DisposableHandle] objects that is itself a [DisposableHandle] */
+class DisposableHandles : DisposableHandle {
+    private val handles = mutableListOf<DisposableHandle>()
+
+    /** Add the provided handles to this collection. */
+    fun add(vararg handles: DisposableHandle) {
+        this.handles.addAll(handles)
+    }
+
+    /** Same as [add] */
+    operator fun plusAssign(handle: DisposableHandle) {
+        this.handles.add(handle)
+    }
+
+    /** Same as [add] */
+    operator fun plusAssign(handles: Iterable<DisposableHandle>) {
+        this.handles.addAll(handles)
+    }
+
+    /** [dispose] the current contents, then [add] the provided [handles] */
+    fun replaceAll(vararg handles: DisposableHandle) {
+        dispose()
+        add(*handles)
+    }
+
+    /** Dispose of all added handles and empty this collection. */
+    override fun dispose() {
+        handles.forEach { it.dispose() }
+        handles.clear()
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
index d134e60..155102c9 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
@@ -21,7 +21,6 @@
 import com.android.settingslib.volume.data.repository.LocalMediaRepository
 import com.android.settingslib.volume.data.repository.MediaControllerRepository
 import com.android.settingslib.volume.data.repository.MediaControllerRepositoryImpl
-import com.android.settingslib.volume.domain.interactor.LocalMediaInteractor
 import com.android.settingslib.volume.shared.AudioManagerEventsReceiver
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
@@ -52,13 +51,6 @@
 
         @Provides
         @SysUISingleton
-        fun provideLocalMediaInteractor(
-            repository: LocalMediaRepository,
-            @Application scope: CoroutineScope,
-        ): LocalMediaInteractor = LocalMediaInteractor(repository, scope)
-
-        @Provides
-        @SysUISingleton
         fun provideMediaDeviceSessionRepository(
             intentsReceiver: AudioManagerEventsReceiver,
             mediaSessionManager: MediaSessionManager,
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
index 11b4690..e052f24 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
@@ -15,15 +15,12 @@
  */
 package com.android.systemui.volume.panel.component.mediaoutput.data.repository
 
-import android.media.MediaRouter2Manager
 import com.android.settingslib.volume.data.repository.LocalMediaRepository
 import com.android.settingslib.volume.data.repository.LocalMediaRepositoryImpl
 import com.android.settingslib.volume.shared.AudioManagerEventsReceiver
 import com.android.systemui.dagger.qualifiers.Application
-import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.media.controls.util.LocalMediaManagerFactory
 import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
 import kotlinx.coroutines.CoroutineScope
 
 interface LocalMediaRepositoryFactory {
@@ -35,18 +32,14 @@
 @Inject
 constructor(
     private val eventsReceiver: AudioManagerEventsReceiver,
-    private val mediaRouter2Manager: MediaRouter2Manager,
     private val localMediaManagerFactory: LocalMediaManagerFactory,
     @Application private val coroutineScope: CoroutineScope,
-    @Background private val backgroundCoroutineContext: CoroutineContext,
 ) : LocalMediaRepositoryFactory {
 
     override fun create(packageName: String?): LocalMediaRepository =
         LocalMediaRepositoryImpl(
             eventsReceiver,
             localMediaManagerFactory.create(packageName),
-            mediaRouter2Manager,
             coroutineScope,
-            backgroundCoroutineContext,
         )
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaDeviceSessionInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaDeviceSessionInteractor.kt
new file mode 100644
index 0000000..b0c8a4a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaDeviceSessionInteractor.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.domain.interactor
+
+import android.media.session.MediaController
+import android.media.session.PlaybackState
+import android.os.Handler
+import com.android.settingslib.volume.data.repository.MediaControllerChange
+import com.android.settingslib.volume.data.repository.MediaControllerRepository
+import com.android.settingslib.volume.data.repository.stateChanges
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
+import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.withContext
+
+/** Allows to observe and change [MediaDeviceSession] state. */
+@OptIn(ExperimentalCoroutinesApi::class)
+@VolumePanelScope
+class MediaDeviceSessionInteractor
+@Inject
+constructor(
+    @Background private val backgroundCoroutineContext: CoroutineContext,
+    @Background private val backgroundHandler: Handler,
+    private val mediaControllerRepository: MediaControllerRepository,
+) {
+
+    /** [PlaybackState] changes for the [MediaDeviceSession]. */
+    fun playbackState(session: MediaDeviceSession): Flow<PlaybackState?> {
+        return stateChanges(session) {
+                emit(MediaControllerChange.PlaybackStateChanged(it.playbackState))
+            }
+            .filterIsInstance(MediaControllerChange.PlaybackStateChanged::class)
+            .map { it.state }
+    }
+
+    /** [MediaController.PlaybackInfo] changes for the [MediaDeviceSession]. */
+    fun playbackInfo(session: MediaDeviceSession): Flow<MediaController.PlaybackInfo?> {
+        return stateChanges(session) {
+                emit(MediaControllerChange.AudioInfoChanged(it.playbackInfo))
+            }
+            .filterIsInstance(MediaControllerChange.AudioInfoChanged::class)
+            .map { it.info }
+    }
+
+    private fun stateChanges(
+        session: MediaDeviceSession,
+        onStart: suspend FlowCollector<MediaControllerChange>.(controller: MediaController) -> Unit,
+    ): Flow<MediaControllerChange?> =
+        mediaControllerRepository.activeSessions
+            .flatMapLatest { controllers ->
+                val controller: MediaController =
+                    findControllerForSession(controllers, session)
+                        ?: return@flatMapLatest flowOf(null)
+                controller.stateChanges(backgroundHandler).onStart { onStart(controller) }
+            }
+            .flowOn(backgroundCoroutineContext)
+
+    /** Set [MediaDeviceSession] volume to [volume]. */
+    suspend fun setSessionVolume(mediaDeviceSession: MediaDeviceSession, volume: Int): Boolean {
+        if (!mediaDeviceSession.canAdjustVolume) {
+            return false
+        }
+        return withContext(backgroundCoroutineContext) {
+            val controller =
+                findControllerForSession(
+                    mediaControllerRepository.activeSessions.value,
+                    mediaDeviceSession,
+                )
+            if (controller == null) {
+                false
+            } else {
+                controller.setVolumeTo(volume, 0)
+                true
+            }
+        }
+    }
+
+    private fun findControllerForSession(
+        controllers: Collection<MediaController>,
+        mediaDeviceSession: MediaDeviceSession,
+    ): MediaController? =
+        controllers.firstOrNull { it.sessionToken == mediaDeviceSession.sessionToken }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
index cb16abe..ea4c082 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
@@ -33,23 +33,15 @@
     private val mediaOutputDialogManager: MediaOutputDialogManager,
 ) {
 
-    fun onBarClick(session: MediaDeviceSession, expandable: Expandable) {
-        when (session) {
-            is MediaDeviceSession.Active -> {
-                mediaOutputDialogManager.createAndShowWithController(
-                    session.packageName,
-                    false,
-                    expandable.dialogController()
-                )
-            }
-            is MediaDeviceSession.Inactive -> {
-                mediaOutputDialogManager.createAndShowForSystemRouting(
-                    expandable.dialogController()
-                )
-            }
-            else -> {
-                /* do nothing */
-            }
+    fun onBarClick(session: MediaDeviceSession, isPlaybackActive: Boolean, expandable: Expandable) {
+        if (isPlaybackActive) {
+            mediaOutputDialogManager.createAndShowWithController(
+                session.packageName,
+                false,
+                expandable.dialogController()
+            )
+        } else {
+            mediaOutputDialogManager.createAndShowForSystemRouting(expandable.dialogController())
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
index 0f53437..e60139e 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
@@ -17,17 +17,16 @@
 package com.android.systemui.volume.panel.component.mediaoutput.domain.interactor
 
 import android.content.pm.PackageManager
+import android.media.VolumeProvider
 import android.media.session.MediaController
-import android.os.Handler
 import android.util.Log
 import com.android.settingslib.media.MediaDevice
 import com.android.settingslib.volume.data.repository.LocalMediaRepository
-import com.android.settingslib.volume.data.repository.MediaControllerChange
 import com.android.settingslib.volume.data.repository.MediaControllerRepository
-import com.android.settingslib.volume.data.repository.stateChanges
 import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
 import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSessions
 import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
 import javax.inject.Inject
 import kotlin.coroutines.CoroutineContext
@@ -38,12 +37,9 @@
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterIsInstance
 import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.shareIn
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.withContext
@@ -58,35 +54,40 @@
     private val packageManager: PackageManager,
     @VolumePanelScope private val coroutineScope: CoroutineScope,
     @Background private val backgroundCoroutineContext: CoroutineContext,
-    @Background private val backgroundHandler: Handler,
-    mediaControllerRepository: MediaControllerRepository
+    mediaControllerRepository: MediaControllerRepository,
 ) {
 
-    /** Current [MediaDeviceSession]. Emits when the session playback changes. */
-    val mediaDeviceSession: StateFlow<MediaDeviceSession> =
-        mediaControllerRepository.activeLocalMediaController
-            .flatMapLatest { it?.mediaDeviceSession() ?: flowOf(MediaDeviceSession.Inactive) }
-            .flowOn(backgroundCoroutineContext)
-            .stateIn(coroutineScope, SharingStarted.Eagerly, MediaDeviceSession.Inactive)
+    private val activeMediaControllers: Flow<MediaControllers> =
+        mediaControllerRepository.activeSessions
+            .map { getMediaControllers(it) }
+            .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
 
-    private fun MediaController.mediaDeviceSession(): Flow<MediaDeviceSession> {
-        return stateChanges(backgroundHandler)
-            .onStart { emit(MediaControllerChange.PlaybackStateChanged(playbackState)) }
-            .filterIsInstance<MediaControllerChange.PlaybackStateChanged>()
+    /** [MediaDeviceSessions] that contains currently active sessions. */
+    val activeMediaDeviceSessions: Flow<MediaDeviceSessions> =
+        activeMediaControllers.map {
+            MediaDeviceSessions(
+                local = it.local?.mediaDeviceSession(),
+                remote = it.remote?.mediaDeviceSession()
+            )
+        }
+
+    /** Returns the default [MediaDeviceSession] from [activeMediaDeviceSessions] */
+    val defaultActiveMediaSession: StateFlow<MediaDeviceSession?> =
+        activeMediaControllers
             .map {
-                MediaDeviceSession.Active(
-                    appLabel = getApplicationLabel(packageName)
-                            ?: return@map MediaDeviceSession.Inactive,
-                    packageName = packageName,
-                    sessionToken = sessionToken,
-                    playbackState = playbackState,
-                )
+                when {
+                    it.local?.playbackState?.isActive == true -> it.local.mediaDeviceSession()
+                    it.remote?.playbackState?.isActive == true -> it.remote.mediaDeviceSession()
+                    it.local != null -> it.local.mediaDeviceSession()
+                    else -> null
+                }
             }
-    }
+            .flowOn(backgroundCoroutineContext)
+            .stateIn(coroutineScope, SharingStarted.Eagerly, null)
 
     private val localMediaRepository: SharedFlow<LocalMediaRepository> =
-        mediaDeviceSession
-            .map { (it as? MediaDeviceSession.Active)?.packageName }
+        defaultActiveMediaSession
+            .map { it?.packageName }
             .distinctUntilChanged()
             .map { localMediaRepositoryFactory.create(it) }
             .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
@@ -111,6 +112,54 @@
         }
     }
 
+    /** Finds local and remote media controllers. */
+    private fun getMediaControllers(
+        controllers: Collection<MediaController>,
+    ): MediaControllers {
+        var localController: MediaController? = null
+        var remoteController: MediaController? = null
+        val remoteMediaSessions: MutableSet<String> = mutableSetOf()
+        for (controller in controllers) {
+            val playbackInfo: MediaController.PlaybackInfo = controller.playbackInfo ?: continue
+            when (playbackInfo.playbackType) {
+                MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE -> {
+                    // MediaController can't be local if there is a remote one for the same package
+                    if (localController?.packageName.equals(controller.packageName)) {
+                        localController = null
+                    }
+                    if (!remoteMediaSessions.contains(controller.packageName)) {
+                        remoteMediaSessions.add(controller.packageName)
+                        if (remoteController == null) {
+                            remoteController = controller
+                        }
+                    }
+                }
+                MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL -> {
+                    if (controller.packageName in remoteMediaSessions) continue
+                    if (localController != null) continue
+                    localController = controller
+                }
+            }
+        }
+        return MediaControllers(local = localController, remote = remoteController)
+    }
+
+    private suspend fun MediaController.mediaDeviceSession(): MediaDeviceSession? {
+        return MediaDeviceSession(
+            packageName = packageName,
+            sessionToken = sessionToken,
+            canAdjustVolume =
+                playbackInfo != null &&
+                    playbackInfo?.volumeControl != VolumeProvider.VOLUME_CONTROL_FIXED,
+            appLabel = getApplicationLabel(packageName) ?: return null
+        )
+    }
+
+    private data class MediaControllers(
+        val local: MediaController?,
+        val remote: MediaController?,
+    )
+
     private companion object {
         const val TAG = "MediaOutputInteractor"
     }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
index 1bceee9..2a2ce79 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
@@ -17,26 +17,15 @@
 package com.android.systemui.volume.panel.component.mediaoutput.domain.model
 
 import android.media.session.MediaSession
-import android.media.session.PlaybackState
 
 /** Represents media playing on the connected device. */
-sealed interface MediaDeviceSession {
+data class MediaDeviceSession(
+    val appLabel: CharSequence,
+    val packageName: String,
+    val sessionToken: MediaSession.Token,
+    val canAdjustVolume: Boolean,
+)
 
-    /** Media is playing. */
-    data class Active(
-        val appLabel: CharSequence,
-        val packageName: String,
-        val sessionToken: MediaSession.Token,
-        val playbackState: PlaybackState?,
-    ) : MediaDeviceSession
-
-    /** Media is not playing. */
-    data object Inactive : MediaDeviceSession
-
-    /** Current media state is unknown yet. */
-    data object Unknown : MediaDeviceSession
-}
-
-/** Returns true when the audio is playing for the [MediaDeviceSession]. */
-fun MediaDeviceSession.isPlaying(): Boolean =
-    this is MediaDeviceSession.Active && playbackState?.isActive == true
+/** Returns true when [other] controls the same sessions as [this]. */
+fun MediaDeviceSession.isTheSameSession(other: MediaDeviceSession?): Boolean =
+    sessionToken == other?.sessionToken
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSessions.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSessions.kt
new file mode 100644
index 0000000..ddc0784
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSessions.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.domain.model
+
+/** Models a pair of local and remote [MediaDeviceSession]s. */
+data class MediaDeviceSessions(
+    val local: MediaDeviceSession?,
+    val remote: MediaDeviceSession?,
+) {
+
+    companion object {
+        /** Returns [MediaDeviceSessions.local]. */
+        val Local: (MediaDeviceSessions) -> MediaDeviceSession? = { it.local }
+        /** Returns [MediaDeviceSessions.remote]. */
+        val Remote: (MediaDeviceSessions) -> MediaDeviceSession? = { it.remote }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModel.kt
index d49cb1e..2530a3a 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/ui/viewmodel/MediaOutputViewModel.kt
@@ -17,24 +17,30 @@
 package com.android.systemui.volume.panel.component.mediaoutput.ui.viewmodel
 
 import android.content.Context
+import android.media.session.PlaybackState
 import com.android.systemui.animation.Expandable
 import com.android.systemui.common.shared.model.Color
 import com.android.systemui.common.shared.model.Icon
 import com.android.systemui.res.R
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaDeviceSessionInteractor
 import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputActionsInteractor
 import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
 import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
-import com.android.systemui.volume.panel.component.mediaoutput.domain.model.isPlaying
 import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
 import com.android.systemui.volume.panel.ui.viewmodel.VolumePanelViewModel
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 
 /** Models the UI of the Media Output Volume Panel component. */
+@OptIn(ExperimentalCoroutinesApi::class)
 @VolumePanelScope
 class MediaOutputViewModel
 @Inject
@@ -43,25 +49,36 @@
     @VolumePanelScope private val coroutineScope: CoroutineScope,
     private val volumePanelViewModel: VolumePanelViewModel,
     private val actionsInteractor: MediaOutputActionsInteractor,
+    private val mediaDeviceSessionInteractor: MediaDeviceSessionInteractor,
     interactor: MediaOutputInteractor,
 ) {
 
-    private val mediaDeviceSession: StateFlow<MediaDeviceSession> =
-        interactor.mediaDeviceSession.stateIn(
-            coroutineScope,
-            SharingStarted.Eagerly,
-            MediaDeviceSession.Unknown,
-        )
+    private val sessionWithPlayback: StateFlow<SessionWithPlayback?> =
+        interactor.defaultActiveMediaSession
+            .flatMapLatest { session ->
+                if (session == null) {
+                    flowOf(null)
+                } else {
+                    mediaDeviceSessionInteractor.playbackState(session).map { playback ->
+                        playback?.let { SessionWithPlayback(session, it) }
+                    }
+                }
+            }
+            .stateIn(
+                coroutineScope,
+                SharingStarted.Eagerly,
+                null,
+            )
 
     val connectedDeviceViewModel: StateFlow<ConnectedDeviceViewModel?> =
-        combine(mediaDeviceSession, interactor.currentConnectedDevice) {
+        combine(sessionWithPlayback, interactor.currentConnectedDevice) {
                 mediaDeviceSession,
                 currentConnectedDevice ->
                 ConnectedDeviceViewModel(
-                    if (mediaDeviceSession.isPlaying()) {
+                    if (mediaDeviceSession?.playback?.isActive == true) {
                         context.getString(
                             R.string.media_output_label_title,
-                            (mediaDeviceSession as MediaDeviceSession.Active).appLabel
+                            mediaDeviceSession.session.appLabel
                         )
                     } else {
                         context.getString(R.string.media_output_title_without_playing)
@@ -76,10 +93,10 @@
             )
 
     val deviceIconViewModel: StateFlow<DeviceIconViewModel?> =
-        combine(mediaDeviceSession, interactor.currentConnectedDevice) {
+        combine(sessionWithPlayback, interactor.currentConnectedDevice) {
                 mediaDeviceSession,
                 currentConnectedDevice ->
-                if (mediaDeviceSession.isPlaying()) {
+                if (mediaDeviceSession?.playback?.isActive == true) {
                     val icon =
                         currentConnectedDevice?.icon?.let { Icon.Loaded(it, null) }
                             ?: Icon.Resource(
@@ -112,7 +129,14 @@
             )
 
     fun onBarClick(expandable: Expandable) {
-        actionsInteractor.onBarClick(mediaDeviceSession.value, expandable)
+        sessionWithPlayback.value?.let {
+            actionsInteractor.onBarClick(it.session, it.playback.isActive, expandable)
+        }
         volumePanelViewModel.dismissPanel()
     }
+
+    private data class SessionWithPlayback(
+        val session: MediaDeviceSession,
+        val playback: PlaybackState,
+    )
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/CastVolumeInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/CastVolumeInteractor.kt
deleted file mode 100644
index 6b62074..0000000
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/CastVolumeInteractor.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- *  Licensed under the Apache License, Version 2.0 (the "License");
- *  you may not use this file except in compliance with the License.
- *  You may obtain a copy of the License at
- *
- *       http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
- */
-
-package com.android.systemui.volume.panel.component.volume.domain.interactor
-
-import com.android.settingslib.volume.domain.interactor.LocalMediaInteractor
-import com.android.settingslib.volume.domain.model.RoutingSession
-import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
-import javax.inject.Inject
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-
-/** Provides a remote media casting state. */
-@VolumePanelScope
-class CastVolumeInteractor
-@Inject
-constructor(
-    @VolumePanelScope private val coroutineScope: CoroutineScope,
-    private val localMediaInteractor: LocalMediaInteractor,
-) {
-
-    /** Returns a list of [RoutingSession] to show in the UI. */
-    val remoteRoutingSessions: StateFlow<List<RoutingSession>> =
-        localMediaInteractor.remoteRoutingSessions
-            .map { it.filter { routingSession -> routingSession.isVolumeSeekBarEnabled } }
-            .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
-
-    /** Sets [routingSession] volume to [volume]. */
-    suspend fun setVolume(routingSession: RoutingSession, volume: Int) {
-        localMediaInteractor.adjustSessionVolume(routingSession.routingSessionInfo.id, volume)
-    }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt
index 1b73208..d49442c 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt
@@ -80,7 +80,7 @@
             ) { model, isEnabled, ringerMode ->
                 model.toState(isEnabled, ringerMode)
             }
-            .stateIn(coroutineScope, SharingStarted.Eagerly, EmptyState)
+            .stateIn(coroutineScope, SharingStarted.Eagerly, SliderState.Empty)
 
     override fun onValueChanged(state: SliderState, newValue: Float) {
         val audioViewModel = state as? State
@@ -163,17 +163,6 @@
         val audioStreamModel: AudioStreamModel,
     ) : SliderState
 
-    private data object EmptyState : SliderState {
-        override val value: Float = 0f
-        override val valueRange: ClosedFloatingPointRange<Float> = 0f..1f
-        override val icon: Icon? = null
-        override val valueText: String = ""
-        override val label: String = ""
-        override val disabledMessage: String? = null
-        override val a11yStep: Int = 0
-        override val isEnabled: Boolean = true
-    }
-
     @AssistedFactory
     interface Factory {
 
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt
index 86b2d73..0f240b3 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt
@@ -17,11 +17,11 @@
 package com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel
 
 import android.content.Context
-import com.android.settingslib.volume.domain.model.RoutingSession
+import android.media.session.MediaController.PlaybackInfo
 import com.android.systemui.common.shared.model.Icon
 import com.android.systemui.res.R
-import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
-import com.android.systemui.volume.panel.component.volume.domain.interactor.CastVolumeInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaDeviceSessionInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
 import com.android.systemui.volume.panel.component.volume.domain.interactor.VolumeSliderInteractor
 import dagger.assisted.Assisted
 import dagger.assisted.AssistedFactory
@@ -30,30 +30,29 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 
 class CastVolumeSliderViewModel
 @AssistedInject
 constructor(
-    @Assisted private val routingSession: RoutingSession,
+    @Assisted private val session: MediaDeviceSession,
     @Assisted private val coroutineScope: CoroutineScope,
     private val context: Context,
-    mediaOutputInteractor: MediaOutputInteractor,
+    private val mediaDeviceSessionInteractor: MediaDeviceSessionInteractor,
     private val volumeSliderInteractor: VolumeSliderInteractor,
-    private val castVolumeInteractor: CastVolumeInteractor,
 ) : SliderViewModel {
 
-    private val volumeRange = 0..routingSession.routingSessionInfo.volumeMax
-
     override val slider: StateFlow<SliderState> =
-        combine(mediaOutputInteractor.currentConnectedDevice) { _ -> getCurrentState() }
-            .stateIn(coroutineScope, SharingStarted.Eagerly, getCurrentState())
+        mediaDeviceSessionInteractor
+            .playbackInfo(session)
+            .mapNotNull { it?.getCurrentState() }
+            .stateIn(coroutineScope, SharingStarted.Eagerly, SliderState.Empty)
 
     override fun onValueChanged(state: SliderState, newValue: Float) {
         coroutineScope.launch {
-            castVolumeInteractor.setVolume(routingSession, newValue.roundToInt())
+            mediaDeviceSessionInteractor.setSessionVolume(session, newValue.roundToInt())
         }
     }
 
@@ -61,15 +60,16 @@
         // do nothing because this action isn't supported for Cast sliders.
     }
 
-    private fun getCurrentState(): State =
-        State(
-            value = routingSession.routingSessionInfo.volume.toFloat(),
+    private fun PlaybackInfo.getCurrentState(): State {
+        val volumeRange = 0..maxVolume
+        return State(
+            value = currentVolume.toFloat(),
             valueRange = volumeRange.first.toFloat()..volumeRange.last.toFloat(),
             icon = Icon.Resource(R.drawable.ic_cast, null),
             valueText =
                 SliderViewModel.formatValue(
                     volumeSliderInteractor.processVolumeToValue(
-                        volume = routingSession.routingSessionInfo.volume,
+                        volume = currentVolume,
                         volumeRange = volumeRange,
                     )
                 ),
@@ -77,6 +77,7 @@
             isEnabled = true,
             a11yStep = 1
         )
+    }
 
     private data class State(
         override val value: Float,
@@ -95,7 +96,7 @@
     interface Factory {
 
         fun create(
-            routingSession: RoutingSession,
+            session: MediaDeviceSession,
             coroutineScope: CoroutineScope,
         ): CastVolumeSliderViewModel
     }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt
index b87d0a7..3dca272 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt
@@ -36,4 +36,15 @@
      */
     val a11yStep: Int
     val disabledMessage: String?
+
+    data object Empty : SliderState {
+        override val value: Float = 0f
+        override val valueRange: ClosedFloatingPointRange<Float> = 0f..1f
+        override val icon: Icon? = null
+        override val valueText: String = ""
+        override val label: String = ""
+        override val disabledMessage: String? = null
+        override val a11yStep: Int = 0
+        override val isEnabled: Boolean = true
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt
index aaee24b..4e9a456 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt
@@ -18,9 +18,10 @@
 
 import android.media.AudioManager
 import com.android.settingslib.volume.shared.model.AudioStream
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaDeviceSessionInteractor
 import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
-import com.android.systemui.volume.panel.component.mediaoutput.domain.model.isPlaying
-import com.android.systemui.volume.panel.component.volume.domain.interactor.CastVolumeInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.isTheSameSession
 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.AudioStreamSliderViewModel
 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.CastVolumeSliderViewModel
 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
@@ -29,17 +30,15 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.combineTransform
+import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.transformLatest
 import kotlinx.coroutines.launch
 
 /**
@@ -52,50 +51,34 @@
 @Inject
 constructor(
     @VolumePanelScope private val scope: CoroutineScope,
-    castVolumeInteractor: CastVolumeInteractor,
     mediaOutputInteractor: MediaOutputInteractor,
+    private val mediaDeviceSessionInteractor: MediaDeviceSessionInteractor,
     private val streamSliderViewModelFactory: AudioStreamSliderViewModel.Factory,
     private val castVolumeSliderViewModelFactory: CastVolumeSliderViewModel.Factory,
 ) {
 
-    private val remoteSessionsViewModels: Flow<List<SliderViewModel>> =
-        castVolumeInteractor.remoteRoutingSessions.transformLatest { routingSessions ->
-            coroutineScope {
-                emit(
-                    routingSessions.map { routingSession ->
-                        castVolumeSliderViewModelFactory.create(routingSession, this)
-                    }
-                )
-            }
-        }
-    private val streamViewModels: Flow<List<SliderViewModel>> =
-        flowOf(
-                listOf(
-                    AudioStream(AudioManager.STREAM_MUSIC),
-                    AudioStream(AudioManager.STREAM_VOICE_CALL),
-                    AudioStream(AudioManager.STREAM_RING),
-                    AudioStream(AudioManager.STREAM_NOTIFICATION),
-                    AudioStream(AudioManager.STREAM_ALARM),
-                )
-            )
-            .transformLatest { streams ->
-                coroutineScope {
-                    emit(
-                        streams.map { stream ->
-                            streamSliderViewModelFactory.create(
-                                AudioStreamSliderViewModel.FactoryAudioStreamWrapper(stream),
-                                this,
-                            )
-                        }
-                    )
-                }
-            }
-
     val sliderViewModels: StateFlow<List<SliderViewModel>> =
-        combine(remoteSessionsViewModels, streamViewModels) {
-                remoteSessionsViewModels,
-                streamViewModels ->
-                remoteSessionsViewModels + streamViewModels
+        combineTransform(
+                mediaOutputInteractor.activeMediaDeviceSessions,
+                mediaOutputInteractor.defaultActiveMediaSession,
+            ) { activeSessions, defaultSession ->
+                coroutineScope {
+                    val viewModels = buildList {
+                        if (defaultSession?.isTheSameSession(activeSessions.remote) == true) {
+                            addRemoteViewModelIfNeeded(this, activeSessions.remote)
+                            addStreamViewModel(this, AudioManager.STREAM_MUSIC)
+                        } else {
+                            addStreamViewModel(this, AudioManager.STREAM_MUSIC)
+                            addRemoteViewModelIfNeeded(this, activeSessions.remote)
+                        }
+
+                        addStreamViewModel(this, AudioManager.STREAM_VOICE_CALL)
+                        addStreamViewModel(this, AudioManager.STREAM_RING)
+                        addStreamViewModel(this, AudioManager.STREAM_NOTIFICATION)
+                        addStreamViewModel(this, AudioManager.STREAM_ALARM)
+                    }
+                    emit(viewModels)
+                }
             }
             .stateIn(scope, SharingStarted.Eagerly, emptyList())
 
@@ -103,12 +86,41 @@
 
     val isExpanded: StateFlow<Boolean> =
         merge(
-                mutableIsExpanded.onStart { emit(false) },
-                mediaOutputInteractor.mediaDeviceSession.map { !it.isPlaying() },
+                mutableIsExpanded,
+                mediaOutputInteractor.defaultActiveMediaSession.flatMapLatest {
+                    if (it == null) flowOf(true)
+                    else mediaDeviceSessionInteractor.playbackState(it).map { it?.isActive != true }
+                },
             )
             .stateIn(scope, SharingStarted.Eagerly, false)
 
     fun onExpandedChanged(isExpanded: Boolean) {
         scope.launch { mutableIsExpanded.emit(isExpanded) }
     }
+
+    private fun CoroutineScope.addRemoteViewModelIfNeeded(
+        list: MutableList<SliderViewModel>,
+        remoteMediaDeviceSession: MediaDeviceSession?
+    ) {
+        if (remoteMediaDeviceSession?.canAdjustVolume == true) {
+            val viewModel =
+                castVolumeSliderViewModelFactory.create(
+                    remoteMediaDeviceSession,
+                    this,
+                )
+            list.add(viewModel)
+        }
+    }
+
+    private fun CoroutineScope.addStreamViewModel(
+        list: MutableList<SliderViewModel>,
+        stream: Int,
+    ) {
+        val viewModel =
+            streamSliderViewModelFactory.create(
+                AudioStreamSliderViewModel.FactoryAudioStreamWrapper(AudioStream(stream)),
+                this,
+            )
+        list.add(viewModel)
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/complication/ComplicationViewModelTransformerTest.java b/packages/SystemUI/tests/src/com/android/systemui/complication/ComplicationViewModelTransformerTest.java
index 206babf..09675e2 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/complication/ComplicationViewModelTransformerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/complication/ComplicationViewModelTransformerTest.java
@@ -23,6 +23,7 @@
 
 import android.testing.AndroidTestingRunner;
 
+import androidx.lifecycle.ViewModel;
 import androidx.test.filters.SmallTest;
 
 import com.android.systemui.SysuiTestCase;
@@ -56,7 +57,8 @@
         MockitoAnnotations.initMocks(this);
         when(mFactory.create(Mockito.any(), Mockito.any())).thenReturn(mComponent);
         when(mComponent.getViewModelProvider()).thenReturn(mViewModelProvider);
-        when(mViewModelProvider.get(Mockito.any(), Mockito.any())).thenReturn(mViewModel);
+        when(mViewModelProvider.get(Mockito.any(), Mockito.<Class<ViewModel>>any()))
+                .thenReturn(mViewModel);
     }
 
     /**
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.kt
index cc48640..5c6ed70 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.kt
@@ -21,6 +21,7 @@
 import android.testing.ViewUtils
 import android.view.ContextThemeWrapper
 import android.view.View
+import android.view.ViewGroup
 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
@@ -71,7 +72,7 @@
             qsPanel = QSPanel(themedContext, null)
             qsPanel.mUsingMediaPlayer = true
 
-            qsPanel.initialize(qsLogger)
+            qsPanel.initialize(qsLogger, true)
             // QSPanel inflates a footer inside of it, mocking it here
             footer = LinearLayout(themedContext).apply { id = R.id.qs_footer }
             qsPanel.addView(footer, MATCH_PARENT, 100)
@@ -218,6 +219,62 @@
         verify(tile).addCallback(record.callback)
     }
 
+    @Test
+    fun initializedWithNoMedia_tileLayoutParentIsAlwaysQsPanel() {
+        lateinit var panel: QSPanel
+        lateinit var tileLayout: View
+        testableLooper.runWithLooper {
+            panel = QSPanel(themedContext, null)
+            panel.mUsingMediaPlayer = true
+
+            panel.initialize(qsLogger, /* usingMediaPlayer= */ false)
+            tileLayout = panel.orCreateTileLayout as View
+            // QSPanel inflates a footer inside of it, mocking it here
+            footer = LinearLayout(themedContext).apply { id = R.id.qs_footer }
+            panel.addView(footer, MATCH_PARENT, 100)
+            panel.onFinishInflate()
+            // Provides a parent with non-zero size for QSPanel
+            ViewUtils.attachView(panel)
+        }
+        val mockMediaHost = mock(ViewGroup::class.java)
+
+        panel.setUsingHorizontalLayout(false, mockMediaHost, true)
+
+        assertThat(tileLayout.parent).isSameInstanceAs(panel)
+
+        panel.setUsingHorizontalLayout(true, mockMediaHost, true)
+        assertThat(tileLayout.parent).isSameInstanceAs(panel)
+
+        ViewUtils.detachView(panel)
+    }
+
+    @Test
+    fun initializeWithNoMedia_mediaNeverAttached() {
+        lateinit var panel: QSPanel
+        testableLooper.runWithLooper {
+            panel = QSPanel(themedContext, null)
+            panel.mUsingMediaPlayer = true
+
+            panel.initialize(qsLogger, /* usingMediaPlayer= */ false)
+            panel.orCreateTileLayout as View
+            // QSPanel inflates a footer inside of it, mocking it here
+            footer = LinearLayout(themedContext).apply { id = R.id.qs_footer }
+            panel.addView(footer, MATCH_PARENT, 100)
+            panel.onFinishInflate()
+            // Provides a parent with non-zero size for QSPanel
+            ViewUtils.attachView(panel)
+        }
+        val mockMediaHost = FrameLayout(themedContext)
+
+        panel.setUsingHorizontalLayout(false, mockMediaHost, true)
+        assertThat(mockMediaHost.parent).isNull()
+
+        panel.setUsingHorizontalLayout(true, mockMediaHost, true)
+        assertThat(mockMediaHost.parent).isNull()
+
+        ViewUtils.detachView(panel)
+    }
+
     private infix fun View.isLeftOf(other: View): Boolean {
         val rect = Rect()
         getBoundsOnScreen(rect)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelTest.kt
index 3fba393..e5369fc 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelTest.kt
@@ -36,7 +36,7 @@
 
         testableLooper.runWithLooper {
             quickQSPanel = QuickQSPanel(mContext, null)
-            quickQSPanel.initialize(qsLogger)
+            quickQSPanel.initialize(qsLogger, true)
 
             quickQSPanel.onFinishInflate()
             // Provides a parent with non-zero size for QSPanel
diff --git a/packages/SystemUI/tests/src/com/android/systemui/recents/OverviewProxyServiceTest.kt b/packages/SystemUI/tests/src/com/android/systemui/recents/OverviewProxyServiceTest.kt
index 10d6ebf..1313227 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/recents/OverviewProxyServiceTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/recents/OverviewProxyServiceTest.kt
@@ -21,7 +21,7 @@
 import android.content.pm.PackageManager
 import android.content.pm.ResolveInfo
 import android.os.PowerManager
-import android.os.Process;
+import android.os.Process
 import android.os.UserHandle
 import android.testing.AndroidTestingRunner
 import android.testing.TestableContext
@@ -34,8 +34,6 @@
 import com.android.systemui.broadcast.BroadcastDispatcher
 import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.dump.DumpManager
-import com.android.systemui.flags.FakeFeatureFlags
-import com.android.systemui.flags.Flags
 import com.android.systemui.keyguard.KeyguardUnlockAnimationController
 import com.android.systemui.keyguard.WakefulnessLifecycle
 import com.android.systemui.keyguard.ui.view.InWindowLauncherUnlockAnimationManager
@@ -96,7 +94,6 @@
     private val displayTracker = FakeDisplayTracker(mContext)
     private val fakeSystemClock = FakeSystemClock()
     private val sysUiState = SysUiState(displayTracker, kosmos.sceneContainerPlugin)
-    private val featureFlags = FakeFeatureFlags()
     private val wakefulnessLifecycle =
         WakefulnessLifecycle(mContext, null, fakeSystemClock, dumpManager)
 
@@ -121,8 +118,7 @@
     @Mock
     private lateinit var unfoldTransitionProgressForwarder:
         Optional<UnfoldTransitionProgressForwarder>
-    @Mock
-    private lateinit var broadcastDispatcher: BroadcastDispatcher
+    @Mock private lateinit var broadcastDispatcher: BroadcastDispatcher
 
     @Before
     fun setUp() {
@@ -205,16 +201,14 @@
 
     @Test
     fun connectToOverviewService_primaryUser_expectBindService() {
-        val mockitoSession = ExtendedMockito.mockitoSession()
-                .spyStatic(Process::class.java)
-                .startMocking()
+        val mockitoSession =
+            ExtendedMockito.mockitoSession().spyStatic(Process::class.java).startMocking()
         try {
             `when`(Process.myUserHandle()).thenReturn(UserHandle.SYSTEM)
             val spyContext = spy(context)
             val ops = createOverviewProxyService(spyContext)
             ops.startConnectionToCurrentUser()
-            verify(spyContext, atLeast(1)).bindServiceAsUser(any(), any(),
-                anyInt(), any())
+            verify(spyContext, atLeast(1)).bindServiceAsUser(any(), any(), anyInt(), any())
         } finally {
             mockitoSession.finishMocking()
         }
@@ -222,22 +216,20 @@
 
     @Test
     fun connectToOverviewService_nonPrimaryUser_expectNoBindService() {
-        val mockitoSession = ExtendedMockito.mockitoSession()
-                .spyStatic(Process::class.java)
-                .startMocking()
+        val mockitoSession =
+            ExtendedMockito.mockitoSession().spyStatic(Process::class.java).startMocking()
         try {
             `when`(Process.myUserHandle()).thenReturn(UserHandle.of(12345))
             val spyContext = spy(context)
             val ops = createOverviewProxyService(spyContext)
             ops.startConnectionToCurrentUser()
-            verify(spyContext, times(0)).bindServiceAsUser(any(), any(),
-                anyInt(), any())
+            verify(spyContext, times(0)).bindServiceAsUser(any(), any(), anyInt(), any())
         } finally {
             mockitoSession.finishMocking()
         }
     }
 
-    private fun createOverviewProxyService(ctx: Context) : OverviewProxyService {
+    private fun createOverviewProxyService(ctx: Context): OverviewProxyService {
         return OverviewProxyService(
             ctx,
             executor,
@@ -257,7 +249,6 @@
             sysuiUnlockAnimationController,
             inWindowLauncherUnlockAnimationManager,
             assistUtils,
-            featureFlags,
             FakeSceneContainerFlags(),
             dumpManager,
             unfoldTransitionProgressForwarder,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacksTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacksTest.java
index d5c4053..8e8dd4d 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacksTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/CentralSurfacesCommandQueueCallbacksTest.java
@@ -188,7 +188,7 @@
     public void vibrateOnNavigationKeyDown_usesPerformHapticFeedback() {
         mSbcqCallbacks.vibrateOnNavigationKeyDown();
 
-        verify(mShadeViewController).performHapticFeedback(
+        verify(mShadeController).performHapticFeedback(
                 HapticFeedbackConstants.GESTURE_START
         );
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FacePropertyRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FacePropertyRepositoryKosmos.kt
index 6ef7419..ba07a84 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FacePropertyRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FacePropertyRepositoryKosmos.kt
@@ -19,4 +19,5 @@
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.Kosmos.Fixture
 
-val Kosmos.facePropertyRepository by Fixture { FakeFacePropertyRepository() }
+val Kosmos.fakeFacePropertyRepository by Fixture { FakeFacePropertyRepository() }
+val Kosmos.facePropertyRepository by Fixture { fakeFacePropertyRepository }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorKosmos.kt
index 27803b2..c065545 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorKosmos.kt
@@ -16,7 +16,6 @@
 
 package com.android.systemui.bouncer.domain.interactor
 
-import android.content.applicationContext
 import com.android.systemui.authentication.domain.interactor.authenticationInteractor
 import com.android.systemui.bouncer.data.repository.bouncerRepository
 import com.android.systemui.classifier.domain.interactor.falsingInteractor
@@ -29,12 +28,10 @@
 val Kosmos.bouncerInteractor by Fixture {
     BouncerInteractor(
         applicationScope = testScope.backgroundScope,
-        applicationContext = applicationContext,
         repository = bouncerRepository,
         authenticationInteractor = authenticationInteractor,
         deviceEntryFaceAuthInteractor = deviceEntryFaceAuthInteractor,
         falsingInteractor = falsingInteractor,
         powerInteractor = powerInteractor,
-        simBouncerInteractor = simBouncerInteractor,
     )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractorKosmos.kt
index 8ed9f45..02b79af 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractorKosmos.kt
@@ -38,7 +38,7 @@
         telephonyManager = telephonyManager,
         resources = mainResources,
         keyguardUpdateMonitor = keyguardUpdateMonitor,
-        euiccManager = applicationContext.getSystemService(Context.EUICC_SERVICE) as EuiccManager,
+        euiccManager = applicationContext.getSystemService(Context.EUICC_SERVICE) as EuiccManager?,
         mobileConnectionsRepository = mobileConnectionsRepository,
     )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModelKosmos.kt
new file mode 100644
index 0000000..4b64416
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerMessageViewModelKosmos.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bouncer.ui.viewmodel
+
+import android.content.applicationContext
+import com.android.systemui.authentication.domain.interactor.authenticationInteractor
+import com.android.systemui.bouncer.domain.interactor.bouncerInteractor
+import com.android.systemui.bouncer.domain.interactor.simBouncerInteractor
+import com.android.systemui.bouncer.shared.flag.composeBouncerFlags
+import com.android.systemui.deviceentry.domain.interactor.biometricMessageInteractor
+import com.android.systemui.deviceentry.domain.interactor.deviceEntryFaceAuthInteractor
+import com.android.systemui.deviceentry.domain.interactor.deviceEntryFingerprintAuthInteractor
+import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.user.ui.viewmodel.userSwitcherViewModel
+import com.android.systemui.util.time.systemClock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+
+@ExperimentalCoroutinesApi
+val Kosmos.bouncerMessageViewModel by
+    Kosmos.Fixture {
+        BouncerMessageViewModel(
+            applicationContext = applicationContext,
+            applicationScope = testScope.backgroundScope,
+            bouncerInteractor = bouncerInteractor,
+            simBouncerInteractor = simBouncerInteractor,
+            authenticationInteractor = authenticationInteractor,
+            selectedUser = userSwitcherViewModel.selectedUser,
+            clock = systemClock,
+            biometricMessageInteractor = biometricMessageInteractor,
+            faceAuthInteractor = deviceEntryFaceAuthInteractor,
+            deviceEntryInteractor = deviceEntryInteractor,
+            fingerprintInteractor = deviceEntryFingerprintAuthInteractor,
+            flags = composeBouncerFlags,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt
index 6d97238..0f6c7cf 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
 package com.android.systemui.bouncer.ui.viewmodel
 
 import android.content.applicationContext
@@ -30,7 +32,7 @@
 import com.android.systemui.user.domain.interactor.selectedUserInteractor
 import com.android.systemui.user.ui.viewmodel.userSwitcherViewModel
 import com.android.systemui.util.mockito.mock
-import com.android.systemui.util.time.systemClock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 
 val Kosmos.bouncerViewModel by Fixture {
     BouncerViewModel(
@@ -47,7 +49,7 @@
         users = userSwitcherViewModel.users,
         userSwitcherMenu = userSwitcherViewModel.menu,
         actionButton = bouncerActionButtonInteractor.actionButton,
-        clock = systemClock,
         devicePolicyManager = mock(),
+        bouncerMessageViewModel = bouncerMessageViewModel,
     )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToGoneTransitionViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToGoneTransitionViewModelKosmos.kt
new file mode 100644
index 0000000..f389142
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToGoneTransitionViewModelKosmos.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.keyguard.ui.viewmodel
+
+import com.android.systemui.keyguard.ui.keyguardTransitionAnimationFlow
+import com.android.systemui.kosmos.Kosmos
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+
+@ExperimentalCoroutinesApi
+val Kosmos.dreamingToGoneTransitionViewModel by
+    Kosmos.Fixture {
+        DreamingToGoneTransitionViewModel(
+            animationFlow = keyguardTransitionAnimationFlow,
+        )
+    }
\ No newline at end of file
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModelKosmos.kt
index a863edf..a84899e 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardRootViewModelKosmos.kt
@@ -46,10 +46,12 @@
         dozingToGoneTransitionViewModel = dozingToGoneTransitionViewModel,
         dozingToLockscreenTransitionViewModel = dozingToLockscreenTransitionViewModel,
         dozingToOccludedTransitionViewModel = dozingToOccludedTransitionViewModel,
+        dreamingToGoneTransitionViewModel = dreamingToGoneTransitionViewModel,
         dreamingToLockscreenTransitionViewModel = dreamingToLockscreenTransitionViewModel,
         glanceableHubToLockscreenTransitionViewModel = glanceableHubToLockscreenTransitionViewModel,
         goneToAodTransitionViewModel = goneToAodTransitionViewModel,
         goneToDozingTransitionViewModel = goneToDozingTransitionViewModel,
+        goneToDreamingTransitionViewModel = goneToDreamingTransitionViewModel,
         lockscreenToAodTransitionViewModel = lockscreenToAodTransitionViewModel,
         lockscreenToDozingTransitionViewModel = lockscreenToDozingTransitionViewModel,
         lockscreenToDreamingTransitionViewModel = lockscreenToDreamingTransitionViewModel,
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ShadeControllerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ShadeControllerKosmos.kt
index f4acf4d..16c5b72 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ShadeControllerKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ShadeControllerKosmos.kt
@@ -31,6 +31,7 @@
 import com.android.systemui.shade.domain.interactor.shadeInteractor
 import com.android.systemui.statusbar.CommandQueue
 import com.android.systemui.statusbar.NotificationShadeWindowController
+import com.android.systemui.statusbar.VibratorHelper
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager
 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.windowRootViewVisibilityInteractor
@@ -52,6 +53,7 @@
             notificationStackScrollLayout = mock<NotificationStackScrollLayout>(),
             deviceEntryInteractor = deviceEntryInteractor,
             touchLog = mock<LogBuffer>(),
+            vibratorHelper = mock<VibratorHelper>(),
             commandQueue = mock<CommandQueue>(),
             statusBarKeyguardViewManager = mock<StatusBarKeyguardViewManager>(),
             notificationShadeWindowController = mock<NotificationShadeWindowController>(),
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorKosmos.kt
index 546a1e0..5605d10 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractorKosmos.kt
@@ -18,10 +18,12 @@
 
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.shade.domain.interactor.shadeInteractor
 import com.android.systemui.statusbar.notification.stack.data.repository.notificationStackAppearanceRepository
 
 val Kosmos.notificationStackAppearanceInteractor by Fixture {
     NotificationStackAppearanceInteractor(
         repository = notificationStackAppearanceRepository,
+        shadeInteractor = shadeInteractor,
     )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaControllerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaControllerKosmos.kt
new file mode 100644
index 0000000..5db1724
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaControllerKosmos.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume
+
+import android.content.packageManager
+import android.content.pm.ApplicationInfo
+import android.media.AudioAttributes
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+
+private const val LOCAL_PACKAGE = "local.test.pkg"
+var Kosmos.localMediaController: MediaController by
+    Kosmos.Fixture {
+        val appInfo: ApplicationInfo = mock {
+            whenever(loadLabel(any())).thenReturn("local_media_controller_label")
+        }
+        whenever(packageManager.getApplicationInfo(eq(LOCAL_PACKAGE), any<Int>()))
+            .thenReturn(appInfo)
+
+        val localSessionToken: MediaSession.Token = MediaSession.Token(0, mock {})
+        mock {
+            whenever(packageName).thenReturn(LOCAL_PACKAGE)
+            whenever(playbackInfo)
+                .thenReturn(
+                    MediaController.PlaybackInfo(
+                        MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL,
+                        0,
+                        0,
+                        0,
+                        AudioAttributes.Builder().build(),
+                        "",
+                    )
+                )
+            whenever(sessionToken).thenReturn(localSessionToken)
+        }
+    }
+
+private const val REMOTE_PACKAGE = "remote.test.pkg"
+var Kosmos.remoteMediaController: MediaController by
+    Kosmos.Fixture {
+        val appInfo: ApplicationInfo = mock {
+            whenever(loadLabel(any())).thenReturn("remote_media_controller_label")
+        }
+        whenever(packageManager.getApplicationInfo(eq(REMOTE_PACKAGE), any<Int>()))
+            .thenReturn(appInfo)
+
+        val remoteSessionToken: MediaSession.Token = MediaSession.Token(0, mock {})
+        mock {
+            whenever(packageName).thenReturn(REMOTE_PACKAGE)
+            whenever(playbackInfo)
+                .thenReturn(
+                    MediaController.PlaybackInfo(
+                        MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE,
+                        0,
+                        0,
+                        0,
+                        AudioAttributes.Builder().build(),
+                        "",
+                    )
+                )
+            whenever(sessionToken).thenReturn(remoteSessionToken)
+        }
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
index 3938f77..fa3a19b 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
@@ -18,7 +18,6 @@
 
 import android.content.packageManager
 import android.content.pm.ApplicationInfo
-import android.media.session.MediaController
 import android.os.Handler
 import android.testing.TestableLooper
 import com.android.systemui.kosmos.Kosmos
@@ -32,11 +31,10 @@
 import com.android.systemui.volume.data.repository.FakeMediaControllerRepository
 import com.android.systemui.volume.panel.component.mediaoutput.data.repository.FakeLocalMediaRepositoryFactory
 import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaDeviceSessionInteractor
 import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputActionsInteractor
 import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
 
-var Kosmos.mediaController: MediaController by Kosmos.Fixture { mock {} }
-
 val Kosmos.localMediaRepository by Kosmos.Fixture { FakeLocalMediaRepository() }
 val Kosmos.localMediaRepositoryFactory: LocalMediaRepositoryFactory by
     Kosmos.Fixture { FakeLocalMediaRepositoryFactory { localMediaRepository } }
@@ -56,6 +54,14 @@
             },
             testScope.backgroundScope,
             testScope.testScheduler,
+            mediaControllerRepository,
+        )
+    }
+
+val Kosmos.mediaDeviceSessionInteractor by
+    Kosmos.Fixture {
+        MediaDeviceSessionInteractor(
+            testScope.testScheduler,
             Handler(TestableLooper.get(testCase).looper),
             mediaControllerRepository,
         )
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
index 284bd55..909be75 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
@@ -17,7 +17,6 @@
 package com.android.systemui.volume.data.repository
 
 import com.android.settingslib.media.MediaDevice
-import com.android.settingslib.volume.data.model.RoutingSession
 import com.android.settingslib.volume.data.repository.LocalMediaRepository
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
@@ -25,35 +24,11 @@
 
 class FakeLocalMediaRepository : LocalMediaRepository {
 
-    private val volumeBySession: MutableMap<String?, Int> = mutableMapOf()
-
-    private val mutableMediaDevices = MutableStateFlow<List<MediaDevice>>(emptyList())
-    override val mediaDevices: StateFlow<List<MediaDevice>>
-        get() = mutableMediaDevices.asStateFlow()
-
     private val mutableCurrentConnectedDevice = MutableStateFlow<MediaDevice?>(null)
     override val currentConnectedDevice: StateFlow<MediaDevice?>
         get() = mutableCurrentConnectedDevice.asStateFlow()
 
-    private val mutableRemoteRoutingSessions = MutableStateFlow<List<RoutingSession>>(emptyList())
-    override val remoteRoutingSessions: StateFlow<List<RoutingSession>>
-        get() = mutableRemoteRoutingSessions.asStateFlow()
-
-    fun updateMediaDevices(devices: List<MediaDevice>) {
-        mutableMediaDevices.value = devices
-    }
-
     fun updateCurrentConnectedDevice(device: MediaDevice?) {
         mutableCurrentConnectedDevice.value = device
     }
-
-    fun updateRemoteRoutingSessions(sessions: List<RoutingSession>) {
-        mutableRemoteRoutingSessions.value = sessions
-    }
-
-    fun getSessionVolume(sessionId: String?): Int = volumeBySession.getOrDefault(sessionId, 0)
-
-    override suspend fun adjustSessionVolume(sessionId: String?, volume: Int) {
-        volumeBySession[sessionId] = volume
-    }
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
index 6d52e52..8ab5bd90 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
@@ -24,11 +24,11 @@
 
 class FakeMediaControllerRepository : MediaControllerRepository {
 
-    private val mutableActiveLocalMediaController = MutableStateFlow<MediaController?>(null)
-    override val activeLocalMediaController: StateFlow<MediaController?> =
-        mutableActiveLocalMediaController.asStateFlow()
+    private val mutableActiveSessions = MutableStateFlow<List<MediaController>>(emptyList())
+    override val activeSessions: StateFlow<List<MediaController>>
+        get() = mutableActiveSessions.asStateFlow()
 
-    fun setActiveLocalMediaController(controller: MediaController?) {
-        mutableActiveLocalMediaController.value = controller
+    fun setActiveSessions(sessions: List<MediaController>) {
+        mutableActiveSessions.value = sessions
     }
 }
diff --git a/services/core/java/com/android/server/display/AutomaticBrightnessController.java b/services/core/java/com/android/server/display/AutomaticBrightnessController.java
index 4032514..4aab9d2 100644
--- a/services/core/java/com/android/server/display/AutomaticBrightnessController.java
+++ b/services/core/java/com/android/server/display/AutomaticBrightnessController.java
@@ -54,6 +54,7 @@
 import com.android.internal.os.BackgroundThread;
 import com.android.server.EventLogTags;
 import com.android.server.display.brightness.BrightnessEvent;
+import com.android.server.display.brightness.clamper.BrightnessClamperController;
 
 import java.io.PrintWriter;
 import java.lang.annotation.Retention;
@@ -252,6 +253,7 @@
 
     // Controls Brightness range (including High Brightness Mode).
     private final BrightnessRangeController mBrightnessRangeController;
+    private final BrightnessClamperController mBrightnessClamperController;
 
     // Throttles (caps) maximum allowed brightness
     private final BrightnessThrottler mBrightnessThrottler;
@@ -287,7 +289,8 @@
             HysteresisLevels screenBrightnessThresholdsIdle, Context context,
             BrightnessRangeController brightnessModeController,
             BrightnessThrottler brightnessThrottler, int ambientLightHorizonShort,
-            int ambientLightHorizonLong, float userLux, float userNits) {
+            int ambientLightHorizonLong, float userLux, float userNits,
+            BrightnessClamperController brightnessClamperController) {
         this(new Injector(), callbacks, looper, sensorManager, lightSensor,
                 brightnessMappingStrategyMap, lightSensorWarmUpTime, brightnessMin, brightnessMax,
                 dozeScaleFactor, lightSensorRate, initialLightSensorRate,
@@ -297,7 +300,7 @@
                 screenBrightnessThresholds, ambientBrightnessThresholdsIdle,
                 screenBrightnessThresholdsIdle, context, brightnessModeController,
                 brightnessThrottler, ambientLightHorizonShort, ambientLightHorizonLong, userLux,
-                userNits
+                userNits, brightnessClamperController
         );
     }
 
@@ -313,9 +316,10 @@
             HysteresisLevels screenBrightnessThresholds,
             HysteresisLevels ambientBrightnessThresholdsIdle,
             HysteresisLevels screenBrightnessThresholdsIdle, Context context,
-            BrightnessRangeController brightnessModeController,
+            BrightnessRangeController brightnessRangeController,
             BrightnessThrottler brightnessThrottler, int ambientLightHorizonShort,
-            int ambientLightHorizonLong, float userLux, float userNits) {
+            int ambientLightHorizonLong, float userLux, float userNits,
+            BrightnessClamperController brightnessClamperController) {
         mInjector = injector;
         mClock = injector.createClock();
         mContext = context;
@@ -358,7 +362,8 @@
         mPendingForegroundAppPackageName = null;
         mForegroundAppCategory = ApplicationInfo.CATEGORY_UNDEFINED;
         mPendingForegroundAppCategory = ApplicationInfo.CATEGORY_UNDEFINED;
-        mBrightnessRangeController = brightnessModeController;
+        mBrightnessRangeController = brightnessRangeController;
+        mBrightnessClamperController = brightnessClamperController;
         mBrightnessThrottler = brightnessThrottler;
         mBrightnessMappingStrategyMap = brightnessMappingStrategyMap;
 
@@ -791,7 +796,7 @@
                     mAmbientBrightnessThresholds.getDarkeningThreshold(lux);
         }
         mBrightnessRangeController.onAmbientLuxChange(mAmbientLux);
-
+        mBrightnessClamperController.onAmbientLuxChange(mAmbientLux);
 
         // If the short term model was invalidated and the change is drastic enough, reset it.
         mShortTermModel.maybeReset(mAmbientLux);
diff --git a/services/core/java/com/android/server/display/DisplayDeviceConfig.java b/services/core/java/com/android/server/display/DisplayDeviceConfig.java
index 4116669..04e7f77 100644
--- a/services/core/java/com/android/server/display/DisplayDeviceConfig.java
+++ b/services/core/java/com/android/server/display/DisplayDeviceConfig.java
@@ -61,6 +61,7 @@
 import com.android.server.display.config.IdleScreenRefreshRateTimeoutLuxThresholdPoint;
 import com.android.server.display.config.IdleScreenRefreshRateTimeoutLuxThresholds;
 import com.android.server.display.config.IntegerArray;
+import com.android.server.display.config.LowBrightnessData;
 import com.android.server.display.config.LuxThrottling;
 import com.android.server.display.config.NitsMap;
 import com.android.server.display.config.NonNegativeFloatToFloatPoint;
@@ -555,6 +556,24 @@
  *         <majorVersion>2</majorVersion>
  *         <minorVersion>0</minorVersion>
  *     </usiVersion>
+ *     <lowBrightness enabled="true">
+ *       <transitionPoint>0.1</transitionPoint>
+ *
+ *       <nits>0.2</nits>
+ *       <nits>2.0</nits>
+ *       <nits>500.0</nits>
+ *       <nits>1000.0</nits>
+ *
+ *       <backlight>0</backlight>
+ *       <backlight>0.0001</backlight>
+ *       <backlight>0.5</backlight>
+ *       <backlight>1.0</backlight>
+ *
+ *       <brightness>0</brightness>
+ *       <brightness>0.1</brightness>
+ *       <brightness>0.5</brightness>
+ *       <brightness>1.0</brightness>
+ *     </lowBrightness>
  *     <screenBrightnessCapForWearBedtimeMode>0.1</screenBrightnessCapForWearBedtimeMode>
  *     <idleScreenRefreshRateTimeout>
  *          <luxThresholds>
@@ -568,6 +587,8 @@
  *              </point>
  *          </luxThresholds>
  *     </idleScreenRefreshRateTimeout>
+ *
+ *
  *    </displayConfiguration>
  *  }
  *  </pre>
@@ -732,6 +753,7 @@
     private Spline mBacklightToBrightnessSpline;
     private Spline mBacklightToNitsSpline;
     private Spline mNitsToBacklightSpline;
+
     private List<String> mQuirks;
     private boolean mIsHighBrightnessModeEnabled = false;
     private HighBrightnessModeData mHbmData;
@@ -872,6 +894,9 @@
     @Nullable
     private HdrBrightnessData mHdrBrightnessData;
 
+    @Nullable
+    public LowBrightnessData mLowBrightnessData;
+
     /**
      * Maximum screen brightness setting when screen brightness capped in Wear Bedtime mode.
      */
@@ -1814,6 +1839,15 @@
     }
 
     /**
+     *
+     * @return true if low brightness mode is enabled
+     */
+    @VisibleForTesting
+    public boolean getLbmEnabled() {
+        return mLowBrightnessData != null;
+    }
+
+    /**
      * @return Maximum screen brightness setting when screen brightness capped in Wear Bedtime mode.
      */
     public float getBrightnessCapForWearBedtimeMode() {
@@ -1952,6 +1986,8 @@
                 + "mUsiVersion= " + mHostUsiVersion + "\n"
                 + "mHdrBrightnessData= " + mHdrBrightnessData + "\n"
                 + "mBrightnessCapForWearBedtimeMode= " + mBrightnessCapForWearBedtimeMode
+                + "\n"
+                + (mLowBrightnessData != null ? mLowBrightnessData.toString() : "")
                 + "}";
     }
 
@@ -2002,6 +2038,9 @@
                 loadDensityMapping(config);
                 loadBrightnessDefaultFromDdcXml(config);
                 loadBrightnessConstraintsFromConfigXml();
+                if (mFlags.isEvenDimmerEnabled()) {
+                    mLowBrightnessData = LowBrightnessData.loadConfig(config);
+                }
                 loadBrightnessMap(config);
                 loadThermalThrottlingConfig(config);
                 loadPowerThrottlingConfigData(config);
@@ -2793,6 +2832,18 @@
     // These splines are used to convert from the system brightness value to the HAL backlight
     // value
     private void createBacklightConversionSplines() {
+        if (mLowBrightnessData != null) {
+            mBrightnessToBacklightSpline = mLowBrightnessData.mBrightnessToBacklight;
+            mBacklightToBrightnessSpline = mLowBrightnessData.mBacklightToBrightness;
+            mBacklightToNitsSpline = mLowBrightnessData.mBacklightToNits;
+            mNitsToBacklightSpline = mLowBrightnessData.mNitsToBacklight;
+
+            mNits = mLowBrightnessData.mNits;
+            mBrightness = mLowBrightnessData.mBrightness;
+            mBacklight = mLowBrightnessData.mBacklight;
+            return;
+        }
+
         mBrightness = new float[mBacklight.length];
         for (int i = 0; i < mBrightness.length; i++) {
             mBrightness[i] = MathUtils.map(mBacklight[0],
diff --git a/services/core/java/com/android/server/display/DisplayPowerController.java b/services/core/java/com/android/server/display/DisplayPowerController.java
index 87d017c..90ad8c0 100644
--- a/services/core/java/com/android/server/display/DisplayPowerController.java
+++ b/services/core/java/com/android/server/display/DisplayPowerController.java
@@ -1165,7 +1165,8 @@
                     screenBrightnessThresholds, ambientBrightnessThresholdsIdle,
                     screenBrightnessThresholdsIdle, mContext, mBrightnessRangeController,
                     mBrightnessThrottler, mDisplayDeviceConfig.getAmbientHorizonShort(),
-                    mDisplayDeviceConfig.getAmbientHorizonLong(), userLux, userNits);
+                    mDisplayDeviceConfig.getAmbientHorizonLong(), userLux, userNits,
+                    mBrightnessClamperController);
             mDisplayBrightnessController.setAutomaticBrightnessController(
                     mAutomaticBrightnessController);
 
@@ -2479,6 +2480,7 @@
     public void setBrightnessToFollow(float leadDisplayBrightness, float nits, float ambientLux,
             boolean slowChange) {
         mBrightnessRangeController.onAmbientLuxChange(ambientLux);
+        mBrightnessClamperController.onAmbientLuxChange(ambientLux);
         if (nits == BrightnessMappingStrategy.INVALID_NITS) {
             mDisplayBrightnessController.setBrightnessToFollow(leadDisplayBrightness, slowChange);
         } else {
@@ -3176,7 +3178,9 @@
                 HysteresisLevels screenBrightnessThresholdsIdle, Context context,
                 BrightnessRangeController brightnessModeController,
                 BrightnessThrottler brightnessThrottler, int ambientLightHorizonShort,
-                int ambientLightHorizonLong, float userLux, float userNits) {
+                int ambientLightHorizonLong, float userLux, float userNits,
+                BrightnessClamperController brightnessClamperController) {
+
             return new AutomaticBrightnessController(callbacks, looper, sensorManager, lightSensor,
                     brightnessMappingStrategyMap, lightSensorWarmUpTime, brightnessMin,
                     brightnessMax, dozeScaleFactor, lightSensorRate, initialLightSensorRate,
@@ -3186,7 +3190,7 @@
                     screenBrightnessThresholds, ambientBrightnessThresholdsIdle,
                     screenBrightnessThresholdsIdle, context, brightnessModeController,
                     brightnessThrottler, ambientLightHorizonShort, ambientLightHorizonLong, userLux,
-                    userNits);
+                    userNits, brightnessClamperController);
         }
 
         BrightnessMappingStrategy getDefaultModeBrightnessMapper(Context context,
diff --git a/services/core/java/com/android/server/display/LocalDisplayAdapter.java b/services/core/java/com/android/server/display/LocalDisplayAdapter.java
index b2fd9ed..3b3a03b 100644
--- a/services/core/java/com/android/server/display/LocalDisplayAdapter.java
+++ b/services/core/java/com/android/server/display/LocalDisplayAdapter.java
@@ -37,6 +37,7 @@
 import android.os.Trace;
 import android.util.DisplayUtils;
 import android.util.LongSparseArray;
+import android.util.MathUtils;
 import android.util.Slog;
 import android.util.SparseArray;
 import android.view.Display;
@@ -78,6 +79,13 @@
     private static final String UNIQUE_ID_PREFIX = "local:";
 
     private static final String PROPERTY_EMULATOR_CIRCULAR = "ro.boot.emulator.circular";
+    // Min and max strengths for even dimmer feature.
+    private static final float EVEN_DIMMER_MIN_STRENGTH = 0.0f;
+    private static final float EVEN_DIMMER_MAX_STRENGTH = 70.0f; // not too dim yet.
+    private static final float BRIGHTNESS_MIN = 0.0f;
+    // The brightness at which we start using color matrices rather than backlight,
+    // to dim the display
+    private static final float BACKLIGHT_COLOR_TRANSITION_POINT = 0.1f;
 
     private final LongSparseArray<LocalDisplayDevice> mDevices = new LongSparseArray<>();
 
@@ -91,6 +99,8 @@
 
     private Context mOverlayContext;
 
+    private int mEvenDimmerStrength = -1;
+
     // Called with SyncRoot lock held.
     LocalDisplayAdapter(DisplayManagerService.SyncRoot syncRoot, Context context,
             Handler handler, Listener listener, DisplayManagerFlags flags,
@@ -928,6 +938,10 @@
                             final float nits = backlightToNits(backlight);
                             final float sdrNits = backlightToNits(sdrBacklight);
 
+                            if (getFeatureFlags().isEvenDimmerEnabled()) {
+                                applyColorMatrixBasedDimming(brightnessState);
+                            }
+
                             mBacklightAdapter.setBacklight(sdrBacklight, sdrNits, backlight, nits);
                             Trace.traceCounter(Trace.TRACE_TAG_POWER,
                                     "ScreenBrightness",
@@ -974,6 +988,22 @@
                             }
                         }
                     }
+
+                    private void applyColorMatrixBasedDimming(float brightnessState) {
+                        int strength = (int) (MathUtils.constrainedMap(
+                                EVEN_DIMMER_MAX_STRENGTH, EVEN_DIMMER_MIN_STRENGTH, // to this range
+                                BRIGHTNESS_MIN, BACKLIGHT_COLOR_TRANSITION_POINT, // from this range
+                                brightnessState) + 0.5); // map this (+ rounded up)
+
+                        if (mEvenDimmerStrength < 0 // uninitialised
+                                || MathUtils.abs(mEvenDimmerStrength - strength) > 1
+                                || strength <= 1) {
+                            mEvenDimmerStrength = strength;
+                        }
+
+                        // TODO: use `enabled` and `mRbcStrength` to set color matrices here
+                        // TODO: boolean enabled = mEvenDimmerStrength > 0.0f;
+                    }
                 };
             }
             return null;
diff --git a/services/core/java/com/android/server/display/brightness/clamper/BrightnessClamperController.java b/services/core/java/com/android/server/display/brightness/clamper/BrightnessClamperController.java
index 18e8fab..d8a4500 100644
--- a/services/core/java/com/android/server/display/brightness/clamper/BrightnessClamperController.java
+++ b/services/core/java/com/android/server/display/brightness/clamper/BrightnessClamperController.java
@@ -189,6 +189,13 @@
         mModifiers.forEach(BrightnessStateModifier::stop);
     }
 
+    /**
+     * Notifies modifiers that ambient lux has changed.
+     * @param ambientLux current lux, debounced
+     */
+    public void onAmbientLuxChange(float ambientLux) {
+        mModifiers.forEach(modifier -> modifier.onAmbientLuxChange(ambientLux));
+    }
 
     // Called in DisplayControllerHandler
     private void recalculateBrightnessCap() {
diff --git a/services/core/java/com/android/server/display/brightness/clamper/BrightnessLowLuxModifier.java b/services/core/java/com/android/server/display/brightness/clamper/BrightnessLowLuxModifier.java
index 7f1f7a9..a91bb59 100644
--- a/services/core/java/com/android/server/display/brightness/clamper/BrightnessLowLuxModifier.java
+++ b/services/core/java/com/android/server/display/brightness/clamper/BrightnessLowLuxModifier.java
@@ -39,21 +39,21 @@
  * Class used to prevent the screen brightness dipping below a certain value, based on current
  * lux conditions and user preferred minimum.
  */
-public class BrightnessLowLuxModifier implements
-        BrightnessStateModifier {
+public class BrightnessLowLuxModifier extends BrightnessModifier {
 
     // To enable these logs, run:
     // 'adb shell setprop persist.log.tag.BrightnessLowLuxModifier DEBUG && adb reboot'
     private static final String TAG = "BrightnessLowLuxModifier";
     private static final boolean DEBUG = DebugUtils.isDebuggable(TAG);
+    private static final float MIN_NITS = 2.0f;
     private final SettingsObserver mSettingsObserver;
     private final ContentResolver mContentResolver;
     private final Handler mHandler;
     private final BrightnessClamperController.ClamperChangeListener mChangeListener;
-    protected float mSettingNitsLowerBound = PowerManager.BRIGHTNESS_MIN;
     private int mReason;
     private float mBrightnessLowerBound;
     private boolean mIsActive;
+    private float mAmbientLux;
 
     @VisibleForTesting
     BrightnessLowLuxModifier(Handler handler,
@@ -78,17 +78,17 @@
         int userId = UserHandle.USER_CURRENT;
         float settingNitsLowerBound = Settings.Secure.getFloatForUser(
                 mContentResolver, Settings.Secure.EVEN_DIMMER_MIN_NITS,
-                /* def= */ PowerManager.BRIGHTNESS_MIN, userId);
+                /* def= */ MIN_NITS, userId);
 
-        boolean isActive = Settings.Secure.getIntForUser(mContentResolver,
+        boolean isActive = Settings.Secure.getFloatForUser(mContentResolver,
                 Settings.Secure.EVEN_DIMMER_ACTIVATED,
-                /* def= */ 0, userId) == 1;
+                /* def= */ 0, userId) == 1.0f;
 
-        // TODO: luxBasedNitsLowerBound = mMinNitsToLuxSpline(currentLux);
-        float luxBasedNitsLowerBound = 0.0f;
+        // TODO: luxBasedNitsLowerBound = mMinLuxToNitsSpline(currentLux);
+        float luxBasedNitsLowerBound = 2.0f;
 
-        // TODO: final float nitsLowerBound = isActive ? Math.max(settingNitsLowerBound,
-                // luxBasedNitsLowerBound) : PowerManager.BRIGHTNESS_MIN;
+        final float nitsLowerBound = isActive ? Math.max(settingNitsLowerBound,
+                 luxBasedNitsLowerBound) : MIN_NITS;
 
         final int reason = settingNitsLowerBound > luxBasedNitsLowerBound
                 ? BrightnessReason.MODIFIER_MIN_USER_SET_LOWER_BOUND
@@ -104,8 +104,13 @@
             mReason = reason;
             if (DEBUG) {
                 Slog.i(TAG, "isActive: " + isActive
-                        + ", settingNitsLowerBound: " + settingNitsLowerBound
-                        + ", lowerBound: " + brightnessLowerBound);
+                        + ", brightnessLowerBound: " + brightnessLowerBound
+                        + ", mAmbientLux: " + mAmbientLux
+                        + ", mReason: " + (
+                        mReason == BrightnessReason.MODIFIER_MIN_USER_SET_LOWER_BOUND ? "minSetting"
+                                : "lux")
+                        + ", nitsLowerBound: " + nitsLowerBound
+                );
             }
             mBrightnessLowerBound = brightnessLowerBound;
             mChangeListener.onChanged();
@@ -132,6 +137,22 @@
     }
 
     @Override
+    boolean shouldApply(DisplayManagerInternal.DisplayPowerRequest request) {
+        return mIsActive;
+    }
+
+    @Override
+    float getBrightnessAdjusted(float currentBrightness,
+            DisplayManagerInternal.DisplayPowerRequest request) {
+        return Math.max(mBrightnessLowerBound, currentBrightness);
+    }
+
+    @Override
+    int getModifier() {
+        return mReason;
+    }
+
+    @Override
     public void apply(DisplayManagerInternal.DisplayPowerRequest request,
             DisplayBrightnessState.Builder stateBuilder) {
         stateBuilder.setMinBrightness(mBrightnessLowerBound);
@@ -150,10 +171,16 @@
     }
 
     @Override
+    public void onAmbientLuxChange(float ambientLux) {
+        mAmbientLux = ambientLux;
+        recalculateLowerBound();
+    }
+
+    @Override
     public void dump(PrintWriter pw) {
         pw.println("BrightnessLowLuxModifier:");
-        pw.println("  mBrightnessLowerBound=" + mBrightnessLowerBound);
         pw.println("  mIsActive=" + mIsActive);
+        pw.println("  mBrightnessLowerBound=" + mBrightnessLowerBound);
         pw.println("  mReason=" + mReason);
     }
 
diff --git a/services/core/java/com/android/server/display/brightness/clamper/BrightnessModifier.java b/services/core/java/com/android/server/display/brightness/clamper/BrightnessModifier.java
index be8fa5a..2a3dd87 100644
--- a/services/core/java/com/android/server/display/brightness/clamper/BrightnessModifier.java
+++ b/services/core/java/com/android/server/display/brightness/clamper/BrightnessModifier.java
@@ -68,4 +68,9 @@
     public void stop() {
         // do nothing
     }
+
+    @Override
+    public void onAmbientLuxChange(float ambientLux) {
+        // do nothing
+    }
 }
diff --git a/services/core/java/com/android/server/display/brightness/clamper/BrightnessStateModifier.java b/services/core/java/com/android/server/display/brightness/clamper/BrightnessStateModifier.java
index 441ba8f..2234258 100644
--- a/services/core/java/com/android/server/display/brightness/clamper/BrightnessStateModifier.java
+++ b/services/core/java/com/android/server/display/brightness/clamper/BrightnessStateModifier.java
@@ -42,4 +42,10 @@
      * Called when stopped. Listeners can be unregistered here.
      */
     void stop();
+
+    /**
+     * Allows modifiers to react to ambient lux changes.
+     * @param ambientLux current debounced lux.
+     */
+    void onAmbientLuxChange(float ambientLux);
 }
diff --git a/services/core/java/com/android/server/display/config/LowBrightnessData.java b/services/core/java/com/android/server/display/config/LowBrightnessData.java
new file mode 100644
index 0000000..aa82533
--- /dev/null
+++ b/services/core/java/com/android/server/display/config/LowBrightnessData.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.display.config;
+
+import android.annotation.Nullable;
+import android.util.Slog;
+import android.util.Spline;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Brightness config for low brightness mode
+ */
+public class LowBrightnessData {
+    private static final String TAG = "LowBrightnessData";
+
+    /**
+     * Brightness value at which lower brightness methods are used.
+     */
+    public final float mTransitionPoint;
+
+    /**
+     * Nits array, maps to mBacklight
+     */
+    public final float[] mNits;
+
+    /**
+     * Backlight array, maps to mBrightness and mNits
+     */
+    public final float[] mBacklight;
+
+    /**
+     * Brightness array, maps to mBacklight
+     */
+    public final float[] mBrightness;
+    /**
+     * Spline, mapping between backlight and nits
+     */
+    public final Spline mBacklightToNits;
+    /**
+     * Spline, mapping between nits and backlight
+     */
+    public final Spline mNitsToBacklight;
+    /**
+     * Spline, mapping between brightness and backlight
+     */
+    public final Spline mBrightnessToBacklight;
+    /**
+     * Spline, mapping between backlight and brightness
+     */
+    public final Spline mBacklightToBrightness;
+
+    @VisibleForTesting
+    public LowBrightnessData(float transitionPoint, float[] nits,
+            float[] backlight, float[] brightness, Spline backlightToNits,
+            Spline nitsToBacklight, Spline brightnessToBacklight, Spline backlightToBrightness) {
+        mTransitionPoint = transitionPoint;
+        mNits = nits;
+        mBacklight = backlight;
+        mBrightness = brightness;
+        mBacklightToNits = backlightToNits;
+        mNitsToBacklight = nitsToBacklight;
+        mBrightnessToBacklight = brightnessToBacklight;
+        mBacklightToBrightness = backlightToBrightness;
+    }
+
+    @Override
+    public String toString() {
+        return "LowBrightnessData {"
+                + "mTransitionPoint: " + mTransitionPoint
+                + ", mNits: " + Arrays.toString(mNits)
+                + ", mBacklight: " + Arrays.toString(mBacklight)
+                + ", mBrightness: " + Arrays.toString(mBrightness)
+                + ", mBacklightToNits: " + mBacklightToNits
+                + ", mNitsToBacklight: " + mNitsToBacklight
+                + ", mBrightnessToBacklight: " + mBrightnessToBacklight
+                + ", mBacklightToBrightness: " + mBacklightToBrightness
+                + "} ";
+    }
+
+    /**
+     * Loads LowBrightnessData from DisplayConfiguration
+     */
+    @Nullable
+    public static LowBrightnessData loadConfig(DisplayConfiguration config) {
+        final LowBrightnessMode lbm = config.getLowBrightness();
+        if (lbm == null) {
+            return null;
+        }
+
+        boolean lbmIsEnabled = lbm.getEnabled();
+        if (!lbmIsEnabled) {
+            return null;
+        }
+
+        List<Float> nitsList = lbm.getNits();
+        List<Float> backlightList = lbm.getBacklight();
+        List<Float> brightnessList = lbm.getBrightness();
+        float transitionPoints = lbm.getTransitionPoint().floatValue();
+
+        if (nitsList.isEmpty()
+                || backlightList.size() != brightnessList.size()
+                || backlightList.size() != nitsList.size()) {
+            Slog.e(TAG, "Invalid low brightness array lengths");
+            return null;
+        }
+
+        float[] nits = new float[nitsList.size()];
+        float[] backlight = new float[nitsList.size()];
+        float[] brightness = new float[nitsList.size()];
+
+        for (int i = 0; i < nitsList.size(); i++) {
+            nits[i] = nitsList.get(i);
+            backlight[i] = backlightList.get(i);
+            brightness[i] = brightnessList.get(i);
+        }
+
+        return new LowBrightnessData(transitionPoints, nits, backlight, brightness,
+                Spline.createSpline(backlight, nits),
+                Spline.createSpline(nits, backlight),
+                Spline.createSpline(brightness, backlight),
+                Spline.createSpline(backlight, brightness)
+                );
+    }
+}
diff --git a/services/core/java/com/android/server/input/KeyboardLayoutManager.java b/services/core/java/com/android/server/input/KeyboardLayoutManager.java
index 283e692..6610081 100644
--- a/services/core/java/com/android/server/input/KeyboardLayoutManager.java
+++ b/services/core/java/com/android/server/input/KeyboardLayoutManager.java
@@ -459,13 +459,16 @@
         for (ResolveInfo resolveInfo : pm.queryBroadcastReceiversAsUser(intent,
                 PackageManager.GET_META_DATA | PackageManager.MATCH_DIRECT_BOOT_AWARE
                         | PackageManager.MATCH_DIRECT_BOOT_UNAWARE, UserHandle.USER_SYSTEM)) {
+            if (resolveInfo == null || resolveInfo.activityInfo == null) {
+                continue;
+            }
             final ActivityInfo activityInfo = resolveInfo.activityInfo;
             final int priority = resolveInfo.priority;
             visitKeyboardLayoutsInPackage(pm, activityInfo, null, priority, visitor);
         }
     }
 
-    private void visitKeyboardLayout(String keyboardLayoutDescriptor,
+    private void visitKeyboardLayout(@NonNull String keyboardLayoutDescriptor,
             KeyboardLayoutVisitor visitor) {
         KeyboardLayoutDescriptor d = KeyboardLayoutDescriptor.parse(keyboardLayoutDescriptor);
         if (d != null) {
@@ -482,8 +485,8 @@
         }
     }
 
-    private void visitKeyboardLayoutsInPackage(PackageManager pm, ActivityInfo receiver,
-            String keyboardName, int requestedPriority, KeyboardLayoutVisitor visitor) {
+    private void visitKeyboardLayoutsInPackage(PackageManager pm, @NonNull ActivityInfo receiver,
+            @Nullable String keyboardName, int requestedPriority, KeyboardLayoutVisitor visitor) {
         Bundle metaData = receiver.metaData;
         if (metaData == null) {
             return;
@@ -1415,7 +1418,7 @@
             return packageName + "/" + receiverName + "/" + keyboardName;
         }
 
-        public static KeyboardLayoutDescriptor parse(String descriptor) {
+        public static KeyboardLayoutDescriptor parse(@NonNull String descriptor) {
             int pos = descriptor.indexOf('/');
             if (pos < 0 || pos + 1 == descriptor.length()) {
                 return null;
diff --git a/services/core/java/com/android/server/notification/PreferencesHelper.java b/services/core/java/com/android/server/notification/PreferencesHelper.java
index 4f3cdbc..50ca984 100644
--- a/services/core/java/com/android/server/notification/PreferencesHelper.java
+++ b/services/core/java/com/android/server/notification/PreferencesHelper.java
@@ -310,6 +310,7 @@
                     parser.getAttributeInt(null, ATT_VISIBILITY, DEFAULT_VISIBILITY),
                     parser.getAttributeBoolean(null, ATT_SHOW_BADGE, DEFAULT_SHOW_BADGE),
                     bubblePref);
+            r.bubblePreference = bubblePref;
             r.priority = parser.getAttributeInt(null, ATT_PRIORITY, DEFAULT_PRIORITY);
             r.visibility = parser.getAttributeInt(null, ATT_VISIBILITY, DEFAULT_VISIBILITY);
             r.showBadge = parser.getAttributeBoolean(null, ATT_SHOW_BADGE, DEFAULT_SHOW_BADGE);
@@ -676,7 +677,7 @@
      * @param bubblePreference whether bubbles are allowed.
      */
     public void setBubblesAllowed(String pkg, int uid, int bubblePreference) {
-        boolean changed = false;
+        boolean changed;
         synchronized (mPackagePreferences) {
             PackagePreferences p = getOrCreatePackagePreferencesLocked(pkg, uid);
             changed = p.bubblePreference != bubblePreference;
diff --git a/services/core/java/com/android/server/pm/LauncherAppsService.java b/services/core/java/com/android/server/pm/LauncherAppsService.java
index c6bb99e..20b669b 100644
--- a/services/core/java/com/android/server/pm/LauncherAppsService.java
+++ b/services/core/java/com/android/server/pm/LauncherAppsService.java
@@ -18,12 +18,12 @@
 
 import static android.Manifest.permission.READ_FRAME_BUFFER;
 import static android.app.ActivityOptions.KEY_SPLASH_SCREEN_THEME;
+import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED;
+import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_SYSTEM_DEFINED;
 import static android.app.AppOpsManager.MODE_ALLOWED;
 import static android.app.AppOpsManager.MODE_IGNORED;
 import static android.app.AppOpsManager.OP_ARCHIVE_ICON_OVERLAY;
 import static android.app.AppOpsManager.OP_UNARCHIVAL_CONFIRMATION;
-import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED;
-import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_SYSTEM_DEFINED;
 import static android.app.PendingIntent.FLAG_IMMUTABLE;
 import static android.app.PendingIntent.FLAG_MUTABLE;
 import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
@@ -555,12 +555,6 @@
                     return false;
                 }
 
-                if (!mRoleManager
-                        .getRoleHoldersAsUser(
-                                RoleManager.ROLE_HOME, UserHandle.getUserHandleForUid(callingUid))
-                        .contains(callingPackage.getPackageName())) {
-                    return false;
-                }
                 if (mContext.checkPermission(
                                 Manifest.permission.ACCESS_HIDDEN_PROFILES_FULL,
                                 callingPid,
@@ -569,6 +563,13 @@
                     return true;
                 }
 
+                if (!mRoleManager
+                        .getRoleHoldersAsUser(
+                                RoleManager.ROLE_HOME, UserHandle.getUserHandleForUid(callingUid))
+                        .contains(callingPackage.getPackageName())) {
+                    return false;
+                }
+
                 // TODO(b/321988638): add option to disable with a flag
                 return mContext.checkPermission(
                                 android.Manifest.permission.ACCESS_HIDDEN_PROFILES,
diff --git a/services/core/java/com/android/server/pm/RemovePackageHelper.java b/services/core/java/com/android/server/pm/RemovePackageHelper.java
index 6b12781..3a0f7fb 100644
--- a/services/core/java/com/android/server/pm/RemovePackageHelper.java
+++ b/services/core/java/com/android/server/pm/RemovePackageHelper.java
@@ -261,11 +261,6 @@
         // Step 1: always destroy app profiles.
         mAppDataHelper.destroyAppProfilesLIF(packageName);
 
-        // Everything else is preserved if the DELETE_KEEP_DATA flag is on
-        if ((flags & PackageManager.DELETE_KEEP_DATA) != 0) {
-            return;
-        }
-
         final AndroidPackage pkg;
         final SharedUserSetting sus;
         synchronized (mPm.mLock) {
@@ -282,9 +277,20 @@
             resolvedPkg = PackageImpl.buildFakeForDeletion(packageName, ps.getVolumeUuid());
         }
 
+        int appDataDeletionFlags = FLAG_STORAGE_DE | FLAG_STORAGE_CE | FLAG_STORAGE_EXTERNAL;
+        // Personal data is preserved if the DELETE_KEEP_DATA flag is on
+        if ((flags & PackageManager.DELETE_KEEP_DATA) != 0) {
+            if ((flags & PackageManager.DELETE_ARCHIVE) != 0) {
+                mAppDataHelper.clearAppDataLIF(resolvedPkg, userId,
+                        appDataDeletionFlags | Installer.FLAG_CLEAR_CACHE_ONLY);
+                mAppDataHelper.clearAppDataLIF(resolvedPkg, userId,
+                        appDataDeletionFlags | Installer.FLAG_CLEAR_CODE_CACHE_ONLY);
+            }
+            return;
+        }
+
         // Step 2: destroy app data.
-        mAppDataHelper.destroyAppDataLIF(resolvedPkg, userId,
-                FLAG_STORAGE_DE | FLAG_STORAGE_CE | FLAG_STORAGE_EXTERNAL);
+        mAppDataHelper.destroyAppDataLIF(resolvedPkg, userId, appDataDeletionFlags);
         if (userId != UserHandle.USER_ALL) {
             ps.setCeDataInode(-1, userId);
             ps.setDeDataInode(-1, userId);
diff --git a/services/core/java/com/android/server/policy/PhoneWindowManager.java b/services/core/java/com/android/server/policy/PhoneWindowManager.java
index 98faba1..12a5892 100644
--- a/services/core/java/com/android/server/policy/PhoneWindowManager.java
+++ b/services/core/java/com/android/server/policy/PhoneWindowManager.java
@@ -1905,6 +1905,7 @@
             accessibilityManager.performSystemAction(
                     AccessibilityService.GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS);
         }
+        dismissKeyboardShortcutsMenu();
     }
 
     private void toggleNotificationPanel() {
@@ -3478,13 +3479,6 @@
                     return true;
                 }
                 break;
-            case KeyEvent.KEYCODE_T:
-                if (firstDown && event.isMetaPressed()) {
-                    toggleTaskbar();
-                    logKeyboardSystemsEvent(event, KeyboardLogEvent.TOGGLE_TASKBAR);
-                    return true;
-                }
-                break;
             case KeyEvent.KEYCODE_DEL:
             case KeyEvent.KEYCODE_ESCAPE:
                 if (firstDown && event.isMetaPressed()) {
diff --git a/services/core/xsd/display-device-config/display-device-config.xsd b/services/core/xsd/display-device-config/display-device-config.xsd
index d0df2b2..1f54518 100644
--- a/services/core/xsd/display-device-config/display-device-config.xsd
+++ b/services/core/xsd/display-device-config/display-device-config.xsd
@@ -162,6 +162,10 @@
                 <xs:element type="usiVersion" name="usiVersion">
                     <xs:annotation name="final"/>
                 </xs:element>
+                <xs:element type="lowBrightnessMode" name="lowBrightness">
+                    <xs:attribute name="enabled" type="xs:boolean" use="optional"/>
+                    <xs:annotation name="final"/>
+                </xs:element>
                 <!-- Maximum screen brightness setting when screen brightness capped in
                 Wear Bedtime mode. This must be a non-negative decimal within the range defined by
                 the first and the last brightness value in screenBrightnessMap. -->
@@ -172,6 +176,7 @@
                 <xs:element type="idleScreenRefreshRateTimeout" name="idleScreenRefreshRateTimeout" minOccurs="0">
                     <xs:annotation name="final"/>
                 </xs:element>
+
             </xs:sequence>
         </xs:complexType>
     </xs:element>
@@ -216,6 +221,21 @@
         </xs:restriction>
     </xs:simpleType>
 
+    <xs:complexType name="lowBrightnessMode">
+        <xs:sequence>
+            <xs:element name="transitionPoint" type="nonNegativeDecimal" minOccurs="1"
+                maxOccurs="1">
+            </xs:element>
+            <xs:element name="nits" type="xs:float" maxOccurs="unbounded">
+            </xs:element>
+            <xs:element name="backlight" type="xs:float" maxOccurs="unbounded">
+            </xs:element>
+            <xs:element name="brightness" type="xs:float" maxOccurs="unbounded">
+            </xs:element>
+        </xs:sequence>
+        <xs:attribute name="enabled" type="xs:boolean" use="optional"/>
+    </xs:complexType>
+
     <xs:complexType name="highBrightnessMode">
         <xs:all>
             <xs:element name="transitionPoint" type="nonNegativeDecimal" minOccurs="1"
diff --git a/services/core/xsd/display-device-config/schema/current.txt b/services/core/xsd/display-device-config/schema/current.txt
index 00dc908..c39c3d7 100644
--- a/services/core/xsd/display-device-config/schema/current.txt
+++ b/services/core/xsd/display-device-config/schema/current.txt
@@ -113,6 +113,7 @@
     method public com.android.server.display.config.HighBrightnessMode getHighBrightnessMode();
     method public final com.android.server.display.config.IdleScreenRefreshRateTimeout getIdleScreenRefreshRateTimeout();
     method public final com.android.server.display.config.SensorDetails getLightSensor();
+    method public final com.android.server.display.config.LowBrightnessMode getLowBrightness();
     method public com.android.server.display.config.LuxThrottling getLuxThrottling();
     method @Nullable public final String getName();
     method public com.android.server.display.config.PowerThrottlingConfig getPowerThrottlingConfig();
@@ -149,6 +150,7 @@
     method public void setHighBrightnessMode(com.android.server.display.config.HighBrightnessMode);
     method public final void setIdleScreenRefreshRateTimeout(com.android.server.display.config.IdleScreenRefreshRateTimeout);
     method public final void setLightSensor(com.android.server.display.config.SensorDetails);
+    method public final void setLowBrightness(com.android.server.display.config.LowBrightnessMode);
     method public void setLuxThrottling(com.android.server.display.config.LuxThrottling);
     method public final void setName(@Nullable String);
     method public void setPowerThrottlingConfig(com.android.server.display.config.PowerThrottlingConfig);
@@ -248,6 +250,17 @@
     method public java.util.List<java.math.BigInteger> getItem();
   }
 
+  public class LowBrightnessMode {
+    ctor public LowBrightnessMode();
+    method public java.util.List<java.lang.Float> getBacklight();
+    method public java.util.List<java.lang.Float> getBrightness();
+    method public boolean getEnabled();
+    method public java.util.List<java.lang.Float> getNits();
+    method public java.math.BigDecimal getTransitionPoint();
+    method public void setEnabled(boolean);
+    method public void setTransitionPoint(java.math.BigDecimal);
+  }
+
   public class LuxThrottling {
     ctor public LuxThrottling();
     method @NonNull public final java.util.List<com.android.server.display.config.BrightnessLimitMap> getBrightnessLimitMap();
diff --git a/services/devicepolicy/Android.bp b/services/devicepolicy/Android.bp
index 8dfa685..da965bb 100644
--- a/services/devicepolicy/Android.bp
+++ b/services/devicepolicy/Android.bp
@@ -24,5 +24,6 @@
         "app-compat-annotations",
         "service-permission.stubs.system_server",
         "device_policy_aconfig_flags_lib",
+        "androidx.annotation_annotation",
     ],
 }
diff --git a/services/devicepolicy/java/com/android/server/devicepolicy/OverlayPackagesProvider.java b/services/devicepolicy/java/com/android/server/devicepolicy/OverlayPackagesProvider.java
index f3b164c..94c1374 100644
--- a/services/devicepolicy/java/com/android/server/devicepolicy/OverlayPackagesProvider.java
+++ b/services/devicepolicy/java/com/android/server/devicepolicy/OverlayPackagesProvider.java
@@ -25,15 +25,16 @@
 import static android.content.pm.PackageManager.GET_META_DATA;
 
 import static com.android.internal.util.Preconditions.checkArgument;
-import static com.android.internal.util.Preconditions.checkNotNull;
-import static com.android.server.devicepolicy.DevicePolicyManagerService.dumpResources;
+import static com.android.server.devicepolicy.DevicePolicyManagerService.dumpApps;
 
 import static java.util.Objects.requireNonNull;
 
+import android.annotation.ArrayRes;
 import android.annotation.NonNull;
 import android.annotation.UserIdInt;
 import android.app.admin.DeviceAdminReceiver;
 import android.app.admin.DevicePolicyManager;
+import android.app.admin.flags.Flags;
 import android.app.role.RoleManager;
 import android.content.ComponentName;
 import android.content.Context;
@@ -67,13 +68,16 @@
 
     protected static final String TAG = "OverlayPackagesProvider";
     private static final Map<String, String> sActionToMetadataKeyMap = new HashMap<>();
-    {
+
+    static {
         sActionToMetadataKeyMap.put(ACTION_PROVISION_MANAGED_USER, REQUIRED_APP_MANAGED_USER);
         sActionToMetadataKeyMap.put(ACTION_PROVISION_MANAGED_PROFILE, REQUIRED_APP_MANAGED_PROFILE);
         sActionToMetadataKeyMap.put(ACTION_PROVISION_MANAGED_DEVICE, REQUIRED_APP_MANAGED_DEVICE);
     }
+
     private static final Set<String> sAllowedActions = new HashSet<>();
-    {
+
+    static {
         sAllowedActions.add(ACTION_PROVISION_MANAGED_USER);
         sAllowedActions.add(ACTION_PROVISION_MANAGED_PROFILE);
         sAllowedActions.add(ACTION_PROVISION_MANAGED_DEVICE);
@@ -83,8 +87,13 @@
     private final Context mContext;
     private final Injector mInjector;
 
+    private final RecursiveStringArrayResourceResolver mRecursiveStringArrayResourceResolver;
+
     public OverlayPackagesProvider(Context context) {
-        this(context, new DefaultInjector());
+        this(
+                context,
+                new DefaultInjector(),
+                new RecursiveStringArrayResourceResolver(context.getResources()));
     }
 
     @VisibleForTesting
@@ -113,8 +122,8 @@
         public String getDevicePolicyManagementRoleHolderPackageName(Context context) {
             return Binder.withCleanCallingIdentity(() -> {
                 RoleManager roleManager = context.getSystemService(RoleManager.class);
-                List<String> roleHolders =
-                        roleManager.getRoleHolders(RoleManager.ROLE_DEVICE_POLICY_MANAGEMENT);
+                List<String> roleHolders = roleManager.getRoleHolders(
+                        RoleManager.ROLE_DEVICE_POLICY_MANAGEMENT);
                 if (roleHolders.isEmpty()) {
                     return null;
                 }
@@ -124,17 +133,20 @@
     }
 
     @VisibleForTesting
-    OverlayPackagesProvider(Context context, Injector injector) {
+    OverlayPackagesProvider(Context context, Injector injector,
+            RecursiveStringArrayResourceResolver recursiveStringArrayResourceResolver) {
         mContext = context;
-        mPm = checkNotNull(context.getPackageManager());
-        mInjector = checkNotNull(injector);
+        mPm = requireNonNull(context.getPackageManager());
+        mInjector = requireNonNull(injector);
+        mRecursiveStringArrayResourceResolver = requireNonNull(
+                recursiveStringArrayResourceResolver);
     }
 
     /**
      * Computes non-required apps. All the system apps with a launcher that are not in
      * the required set of packages, and all mainline modules that are not declared as required
      * via metadata in their manifests, will be considered as non-required apps.
-     *
+     * <p>
      * Note: If an app is mistakenly listed as both required and disallowed, it will be treated as
      * disallowed.
      *
@@ -176,12 +188,12 @@
     /**
      * Returns a subset of {@code packageNames} whose packages are mainline modules declared as
      * required apps via their app metadata.
+     *
      * @see DevicePolicyManager#REQUIRED_APP_MANAGED_USER
      * @see DevicePolicyManager#REQUIRED_APP_MANAGED_DEVICE
      * @see DevicePolicyManager#REQUIRED_APP_MANAGED_PROFILE
      */
-    private Set<String> getRequiredAppsMainlineModules(
-            Set<String> packageNames,
+    private Set<String> getRequiredAppsMainlineModules(Set<String> packageNames,
             String provisioningAction) {
         final Set<String> result = new HashSet<>();
         for (String packageName : packageNames) {
@@ -225,8 +237,8 @@
     }
 
     private boolean isApkInApexMainlineModule(String packageName) {
-        final String apexPackageName =
-                mInjector.getActiveApexPackageNameContainingPackage(packageName);
+        final String apexPackageName = mInjector.getActiveApexPackageNameContainingPackage(
+                packageName);
         return apexPackageName != null;
     }
 
@@ -274,112 +286,94 @@
     }
 
     private Set<String> getRequiredAppsSet(String provisioningAction) {
-        final int resId;
-        switch (provisioningAction) {
-            case ACTION_PROVISION_MANAGED_USER:
-                resId = R.array.required_apps_managed_user;
-                break;
-            case ACTION_PROVISION_MANAGED_PROFILE:
-                resId = R.array.required_apps_managed_profile;
-                break;
-            case ACTION_PROVISION_MANAGED_DEVICE:
-                resId = R.array.required_apps_managed_device;
-                break;
-            default:
-                throw new IllegalArgumentException("Provisioning type "
-                        + provisioningAction + " not supported.");
-        }
-        return new ArraySet<>(Arrays.asList(mContext.getResources().getStringArray(resId)));
+        final int resId = switch (provisioningAction) {
+            case ACTION_PROVISION_MANAGED_USER -> R.array.required_apps_managed_user;
+            case ACTION_PROVISION_MANAGED_PROFILE -> R.array.required_apps_managed_profile;
+            case ACTION_PROVISION_MANAGED_DEVICE -> R.array.required_apps_managed_device;
+            default -> throw new IllegalArgumentException(
+                    "Provisioning type " + provisioningAction + " not supported.");
+        };
+        return resolveStringArray(resId);
     }
 
     private Set<String> getDisallowedAppsSet(String provisioningAction) {
-        final int resId;
-        switch (provisioningAction) {
-            case ACTION_PROVISION_MANAGED_USER:
-                resId = R.array.disallowed_apps_managed_user;
-                break;
-            case ACTION_PROVISION_MANAGED_PROFILE:
-                resId = R.array.disallowed_apps_managed_profile;
-                break;
-            case ACTION_PROVISION_MANAGED_DEVICE:
-                resId = R.array.disallowed_apps_managed_device;
-                break;
-            default:
-                throw new IllegalArgumentException("Provisioning type "
-                        + provisioningAction + " not supported.");
-        }
-        return new ArraySet<>(Arrays.asList(mContext.getResources().getStringArray(resId)));
+        final int resId = switch (provisioningAction) {
+            case ACTION_PROVISION_MANAGED_USER -> R.array.disallowed_apps_managed_user;
+            case ACTION_PROVISION_MANAGED_PROFILE -> R.array.disallowed_apps_managed_profile;
+            case ACTION_PROVISION_MANAGED_DEVICE -> R.array.disallowed_apps_managed_device;
+            default -> throw new IllegalArgumentException(
+                    "Provisioning type " + provisioningAction + " not supported.");
+        };
+        return resolveStringArray(resId);
     }
 
     private Set<String> getVendorRequiredAppsSet(String provisioningAction) {
-        final int resId;
-        switch (provisioningAction) {
-            case ACTION_PROVISION_MANAGED_USER:
-                resId = R.array.vendor_required_apps_managed_user;
-                break;
-            case ACTION_PROVISION_MANAGED_PROFILE:
-                resId = R.array.vendor_required_apps_managed_profile;
-                break;
-            case ACTION_PROVISION_MANAGED_DEVICE:
-                resId = R.array.vendor_required_apps_managed_device;
-                break;
-            default:
-                throw new IllegalArgumentException("Provisioning type "
-                        + provisioningAction + " not supported.");
-        }
-        return new ArraySet<>(Arrays.asList(mContext.getResources().getStringArray(resId)));
+        final int resId = switch (provisioningAction) {
+            case ACTION_PROVISION_MANAGED_USER -> R.array.vendor_required_apps_managed_user;
+            case ACTION_PROVISION_MANAGED_PROFILE -> R.array.vendor_required_apps_managed_profile;
+            case ACTION_PROVISION_MANAGED_DEVICE -> R.array.vendor_required_apps_managed_device;
+            default -> throw new IllegalArgumentException(
+                    "Provisioning type " + provisioningAction + " not supported.");
+        };
+        return resolveStringArray(resId);
     }
 
     private Set<String> getVendorDisallowedAppsSet(String provisioningAction) {
-        final int resId;
-        switch (provisioningAction) {
-            case ACTION_PROVISION_MANAGED_USER:
-                resId = R.array.vendor_disallowed_apps_managed_user;
-                break;
-            case ACTION_PROVISION_MANAGED_PROFILE:
-                resId = R.array.vendor_disallowed_apps_managed_profile;
-                break;
-            case ACTION_PROVISION_MANAGED_DEVICE:
-                resId = R.array.vendor_disallowed_apps_managed_device;
-                break;
-            default:
-                throw new IllegalArgumentException("Provisioning type "
-                        + provisioningAction + " not supported.");
+        final int resId = switch (provisioningAction) {
+            case ACTION_PROVISION_MANAGED_USER -> R.array.vendor_disallowed_apps_managed_user;
+            case ACTION_PROVISION_MANAGED_PROFILE -> R.array.vendor_disallowed_apps_managed_profile;
+            case ACTION_PROVISION_MANAGED_DEVICE -> R.array.vendor_disallowed_apps_managed_device;
+            default -> throw new IllegalArgumentException(
+                    "Provisioning type " + provisioningAction + " not supported.");
+        };
+        return resolveStringArray(resId);
+    }
+
+    private Set<String> resolveStringArray(@ArrayRes int resId) {
+        if (Flags.isRecursiveRequiredAppMergingEnabled()) {
+            return mRecursiveStringArrayResourceResolver.resolve(mContext.getPackageName(), resId);
+        } else {
+            return new ArraySet<>(Arrays.asList(mContext.getResources().getStringArray(resId)));
         }
-        return new ArraySet<>(Arrays.asList(mContext.getResources().getStringArray(resId)));
     }
 
     void dump(IndentingPrintWriter pw) {
         pw.println("OverlayPackagesProvider");
         pw.increaseIndent();
 
-        dumpResources(pw, mContext, "required_apps_managed_device",
-                R.array.required_apps_managed_device);
-        dumpResources(pw, mContext, "required_apps_managed_user",
-                R.array.required_apps_managed_user);
-        dumpResources(pw, mContext, "required_apps_managed_profile",
-                R.array.required_apps_managed_profile);
+        dumpApps(pw, "required_apps_managed_device",
+                resolveStringArray(R.array.required_apps_managed_device).toArray(String[]::new));
+        dumpApps(pw, "required_apps_managed_user",
+                resolveStringArray(R.array.required_apps_managed_user).toArray(String[]::new));
+        dumpApps(pw, "required_apps_managed_profile",
+                resolveStringArray(R.array.required_apps_managed_profile).toArray(String[]::new));
 
-        dumpResources(pw, mContext, "disallowed_apps_managed_device",
-                R.array.disallowed_apps_managed_device);
-        dumpResources(pw, mContext, "disallowed_apps_managed_user",
-                R.array.disallowed_apps_managed_user);
-        dumpResources(pw, mContext, "disallowed_apps_managed_device",
-                R.array.disallowed_apps_managed_device);
+        dumpApps(pw, "disallowed_apps_managed_device",
+                resolveStringArray(R.array.disallowed_apps_managed_device).toArray(String[]::new));
+        dumpApps(pw, "disallowed_apps_managed_user",
+                resolveStringArray(R.array.disallowed_apps_managed_user).toArray(String[]::new));
+        dumpApps(pw, "disallowed_apps_managed_device",
+                resolveStringArray(R.array.disallowed_apps_managed_device).toArray(String[]::new));
 
-        dumpResources(pw, mContext, "vendor_required_apps_managed_device",
-                R.array.vendor_required_apps_managed_device);
-        dumpResources(pw, mContext, "vendor_required_apps_managed_user",
-                R.array.vendor_required_apps_managed_user);
-        dumpResources(pw, mContext, "vendor_required_apps_managed_profile",
-                R.array.vendor_required_apps_managed_profile);
+        dumpApps(pw, "vendor_required_apps_managed_device",
+                resolveStringArray(R.array.vendor_required_apps_managed_device).toArray(
+                        String[]::new));
+        dumpApps(pw, "vendor_required_apps_managed_user",
+                resolveStringArray(R.array.vendor_required_apps_managed_user).toArray(
+                        String[]::new));
+        dumpApps(pw, "vendor_required_apps_managed_profile",
+                resolveStringArray(R.array.vendor_required_apps_managed_profile).toArray(
+                        String[]::new));
 
-        dumpResources(pw, mContext, "vendor_disallowed_apps_managed_user",
-                R.array.vendor_disallowed_apps_managed_user);
-        dumpResources(pw, mContext, "vendor_disallowed_apps_managed_device",
-                R.array.vendor_disallowed_apps_managed_device);
-        dumpResources(pw, mContext, "vendor_disallowed_apps_managed_profile",
-                R.array.vendor_disallowed_apps_managed_profile);
+        dumpApps(pw, "vendor_disallowed_apps_managed_user",
+                resolveStringArray(R.array.vendor_disallowed_apps_managed_user).toArray(
+                        String[]::new));
+        dumpApps(pw, "vendor_disallowed_apps_managed_device",
+                resolveStringArray(R.array.vendor_disallowed_apps_managed_device).toArray(
+                        String[]::new));
+        dumpApps(pw, "vendor_disallowed_apps_managed_profile",
+                resolveStringArray(R.array.vendor_disallowed_apps_managed_profile).toArray(
+                        String[]::new));
 
         pw.decreaseIndent();
     }
diff --git a/services/devicepolicy/java/com/android/server/devicepolicy/RecursiveStringArrayResourceResolver.java b/services/devicepolicy/java/com/android/server/devicepolicy/RecursiveStringArrayResourceResolver.java
new file mode 100644
index 0000000..935e051
--- /dev/null
+++ b/services/devicepolicy/java/com/android/server/devicepolicy/RecursiveStringArrayResourceResolver.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.devicepolicy;
+
+import android.annotation.SuppressLint;
+import android.content.res.Resources;
+
+import androidx.annotation.ArrayRes;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A class encapsulating all the logic for recursive string-array resource resolution.
+ */
+public class RecursiveStringArrayResourceResolver {
+    private static final String IMPORT_PREFIX = "#import:";
+    private static final String SEPARATOR = "/";
+    private static final String PWP = ".";
+
+    private final Resources mResources;
+
+    /**
+     * @param resources Android resource access object to use when resolving resources
+     */
+    public RecursiveStringArrayResourceResolver(Resources resources) {
+        this.mResources = resources;
+    }
+
+    /**
+     * Resolves a given {@code <string-array/>} resource specified via
+     * {@param rootId} in {@param pkg}. During resolution all values prefixed with
+     * {@link #IMPORT_PREFIX} are expanded and injected
+     * into the final list at the position of the import statement,
+     * pushing all the following values (and their expansions) down.
+     * Circular imports are tracked and skipped to avoid infinite resolution loops without losing
+     * data.
+     *
+     * <p>
+     * The import statements are expected in a form of
+     * "{@link #IMPORT_PREFIX}{package}{@link #SEPARATOR}{resourceName}"
+     * If the resource being imported is from the same package, its package can be specified as a
+     * {@link #PWP} shorthand `.`
+     * > e.g.:
+     * >   {@code "#import:com.android.internal/disallowed_apps_managed_user"}
+     * >   {@code "#import:./disallowed_apps_managed_user"}
+     *
+     * <p>
+     * Any incorrect or unresolvable import statement
+     * will cause the entire resolution to fail with an error.
+     *
+     * @param pkg    the package owning the resource
+     * @param rootId the id of the {@code <string-array>} resource within {@param pkg} to start the
+     *               resolution from
+     * @return a flattened list of all the resolved string array values from the root resource
+     * as well as all the imported arrays
+     */
+    public Set<String> resolve(String pkg, @ArrayRes int rootId) {
+        return resolve(List.of(), pkg, rootId);
+    }
+
+    /**
+     * A version of resolve that tracks already imported resources
+     * to avoid circular imports and wasted work.
+     *
+     * @param cache a list of already resolved packages to be skipped for further resolution
+     */
+    private Set<String> resolve(Collection<String> cache, String pkg, @ArrayRes int rootId) {
+        final var strings = mResources.getStringArray(rootId);
+        final var runningCache = new ArrayList<>(cache);
+
+        final var result = new HashSet<String>();
+        for (var string : strings) {
+            final String ref;
+            if (string.startsWith(IMPORT_PREFIX)) {
+                ref = string.substring(IMPORT_PREFIX.length());
+            } else {
+                ref = null;
+            }
+
+            if (ref == null) {
+                result.add(string);
+            } else if (!runningCache.contains(ref)) {
+                final var next = resolveImport(runningCache, pkg, ref);
+                runningCache.addAll(next);
+                result.addAll(next);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Resolves an import of the {@code <string-array>} resource
+     * in the context of {@param importingPackage} by the provided {@param ref}.
+     *
+     * @param cache            a list of already resolved packages to be passed along into chained
+     *                         {@link #resolve} calls
+     * @param importingPackage the package that owns the resource which defined the import being
+     *                         processed.
+     *                         It is also used to expand all {@link #PWP} shorthands in
+     *                         {@param ref}
+     * @param ref              reference to the resource to be imported in a form of
+     *                         "{package}{@link #SEPARATOR}{resourceName}".
+     *                         e.g.: {@code com.android.internal/disallowed_apps_managed_user}
+     */
+    private Set<String> resolveImport(
+            Collection<String> cache,
+            String importingPackage,
+            String ref) {
+        final var chunks = ref.split(SEPARATOR, 2);
+        final var pkg = chunks[0];
+        final var name = chunks[1];
+        final String resolvedPkg;
+        if (Objects.equals(pkg, PWP)) {
+            resolvedPkg = importingPackage;
+        } else {
+            resolvedPkg = pkg;
+        }
+        @SuppressLint("DiscouragedApi") final var importId = mResources.getIdentifier(
+                /* name = */ name,
+                /* defType = */ "array",
+                /* defPackage = */ resolvedPkg);
+        if (importId == 0) {
+            throw new Resources.NotFoundException(
+                    /* name= */ String.format("%s:array/%s", resolvedPkg, name));
+        }
+        return resolve(cache, resolvedPkg, importId);
+    }
+}
diff --git a/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/InputMethodManagerServiceWindowGainedFocusTest.java b/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/InputMethodManagerServiceWindowGainedFocusTest.java
index cea65b5..9f46d0ba 100644
--- a/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/InputMethodManagerServiceWindowGainedFocusTest.java
+++ b/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/InputMethodManagerServiceWindowGainedFocusTest.java
@@ -198,7 +198,9 @@
 
     @Test
     public void startInputOrWindowGainedFocus_userNotRunning() throws RemoteException {
-        when(mMockUserManagerInternal.isUserRunning(anyInt())).thenReturn(false);
+        // Run blockingly on ServiceThread to avoid that interfering with our stubbing.
+        mServiceThread.getThreadHandler().runWithScissors(
+                () -> when(mMockUserManagerInternal.isUserRunning(anyInt())).thenReturn(false), 0);
 
         assertThat(
                         startInputOrWindowGainedFocus(
diff --git a/services/tests/displayservicetests/src/com/android/server/display/AutomaticBrightnessControllerTest.java b/services/tests/displayservicetests/src/com/android/server/display/AutomaticBrightnessControllerTest.java
index b0f7bfa..54de64e 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/AutomaticBrightnessControllerTest.java
+++ b/services/tests/displayservicetests/src/com/android/server/display/AutomaticBrightnessControllerTest.java
@@ -52,6 +52,7 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.server.display.brightness.clamper.BrightnessClamperController;
 import com.android.server.testutils.OffsettableClock;
 
 import org.junit.After;
@@ -96,6 +97,8 @@
     @Mock HysteresisLevels mScreenBrightnessThresholdsIdle;
     @Mock Handler mNoOpHandler;
     @Mock BrightnessRangeController mBrightnessRangeController;
+    @Mock
+    BrightnessClamperController mBrightnessClamperController;
     @Mock BrightnessThrottler mBrightnessThrottler;
 
     @Before
@@ -161,7 +164,8 @@
                 mAmbientBrightnessThresholdsIdle, mScreenBrightnessThresholdsIdle,
                 mContext, mBrightnessRangeController, mBrightnessThrottler,
                 useHorizon ? AMBIENT_LIGHT_HORIZON_SHORT : 1,
-                useHorizon ? AMBIENT_LIGHT_HORIZON_LONG : 10000, userLux, userNits
+                useHorizon ? AMBIENT_LIGHT_HORIZON_LONG : 10000, userLux, userNits,
+                mBrightnessClamperController
         );
 
         when(mBrightnessRangeController.getCurrentBrightnessMax()).thenReturn(
diff --git a/services/tests/displayservicetests/src/com/android/server/display/DisplayDeviceConfigTest.java b/services/tests/displayservicetests/src/com/android/server/display/DisplayDeviceConfigTest.java
index 35b69f8..73a2f65 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/DisplayDeviceConfigTest.java
+++ b/services/tests/displayservicetests/src/com/android/server/display/DisplayDeviceConfigTest.java
@@ -44,6 +44,7 @@
 import android.hardware.display.DisplayManagerInternal;
 import android.os.PowerManager;
 import android.os.Temperature;
+import android.platform.test.annotations.RequiresFlagsEnabled;
 import android.provider.Settings;
 import android.util.SparseArray;
 import android.util.Spline;
@@ -57,6 +58,7 @@
 import com.android.server.display.config.IdleScreenRefreshRateTimeoutLuxThresholdPoint;
 import com.android.server.display.config.ThermalStatus;
 import com.android.server.display.feature.DisplayManagerFlags;
+import com.android.server.display.feature.flags.Flags;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -380,7 +382,7 @@
     public void testInvalidLuxThrottling() throws Exception {
         setupDisplayDeviceConfigFromDisplayConfigFile(
                 getContent(getInvalidLuxThrottling(), getValidProxSensor(),
-                        /* includeIdleMode= */ true));
+                        /* includeIdleMode= */ true, /* enableEvenDimmer */ false));
 
         Map<DisplayDeviceConfig.BrightnessLimitMapType, Map<Float, Float>> luxThrottlingData =
                 mDisplayDeviceConfig.getLuxThrottlingData();
@@ -588,7 +590,7 @@
     public void testProximitySensorWithEmptyValuesFromDisplayConfig() throws IOException {
         setupDisplayDeviceConfigFromDisplayConfigFile(
                 getContent(getValidLuxThrottling(), getProxSensorWithEmptyValues(),
-                        /* includeIdleMode= */ true));
+                        /* includeIdleMode= */ true, /* enableEvenDimmer */ false));
         assertNull(mDisplayDeviceConfig.getProximitySensor());
     }
 
@@ -596,7 +598,7 @@
     public void testProximitySensorWithRefreshRatesFromDisplayConfig() throws IOException {
         setupDisplayDeviceConfigFromDisplayConfigFile(
                 getContent(getValidLuxThrottling(), getValidProxSensorWithRefreshRateAndVsyncRate(),
-                        /* includeIdleMode= */ true));
+                        /* includeIdleMode= */ true, /* enableEvenDimmer */ false));
         assertEquals("test_proximity_sensor",
                 mDisplayDeviceConfig.getProximitySensor().type);
         assertEquals("Test Proximity Sensor",
@@ -784,7 +786,7 @@
     @Test
     public void testBrightnessRamps_IdleFallsBackToConfigInteractive() throws IOException {
         setupDisplayDeviceConfigFromDisplayConfigFile(getContent(getValidLuxThrottling(),
-                getValidProxSensor(), /* includeIdleMode= */ false));
+                getValidProxSensor(), /* includeIdleMode= */ false, /* enableEvenDimmer */ false));
 
         assertEquals(mDisplayDeviceConfig.getBrightnessRampDecreaseMaxMillis(), 3000);
         assertEquals(mDisplayDeviceConfig.getBrightnessRampIncreaseMaxMillis(), 2000);
@@ -801,14 +803,14 @@
     @Test
     public void testBrightnessCapForWearBedtimeMode() throws IOException {
         setupDisplayDeviceConfigFromDisplayConfigFile(getContent(getValidLuxThrottling(),
-                getValidProxSensor(), /* includeIdleMode= */ false));
+                getValidProxSensor(), /* includeIdleMode= */ false, /* enableEvenDimmer */ false));
         assertEquals(0.1f, mDisplayDeviceConfig.getBrightnessCapForWearBedtimeMode(), ZERO_DELTA);
     }
 
     @Test
     public void testAutoBrightnessBrighteningLevels() throws IOException {
         setupDisplayDeviceConfigFromDisplayConfigFile(getContent(getValidLuxThrottling(),
-                getValidProxSensor(), /* includeIdleMode= */ false));
+                getValidProxSensor(), /* includeIdleMode= */ false, /* enableEvenDimmer */ false));
 
         assertArrayEquals(new float[]{0.0f, 80},
                 mDisplayDeviceConfig.getAutoBrightnessBrighteningLevelsLux(
@@ -871,7 +873,7 @@
         when(mFlags.areAutoBrightnessModesEnabled()).thenReturn(false);
         setupDisplayDeviceConfigFromConfigResourceFile();
         setupDisplayDeviceConfigFromDisplayConfigFile(getContent(getValidLuxThrottling(),
-                getValidProxSensor(), /* includeIdleMode= */ false));
+                getValidProxSensor(), /* includeIdleMode= */ false, /* enableEvenDimmer */ false));
 
         assertArrayEquals(new float[]{brightnessIntToFloat(50), brightnessIntToFloat(100),
                         brightnessIntToFloat(150)},
@@ -904,6 +906,18 @@
         assertFalse(mDisplayDeviceConfig.isAutoBrightnessAvailable());
     }
 
+    @RequiresFlagsEnabled(Flags.FLAG_EVEN_DIMMER)
+    @Test
+    public void testEvenDimmer() throws IOException {
+        when(mFlags.isEvenDimmerEnabled()).thenReturn(true);
+        setupDisplayDeviceConfigFromDisplayConfigFile(getContent(getValidLuxThrottling(),
+                getValidProxSensor(), /* includeIdleMode= */ false, /* enableEvenDimmer */ true));
+
+        assertTrue(mDisplayDeviceConfig.getLbmEnabled());
+        assertEquals(0.0001f, mDisplayDeviceConfig.getBacklightFromBrightness(0.1f), ZERO_DELTA);
+        assertEquals(0.2f, mDisplayDeviceConfig.getNitsFromBacklight(0.0f), ZERO_DELTA);
+    }
+
     private String getValidLuxThrottling() {
         return "<luxThrottling>\n"
                 + "    <brightnessLimitMap>\n"
@@ -1229,11 +1243,11 @@
 
     private String getContent() {
         return getContent(getValidLuxThrottling(), getValidProxSensor(),
-                /* includeIdleMode= */ true);
+                /* includeIdleMode= */ true, false);
     }
 
     private String getContent(String brightnessCapConfig, String proxSensor,
-            boolean includeIdleMode) {
+            boolean includeIdleMode, boolean enableEvenDimmer) {
         return "<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n"
                 + "<displayConfiguration>\n"
                 +   "<name>Example Display</name>\n"
@@ -1603,6 +1617,7 @@
                 +       "<majorVersion>2</majorVersion>\n"
                 +       "<minorVersion>0</minorVersion>\n"
                 +   "</usiVersion>\n"
+                + evenDimmerConfig(enableEvenDimmer)
                 +   "<screenBrightnessCapForWearBedtimeMode>"
                 +       "0.1"
                 +   "</screenBrightnessCapForWearBedtimeMode>"
@@ -1621,6 +1636,24 @@
                 + "</displayConfiguration>\n";
     }
 
+    private String evenDimmerConfig(boolean enabled) {
+        return (enabled ? "<lowBrightness enabled=\"true\">" : "<lowBrightness enabled=\"false\">")
+                + "  <transitionPoint>0.1</transitionPoint>\n"
+                + "  <nits>0.2</nits>\n"
+                + "  <nits>2.0</nits>\n"
+                + "  <nits>500.0</nits>\n"
+                + "  <nits>1000.0</nits>\n"
+                + "  <backlight>0</backlight>\n"
+                + "  <backlight>0.0001</backlight>\n"
+                + "  <backlight>0.5</backlight>\n"
+                + "  <backlight>1.0</backlight>\n"
+                + "  <brightness>0</brightness>\n"
+                + "  <brightness>0.1</brightness>\n"
+                + "  <brightness>0.5</brightness>\n"
+                + "  <brightness>1.0</brightness>\n"
+                + "</lowBrightness>";
+    }
+
     private void mockDeviceConfigs() {
         when(mResources.getFloat(com.android.internal.R.dimen
                 .config_screenBrightnessSettingDefaultFloat)).thenReturn(0.5f);
diff --git a/services/tests/displayservicetests/src/com/android/server/display/DisplayPowerControllerTest.java b/services/tests/displayservicetests/src/com/android/server/display/DisplayPowerControllerTest.java
index 01598ae..740ffc9 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/DisplayPowerControllerTest.java
+++ b/services/tests/displayservicetests/src/com/android/server/display/DisplayPowerControllerTest.java
@@ -1184,7 +1184,8 @@
                 /* ambientLightHorizonShort= */ anyInt(),
                 /* ambientLightHorizonLong= */ anyInt(),
                 eq(lux),
-                eq(nits)
+                eq(nits),
+                any(BrightnessClamperController.class)
         );
     }
 
@@ -2121,7 +2122,8 @@
                 HysteresisLevels screenBrightnessThresholdsIdle, Context context,
                 BrightnessRangeController brightnessRangeController,
                 BrightnessThrottler brightnessThrottler, int ambientLightHorizonShort,
-                int ambientLightHorizonLong, float userLux, float userNits) {
+                int ambientLightHorizonLong, float userLux, float userNits,
+                BrightnessClamperController brightnessClamperController) {
             return mAutomaticBrightnessController;
         }
 
diff --git a/services/tests/displayservicetests/src/com/android/server/display/brightness/clamper/BrightnessLowLuxModifierTest.kt b/services/tests/displayservicetests/src/com/android/server/display/brightness/clamper/BrightnessLowLuxModifierTest.kt
index ac7d1f5..e4a7d98 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/brightness/clamper/BrightnessLowLuxModifierTest.kt
+++ b/services/tests/displayservicetests/src/com/android/server/display/brightness/clamper/BrightnessLowLuxModifierTest.kt
@@ -65,7 +65,7 @@
         Settings.Secure.putIntForUser(context.contentResolver,
                 Settings.Secure.EVEN_DIMMER_ACTIVATED, 1, userId)
         Settings.Secure.putFloatForUser(context.contentResolver,
-                Settings.Secure.EVEN_DIMMER_MIN_NITS, 0.7f, userId)
+                Settings.Secure.EVEN_DIMMER_MIN_NITS, 30.0f, userId)
         modifier.recalculateLowerBound()
         testHandler.flush()
         assertThat(modifier.isActive).isTrue()
@@ -81,11 +81,22 @@
                 Settings.Secure.EVEN_DIMMER_ACTIVATED, 1, userId)
         Settings.Secure.putFloatForUser(context.contentResolver,
                 Settings.Secure.EVEN_DIMMER_MIN_NITS, 0.0f, userId)
-        modifier.recalculateLowerBound()
+        modifier.onAmbientLuxChange(3000.0f)
         testHandler.flush()
         assertThat(modifier.isActive).isTrue()
 
         // Test restriction from lux setting
         assertThat(modifier.brightnessReason).isEqualTo(BrightnessReason.MODIFIER_MIN_LUX)
     }
+
+    @Test
+    fun testSettingOffDisablesModifier() {
+        Settings.Secure.putIntForUser(context.contentResolver,
+            Settings.Secure.EVEN_DIMMER_ACTIVATED, 0, userId)
+        assertThat(modifier.brightnessLowerBound).isEqualTo(PowerManager.BRIGHTNESS_MIN)
+        modifier.onAmbientLuxChange(3000.0f)
+        testHandler.flush()
+        assertThat(modifier.isActive).isFalse()
+        assertThat(modifier.brightnessLowerBound).isEqualTo(PowerManager.BRIGHTNESS_MIN)
+    }
 }
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index 37967fa..65986ea 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -62,6 +62,7 @@
         "cts-wm-util",
         "platform-compat-test-rules",
         "mockito-target-minus-junit4",
+        "mockito-kotlin2",
         "platform-test-annotations",
         "ShortcutManagerTestUtils",
         "truth",
diff --git a/services/tests/servicestests/src/com/android/server/devicepolicy/OverlayPackagesProviderTest.java b/services/tests/servicestests/src/com/android/server/devicepolicy/OverlayPackagesProviderTest.java
index 4f6fc3d..0a696ef 100644
--- a/services/tests/servicestests/src/com/android/server/devicepolicy/OverlayPackagesProviderTest.java
+++ b/services/tests/servicestests/src/com/android/server/devicepolicy/OverlayPackagesProviderTest.java
@@ -47,7 +47,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.test.InstrumentationRegistry;
-import androidx.test.runner.AndroidJUnit4;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
 
 import com.android.internal.R;
 
@@ -67,9 +67,7 @@
 
 /**
  * Run this test with:
- *
  * {@code atest FrameworksServicesTests:com.android.server.devicepolicy.OwnersTest}
- *
  */
 @RunWith(AndroidJUnit4.class)
 public class OverlayPackagesProviderTest {
@@ -87,8 +85,8 @@
 
     private FakePackageManager mPackageManager;
     private String[] mSystemAppsWithLauncher;
-    private Set<String> mRegularMainlineModules = new HashSet<>();
-    private Map<String, String> mMainlineModuleToDeclaredMetadataMap = new HashMap<>();
+    private final Set<String> mRegularMainlineModules = new HashSet<>();
+    private final Map<String, String> mMainlineModuleToDeclaredMetadataMap = new HashMap<>();
     private OverlayPackagesProvider mHelper;
 
     @Before
@@ -115,7 +113,8 @@
         setVendorDisallowedAppsManagedUser();
 
         mRealResources = InstrumentationRegistry.getTargetContext().getResources();
-        mHelper = new OverlayPackagesProvider(mTestContext, mInjector);
+        mHelper = new OverlayPackagesProvider(mTestContext, mInjector,
+                new RecursiveStringArrayResourceResolver(mResources));
     }
 
     @Test
@@ -213,7 +212,7 @@
     }
 
     /**
-     * @see {@link #testAllowedAndDisallowedAtTheSameTimeManagedDevice}
+     * @see #testAllowedAndDisallowedAtTheSameTimeManagedDevice
      */
     @Test
     public void testAllowedAndDisallowedAtTheSameTimeManagedUser() {
@@ -224,7 +223,7 @@
     }
 
     /**
-     * @see {@link #testAllowedAndDisallowedAtTheSameTimeManagedDevice}
+     * @see #testAllowedAndDisallowedAtTheSameTimeManagedDevice
      */
     @Test
     public void testAllowedAndDisallowedAtTheSameTimeManagedProfile() {
@@ -447,7 +446,7 @@
     }
 
     private void setSystemInputMethods(String... packageNames) {
-        List<InputMethodInfo> inputMethods = new ArrayList<InputMethodInfo>();
+        List<InputMethodInfo> inputMethods = new ArrayList<>();
         for (String packageName : packageNames) {
             ApplicationInfo aInfo = new ApplicationInfo();
             aInfo.flags = ApplicationInfo.FLAG_SYSTEM;
@@ -467,6 +466,7 @@
         mSystemAppsWithLauncher = apps;
     }
 
+    @SafeVarargs
     private <T> Set<T> setFromArray(T... array) {
         if (array == null) {
             return null;
@@ -475,6 +475,7 @@
     }
 
     class FakePackageManager extends MockPackageManager {
+        @NonNull
         @Override
         public List<ResolveInfo> queryIntentActivitiesAsUser(Intent intent, int flags, int userId) {
             assertWithMessage("Expected an intent with action ACTION_MAIN")
diff --git a/services/tests/servicestests/src/com/android/server/devicepolicy/RecursiveStringArrayResourceResolverTest.kt b/services/tests/servicestests/src/com/android/server/devicepolicy/RecursiveStringArrayResourceResolverTest.kt
new file mode 100644
index 0000000..647f6c7
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/devicepolicy/RecursiveStringArrayResourceResolverTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.devicepolicy
+
+import android.annotation.ArrayRes
+import android.content.res.Resources
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertWithMessage
+import com.google.errorprone.annotations.CanIgnoreReturnValue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+
+/**
+ * Run this test with:
+ * `atest FrameworksServicesTests:com.android.server.devicepolicy.RecursiveStringArrayResourceResolverTest`
+ */
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class RecursiveStringArrayResourceResolverTest {
+    private companion object {
+        const val PACKAGE = "com.android.test"
+        const val ROOT_RESOURCE = "my_root_resource"
+        const val SUB_RESOURCE = "my_sub_resource"
+        const val EXTERNAL_PACKAGE = "com.external.test"
+        const val EXTERNAL_RESOURCE = "my_external_resource"
+    }
+
+    private val mResources = mock<Resources>()
+    private val mTarget = RecursiveStringArrayResourceResolver(mResources)
+
+    /**
+     * Mocks [Resources.getIdentifier] and [Resources.getStringArray] to return [values] and reference under a generated ID.
+     * @receiver mocked [Resources] container to configure
+     * @param pkg package name to "contain" mocked resource
+     * @param name mocked resource name
+     * @param values string-array resource values to return when mock is queried
+     * @return generated resource ID
+     */
+    @ArrayRes
+    @CanIgnoreReturnValue
+    private fun Resources.mockStringArrayResource(pkg: String, name: String, vararg values: String): Int {
+        val anId = (pkg + name).hashCode()
+        println("Mocking Resources::getIdentifier(name=\"$name\", defType=\"array\", defPackage=\"$pkg\") -> $anId")
+        whenever(getIdentifier(eq(name), eq("array"), eq(pkg))).thenReturn(anId)
+        println("Mocking Resources::getStringArray(id=$anId) -> ${values.asList()}")
+        whenever(getStringArray(eq(anId))).thenReturn(values)
+        return anId
+    }
+
+    @Test
+    fun testCanResolveTheArrayWithoutImports() {
+        val values = arrayOf("app.a", "app.b")
+        val mockId = mResources.mockStringArrayResource(pkg = PACKAGE, name = ROOT_RESOURCE, values = values)
+
+        val actual = mTarget.resolve(/* pkg= */ PACKAGE, /* rootId = */ mockId)
+
+        assertWithMessage("Values are resolved correctly")
+                .that(actual).containsExactlyElementsIn(values)
+    }
+
+    @Test
+    fun testCanResolveTheArrayWithImports() {
+        val externalValues = arrayOf("ext.a", "ext.b", "#import:$PACKAGE/$SUB_RESOURCE")
+        mResources.mockStringArrayResource(pkg = EXTERNAL_PACKAGE, name = EXTERNAL_RESOURCE, values = externalValues)
+        val subValues = arrayOf("sub.a", "sub.b")
+        mResources.mockStringArrayResource(pkg = PACKAGE, name = SUB_RESOURCE, values = subValues)
+        val values = arrayOf("app.a", "#import:./$SUB_RESOURCE", "app.b", "#import:$EXTERNAL_PACKAGE/$EXTERNAL_RESOURCE", "app.c")
+        val mockId = mResources.mockStringArrayResource(pkg = PACKAGE, name = ROOT_RESOURCE, values = values)
+
+        val actual = mTarget.resolve(/* pkg= */ PACKAGE, /* rootId= */ mockId)
+
+        assertWithMessage("Values are resolved correctly")
+                .that(actual).containsExactlyElementsIn((externalValues + subValues + values)
+                        .filterNot { it.startsWith("#import:") }
+                        .toSet())
+    }
+}
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java
index bfc47fd..cee6cdb 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java
@@ -3962,6 +3962,20 @@
     }
 
     @Test
+    public void testReadXml_existingPackage_bubblePrefsRestored() throws Exception {
+        mHelper.setBubblesAllowed(PKG_O, UID_O, BUBBLE_PREFERENCE_ALL);
+        assertEquals(BUBBLE_PREFERENCE_ALL, mHelper.getBubblePreference(PKG_O, UID_O));
+
+        mXmlHelper.setBubblesAllowed(PKG_O, UID_O, BUBBLE_PREFERENCE_NONE);
+        assertEquals(BUBBLE_PREFERENCE_NONE, mXmlHelper.getBubblePreference(PKG_O, UID_O));
+
+        ByteArrayOutputStream stream = writeXmlAndPurge(PKG_O, UID_O, false, UserHandle.USER_ALL);
+        loadStreamXml(stream, true, UserHandle.USER_ALL);
+
+        assertEquals(BUBBLE_PREFERENCE_ALL, mXmlHelper.getBubblePreference(PKG_O, UID_O));
+    }
+
+    @Test
     public void testUpdateNotificationChannel_fixedPermission() {
         List<UserInfo> users = ImmutableList.of(new UserInfo(UserHandle.USER_SYSTEM, "user0", 0));
         when(mPermissionHelper.isPermissionFixed(PKG_O, 0)).thenReturn(true);
diff --git a/services/tests/vibrator/src/com/android/server/vibrator/VibratorManagerServiceTest.java b/services/tests/vibrator/src/com/android/server/vibrator/VibratorManagerServiceTest.java
index 8cbcc22..5861d88 100644
--- a/services/tests/vibrator/src/com/android/server/vibrator/VibratorManagerServiceTest.java
+++ b/services/tests/vibrator/src/com/android/server/vibrator/VibratorManagerServiceTest.java
@@ -500,7 +500,8 @@
         InOrder batteryVerifier = inOrder(mBatteryStatsMock);
         batteryVerifier.verify(mBatteryStatsMock)
                 .noteVibratorOn(UID, oneShotDuration + mVibrationConfig.getRampDownDurationMs());
-        batteryVerifier.verify(mBatteryStatsMock).noteVibratorOff(UID);
+        batteryVerifier
+                .verify(mBatteryStatsMock, timeout(TEST_TIMEOUT_MILLIS)).noteVibratorOff(UID);
     }
 
     @Test
diff --git a/services/tests/wmtests/src/com/android/server/policy/ShortcutLoggingTests.java b/services/tests/wmtests/src/com/android/server/policy/ShortcutLoggingTests.java
index 0a29dfb..60716cb 100644
--- a/services/tests/wmtests/src/com/android/server/policy/ShortcutLoggingTests.java
+++ b/services/tests/wmtests/src/com/android/server/policy/ShortcutLoggingTests.java
@@ -95,8 +95,6 @@
                         new int[]{KeyEvent.KEYCODE_NOTIFICATION},
                         KeyboardLogEvent.TOGGLE_NOTIFICATION_PANEL, KeyEvent.KEYCODE_NOTIFICATION,
                         0},
-                {"Meta + T -> Toggle Taskbar", new int[]{META_KEY, KeyEvent.KEYCODE_T},
-                        KeyboardLogEvent.TOGGLE_TASKBAR, KeyEvent.KEYCODE_T, META_ON},
                 {"Meta + Ctrl + S -> Take Screenshot",
                         new int[]{META_KEY, CTRL_KEY, KeyEvent.KEYCODE_S},
                         KeyboardLogEvent.TAKE_SCREENSHOT, KeyEvent.KEYCODE_S, META_ON | CTRL_ON},
diff --git a/tests/inputmethod/ConcurrentMultiSessionImeTest/Android.bp b/tests/inputmethod/ConcurrentMultiSessionImeTest/Android.bp
index 0e0d212..8d05a97 100644
--- a/tests/inputmethod/ConcurrentMultiSessionImeTest/Android.bp
+++ b/tests/inputmethod/ConcurrentMultiSessionImeTest/Android.bp
@@ -26,11 +26,6 @@
         "platform-test-annotations",
         "platform-test-rules",
         "truth",
-
-        // beadstead
-        "Nene",
-        "Harrier",
-        "TestApp",
     ],
     test_suites: [
         "general-tests",
diff --git a/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/ConcurrentMultiUserTest.java b/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/ConcurrentMultiUserTest.java
index 867c0a6..b66ceba 100644
--- a/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/ConcurrentMultiUserTest.java
+++ b/tests/inputmethod/ConcurrentMultiSessionImeTest/src/com/android/server/inputmethod/multisessiontest/ConcurrentMultiUserTest.java
@@ -23,20 +23,14 @@
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
-import com.android.bedstead.harrier.BedsteadJUnit4;
-import com.android.bedstead.harrier.DeviceState;
-
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
-@RunWith(BedsteadJUnit4.class)
+@RunWith(JUnit4.class)
 public final class ConcurrentMultiUserTest {
 
-    @Rule
-    public static final DeviceState sDeviceState = new DeviceState();
-
     @Before
     public void doBeforeEachTest() {
         // No op