Merge "Add secure setting disable_secure_windows" into main
diff --git a/AconfigFlags.bp b/AconfigFlags.bp
index 6ecd38f..3391698 100644
--- a/AconfigFlags.bp
+++ b/AconfigFlags.bp
@@ -335,6 +335,11 @@
     aconfig_declarations: "android.os.flags-aconfig",
     defaults: ["framework-minus-apex-aconfig-java-defaults"],
     mode: "exported",
+    min_sdk_version: "30",
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.mediaprovider",
+    ],
 }
 
 cc_aconfig_library {
@@ -716,6 +721,7 @@
     name: "android.credentials.flags-aconfig",
     package: "android.credentials.flags",
     srcs: ["core/java/android/credentials/flags.aconfig"],
+    exportable: true,
 }
 
 java_aconfig_library {
@@ -724,6 +730,13 @@
     defaults: ["framework-minus-apex-aconfig-java-defaults"],
 }
 
+java_aconfig_library {
+    name: "android.credentials.flags-aconfig-java-export",
+    aconfig_declarations: "android.credentials.flags-aconfig",
+    defaults: ["framework-minus-apex-aconfig-java-defaults"],
+    mode: "exported",
+}
+
 // Content Protection
 aconfig_declarations {
     name: "android.view.contentprotection.flags-aconfig",
diff --git a/TEST_MAPPING b/TEST_MAPPING
index c904eb4..49384cd 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -232,30 +232,5 @@
        }
      ]
    }
- ],
- "auto-features-postsubmit": [
-   // Test tag for automotive feature targets. These are only running in postsubmit.
-   // This tag is used in targeted test features testing to limit resource use.
-   // TODO(b/256932212): this tag to be removed once the above is no longer in use.
-   {
-     "name": "FrameworksMockingServicesTests",
-     "options": [
-       {
-         "include-filter": "com.android.server.pm.UserVisibilityMediatorSUSDTest"
-       },
-       {
-         "include-filter": "com.android.server.pm.UserVisibilityMediatorMUMDTest"
-       },
-       {
-         "include-filter": "com.android.server.pm.UserVisibilityMediatorMUPANDTest"
-       },
-       {
-         "exclude-annotation": "androidx.test.filters.FlakyTest"
-       },
-       {
-         "exclude-annotation": "org.junit.Ignore"
-       }
-     ]
-   }
  ]
 }
diff --git a/core/api/current.txt b/core/api/current.txt
index 4d3ca13..8a61f4a 100644
--- a/core/api/current.txt
+++ b/core/api/current.txt
@@ -10764,6 +10764,7 @@
     field public static final String OVERLAY_SERVICE = "overlay";
     field public static final String PEOPLE_SERVICE = "people";
     field public static final String PERFORMANCE_HINT_SERVICE = "performance_hint";
+    field @FlaggedApi("android.security.frp_enforcement") public static final String PERSISTENT_DATA_BLOCK_SERVICE = "persistent_data_block";
     field public static final String POWER_SERVICE = "power";
     field public static final String PRINT_SERVICE = "print";
     field @FlaggedApi("android.os.telemetry_apis_framework_initialization") public static final String PROFILING_SERVICE = "profiling";
@@ -20235,10 +20236,10 @@
     method public android.hardware.camera2.CaptureRequest getSessionParameters();
     method public int getSessionType();
     method public android.hardware.camera2.CameraCaptureSession.StateCallback getStateCallback();
-    method @FlaggedApi("com.android.internal.camera.flags.camera_device_setup") public void setCallback(@NonNull java.util.concurrent.Executor, @NonNull android.hardware.camera2.CameraCaptureSession.StateCallback);
     method public void setColorSpace(@NonNull android.graphics.ColorSpace.Named);
     method public void setInputConfiguration(@NonNull android.hardware.camera2.params.InputConfiguration);
     method public void setSessionParameters(android.hardware.camera2.CaptureRequest);
+    method @FlaggedApi("com.android.internal.camera.flags.camera_device_setup") public void setStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.hardware.camera2.CameraCaptureSession.StateCallback);
     method public void writeToParcel(android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.hardware.camera2.params.SessionConfiguration> CREATOR;
     field public static final int SESSION_HIGH_SPEED = 1; // 0x1
diff --git a/core/api/system-current.txt b/core/api/system-current.txt
index 8ceda62..5ead3e1 100644
--- a/core/api/system-current.txt
+++ b/core/api/system-current.txt
@@ -598,7 +598,6 @@
     field public static final int FOREGROUND_SERVICE_API_TYPE_MICROPHONE = 6; // 0x6
     field public static final int FOREGROUND_SERVICE_API_TYPE_PHONE_CALL = 7; // 0x7
     field public static final int FOREGROUND_SERVICE_API_TYPE_USB = 8; // 0x8
-    field @FlaggedApi("android.media.audio.foreground_audio_control") public static final int PROCESS_CAPABILITY_FOREGROUND_AUDIO_CONTROL = 64; // 0x40
     field public static final int PROCESS_CAPABILITY_FOREGROUND_CAMERA = 2; // 0x2
     field public static final int PROCESS_CAPABILITY_FOREGROUND_LOCATION = 1; // 0x1
     field public static final int PROCESS_CAPABILITY_FOREGROUND_MICROPHONE = 4; // 0x4
@@ -3797,7 +3796,6 @@
     field @FlaggedApi("android.app.ondeviceintelligence.flags.enable_on_device_intelligence") public static final String ON_DEVICE_INTELLIGENCE_SERVICE = "on_device_intelligence";
     field public static final String PERMISSION_CONTROLLER_SERVICE = "permission_controller";
     field public static final String PERMISSION_SERVICE = "permission";
-    field public static final String PERSISTENT_DATA_BLOCK_SERVICE = "persistent_data_block";
     field public static final String REBOOT_READINESS_SERVICE = "reboot_readiness";
     field public static final String ROLLBACK_SERVICE = "rollback";
     field public static final String SAFETY_CENTER_SERVICE = "safety_center";
@@ -4356,7 +4354,7 @@
     field @Deprecated public static final int INTENT_FILTER_VERIFICATION_SUCCESS = 1; // 0x1
     field @Deprecated public static final int MASK_PERMISSION_FLAGS = 255; // 0xff
     field public static final int MATCH_ANY_USER = 4194304; // 0x400000
-    field public static final int MATCH_CLONE_PROFILE = 536870912; // 0x20000000
+    field @Deprecated public static final int MATCH_CLONE_PROFILE = 536870912; // 0x20000000
     field @FlaggedApi("android.content.pm.fix_duplicated_flags") public static final long MATCH_CLONE_PROFILE_LONG = 17179869184L; // 0x400000000L
     field public static final int MATCH_FACTORY_ONLY = 2097152; // 0x200000
     field public static final int MATCH_HIDDEN_UNTIL_INSTALLED_COMPONENTS = 536870912; // 0x20000000
diff --git a/core/api/test-current.txt b/core/api/test-current.txt
index 0a26490..a2b847e 100644
--- a/core/api/test-current.txt
+++ b/core/api/test-current.txt
@@ -2460,6 +2460,7 @@
   }
 
   public class UserManager {
+    method @FlaggedApi("android.os.allow_private_profile") @RequiresPermission(anyOf={android.Manifest.permission.MANAGE_USERS, android.Manifest.permission.CREATE_USERS}, conditional=true) public boolean canAddPrivateProfile();
     method @Nullable @RequiresPermission(anyOf={android.Manifest.permission.MANAGE_USERS, android.Manifest.permission.CREATE_USERS}) public android.content.pm.UserInfo createProfileForUser(@Nullable String, @NonNull String, int, int, @Nullable String[]);
     method @Nullable @RequiresPermission(anyOf={android.Manifest.permission.MANAGE_USERS, android.Manifest.permission.CREATE_USERS}) public android.content.pm.UserInfo createRestrictedProfile(@Nullable String);
     method @Nullable @RequiresPermission(anyOf={android.Manifest.permission.MANAGE_USERS, android.Manifest.permission.CREATE_USERS}) public android.content.pm.UserInfo createUser(@Nullable String, @NonNull String, int);
diff --git a/core/java/android/app/ActivityManager.java b/core/java/android/app/ActivityManager.java
index fae4348..0c54351 100644
--- a/core/java/android/app/ActivityManager.java
+++ b/core/java/android/app/ActivityManager.java
@@ -20,7 +20,6 @@
 import static android.app.WindowConfiguration.windowingModeToString;
 import static android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS;
 import static android.content.pm.ActivityInfo.RESIZE_MODE_RESIZEABLE;
-import static android.media.audio.Flags.FLAG_FOREGROUND_AUDIO_CONTROL;
 
 import android.Manifest;
 import android.annotation.ColorInt;
@@ -948,8 +947,6 @@
      * @hide
      * Process can access volume APIs and can request audio focus with GAIN.
      */
-    @FlaggedApi(FLAG_FOREGROUND_AUDIO_CONTROL)
-    @SystemApi
     public static final int PROCESS_CAPABILITY_FOREGROUND_AUDIO_CONTROL = 1 << 6;
 
     /**
diff --git a/core/java/android/app/AppOpsManager.java b/core/java/android/app/AppOpsManager.java
index a8352fa..ff713d0 100644
--- a/core/java/android/app/AppOpsManager.java
+++ b/core/java/android/app/AppOpsManager.java
@@ -1581,6 +1581,10 @@
      * Allows an app to access location without the traditional location permissions and while the
      * user location setting is off, but only during pre-defined emergency sessions.
      *
+     * <p>This op is only used for tracking, not for permissions, so it is still the client's
+     * responsibility to check the {@link Manifest.permission.LOCATION_BYPASS} permission
+     * appropriately.
+     *
      * @hide
      */
     public static final int OP_EMERGENCY_LOCATION = AppProtoEnums.APP_OP_EMERGENCY_LOCATION;
@@ -2459,6 +2463,10 @@
      * Allows an app to access location without the traditional location permissions and while the
      * user location setting is off, but only during pre-defined emergency sessions.
      *
+     * <p>This op is only used for tracking, not for permissions, so it is still the client's
+     * responsibility to check the {@link Manifest.permission.LOCATION_BYPASS} permission
+     * appropriately.
+     *
      * @hide
      */
     @SystemApi
@@ -3047,8 +3055,10 @@
         new AppOpInfo.Builder(OP_UNARCHIVAL_CONFIRMATION, OPSTR_UNARCHIVAL_CONFIRMATION,
                 "UNARCHIVAL_CONFIRMATION")
                 .setDefaultMode(MODE_ALLOWED).build(),
-        // TODO(b/301150056): STOPSHIP determine how this appop should work with the permission
         new AppOpInfo.Builder(OP_EMERGENCY_LOCATION, OPSTR_EMERGENCY_LOCATION, "EMERGENCY_LOCATION")
+                .setDefaultMode(MODE_ALLOWED)
+                // even though this has a permission associated, this op is only used for tracking,
+                // and the client is responsible for checking the LOCATION_BYPASS permission.
                 .setPermission(Manifest.permission.LOCATION_BYPASS).build(),
     };
 
diff --git a/core/java/android/app/ContextImpl.java b/core/java/android/app/ContextImpl.java
index 6f6e091..716dee4 100644
--- a/core/java/android/app/ContextImpl.java
+++ b/core/java/android/app/ContextImpl.java
@@ -344,23 +344,37 @@
      */
     private boolean mOwnsToken = false;
 
-    private final Object mDirsLock = new Object();
-    @GuardedBy("mDirsLock")
+    private final Object mDatabasesDirLock = new Object();
+    @GuardedBy("mDatabasesDirLock")
     private File mDatabasesDir;
-    @GuardedBy("mDirsLock")
+
+    private final Object mPreferencesDirLock = new Object();
     @UnsupportedAppUsage
+    @GuardedBy("mPreferencesDirLock")
     private File mPreferencesDir;
-    @GuardedBy("mDirsLock")
+
+    private final Object mFilesDirLock = new Object();
+    @GuardedBy("mFilesDirLock")
     private File mFilesDir;
-    @GuardedBy("mDirsLock")
+
+    private final Object mCratesDirLock = new Object();
+    @GuardedBy("mCratesDirLock")
     private File mCratesDir;
-    @GuardedBy("mDirsLock")
+
+    private final Object mNoBackupFilesDirLock = new Object();
+    @GuardedBy("mNoBackupFilesDirLock")
     private File mNoBackupFilesDir;
-    @GuardedBy("mDirsLock")
+
+    private final Object mCacheDirLock = new Object();
+    @GuardedBy("mCacheDirLock")
     private File mCacheDir;
-    @GuardedBy("mDirsLock")
+
+    private final Object mCodeCacheDirLock = new Object();
+    @GuardedBy("mCodeCacheDirLock")
     private File mCodeCacheDir;
 
+    private final Object mMiscDirsLock = new Object();
+
     // The system service cache for the system services that are cached per-ContextImpl.
     @UnsupportedAppUsage
     final Object[] mServiceCache = SystemServiceRegistry.createServiceCache();
@@ -742,7 +756,7 @@
 
     @UnsupportedAppUsage
     private File getPreferencesDir() {
-        synchronized (mDirsLock) {
+        synchronized (mPreferencesDirLock) {
             if (mPreferencesDir == null) {
                 mPreferencesDir = new File(getDataDir(), "shared_prefs");
             }
@@ -831,7 +845,7 @@
 
     @Override
     public File getFilesDir() {
-        synchronized (mDirsLock) {
+        synchronized (mFilesDirLock) {
             if (mFilesDir == null) {
                 mFilesDir = new File(getDataDir(), "files");
             }
@@ -846,7 +860,7 @@
         final Path absoluteNormalizedCratePath = cratesRootPath.resolve(crateId)
                 .toAbsolutePath().normalize();
 
-        synchronized (mDirsLock) {
+        synchronized (mCratesDirLock) {
             if (mCratesDir == null) {
                 mCratesDir = cratesRootPath.toFile();
             }
@@ -859,7 +873,7 @@
 
     @Override
     public File getNoBackupFilesDir() {
-        synchronized (mDirsLock) {
+        synchronized (mNoBackupFilesDirLock) {
             if (mNoBackupFilesDir == null) {
                 mNoBackupFilesDir = new File(getDataDir(), "no_backup");
             }
@@ -876,7 +890,7 @@
 
     @Override
     public File[] getExternalFilesDirs(String type) {
-        synchronized (mDirsLock) {
+        synchronized (mMiscDirsLock) {
             File[] dirs = Environment.buildExternalStorageAppFilesDirs(getPackageName());
             if (type != null) {
                 dirs = Environment.buildPaths(dirs, type);
@@ -894,7 +908,7 @@
 
     @Override
     public File[] getObbDirs() {
-        synchronized (mDirsLock) {
+        synchronized (mMiscDirsLock) {
             File[] dirs = Environment.buildExternalStorageAppObbDirs(getPackageName());
             return ensureExternalDirsExistOrFilter(dirs, true /* tryCreateInProcess */);
         }
@@ -902,7 +916,7 @@
 
     @Override
     public File getCacheDir() {
-        synchronized (mDirsLock) {
+        synchronized (mCacheDirLock) {
             if (mCacheDir == null) {
                 mCacheDir = new File(getDataDir(), "cache");
             }
@@ -912,7 +926,7 @@
 
     @Override
     public File getCodeCacheDir() {
-        synchronized (mDirsLock) {
+        synchronized (mCodeCacheDirLock) {
             if (mCodeCacheDir == null) {
                 mCodeCacheDir = getCodeCacheDirBeforeBind(getDataDir());
             }
@@ -938,7 +952,7 @@
 
     @Override
     public File[] getExternalCacheDirs() {
-        synchronized (mDirsLock) {
+        synchronized (mMiscDirsLock) {
             File[] dirs = Environment.buildExternalStorageAppCacheDirs(getPackageName());
             // We don't try to create cache directories in-process, because they need special
             // setup for accurate quota tracking. This ensures the cache dirs are always
@@ -949,7 +963,7 @@
 
     @Override
     public File[] getExternalMediaDirs() {
-        synchronized (mDirsLock) {
+        synchronized (mMiscDirsLock) {
             File[] dirs = Environment.buildExternalStorageAppMediaDirs(getPackageName());
             return ensureExternalDirsExistOrFilter(dirs, true /* tryCreateInProcess */);
         }
@@ -1051,7 +1065,7 @@
     }
 
     private File getDatabasesDir() {
-        synchronized (mDirsLock) {
+        synchronized (mDatabasesDirLock) {
             if (mDatabasesDir == null) {
                 if ("android".equals(getPackageName())) {
                     mDatabasesDir = new File("/data/system");
diff --git a/core/java/android/app/SystemServiceRegistry.java b/core/java/android/app/SystemServiceRegistry.java
index 1cbec31..66ec865 100644
--- a/core/java/android/app/SystemServiceRegistry.java
+++ b/core/java/android/app/SystemServiceRegistry.java
@@ -450,6 +450,11 @@
                 new CachedServiceFetcher<VcnManager>() {
             @Override
             public VcnManager createService(ContextImpl ctx) throws ServiceNotFoundException {
+                if (!ctx.getPackageManager().hasSystemFeature(
+                        PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) {
+                    return null;
+                }
+
                 IBinder b = ServiceManager.getService(Context.VCN_MANAGEMENT_SERVICE);
                 IVcnManagementService service = IVcnManagementService.Stub.asInterface(b);
                 return new VcnManager(ctx, service);
@@ -1736,6 +1741,13 @@
         return fetcher;
     }
 
+    private static boolean hasSystemFeatureOpportunistic(@NonNull ContextImpl ctx,
+            @NonNull String featureName) {
+        PackageManager manager = ctx.getPackageManager();
+        if (manager == null) return true;
+        return manager.hasSystemFeature(featureName);
+    }
+
     /**
      * Gets a system service from a given context.
      * @hide
@@ -1758,12 +1770,18 @@
                 case Context.VIRTUALIZATION_SERVICE:
                 case Context.VIRTUAL_DEVICE_SERVICE:
                     return null;
-                case Context.SEARCH_SERVICE:
-                    // Wear device does not support SEARCH_SERVICE so we do not print WTF here
-                    PackageManager manager = ctx.getPackageManager();
-                    if (manager != null && manager.hasSystemFeature(PackageManager.FEATURE_WATCH)) {
+                case Context.VCN_MANAGEMENT_SERVICE:
+                    if (!hasSystemFeatureOpportunistic(ctx,
+                            PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) {
                         return null;
                     }
+                    break;
+                case Context.SEARCH_SERVICE:
+                    // Wear device does not support SEARCH_SERVICE so we do not print WTF here
+                    if (hasSystemFeatureOpportunistic(ctx, PackageManager.FEATURE_WATCH)) {
+                        return null;
+                    }
+                    break;
             }
             Slog.wtf(TAG, "Manager wrapper not available: " + name);
             return null;
diff --git a/core/java/android/app/ondeviceintelligence/IOnDeviceIntelligenceManager.aidl b/core/java/android/app/ondeviceintelligence/IOnDeviceIntelligenceManager.aidl
index 0dbe181..8bf288a 100644
--- a/core/java/android/app/ondeviceintelligence/IOnDeviceIntelligenceManager.aidl
+++ b/core/java/android/app/ondeviceintelligence/IOnDeviceIntelligenceManager.aidl
@@ -53,19 +53,22 @@
       void getFeatureDetails(in Feature feature, in IFeatureDetailsCallback featureDetailsCallback) = 4;
 
       @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.USE_ON_DEVICE_INTELLIGENCE)")
-      void requestFeatureDownload(in Feature feature, in ICancellationSignal signal, in IDownloadCallback callback) = 5;
+      void requestFeatureDownload(in Feature feature, in  AndroidFuture cancellationSignalFuture, in IDownloadCallback callback) = 5;
 
       @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.USE_ON_DEVICE_INTELLIGENCE)")
-      void requestTokenInfo(in Feature feature, in Bundle requestBundle, in  ICancellationSignal signal,
+      void requestTokenInfo(in Feature feature, in Bundle requestBundle, in  AndroidFuture cancellationSignalFuture,
                                                         in ITokenInfoCallback tokenInfocallback) = 6;
 
       @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.USE_ON_DEVICE_INTELLIGENCE)")
-      void processRequest(in Feature feature, in Bundle requestBundle, int requestType, in  ICancellationSignal cancellationSignal,
-                                                in IProcessingSignal signal, in IResponseCallback responseCallback) = 7;
+      void processRequest(in Feature feature, in Bundle requestBundle, int requestType,
+                                                in  AndroidFuture cancellationSignalFuture,
+                                                in AndroidFuture processingSignalFuture,
+                                                in IResponseCallback responseCallback) = 7;
 
       @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.USE_ON_DEVICE_INTELLIGENCE)")
       void processRequestStreaming(in Feature feature,
-                    in Bundle requestBundle, int requestType, in  ICancellationSignal cancellationSignal, in  IProcessingSignal signal,
+                    in Bundle requestBundle, int requestType, in  AndroidFuture cancellationSignalFuture,
+                    in  AndroidFuture processingSignalFuture,
                     in IStreamingResponseCallback streamingCallback) = 8;
 
       String getRemoteServicePackageName() = 9;
diff --git a/core/java/android/app/ondeviceintelligence/OnDeviceIntelligenceManager.java b/core/java/android/app/ondeviceintelligence/OnDeviceIntelligenceManager.java
index a465e3c..bc50d2e4 100644
--- a/core/java/android/app/ondeviceintelligence/OnDeviceIntelligenceManager.java
+++ b/core/java/android/app/ondeviceintelligence/OnDeviceIntelligenceManager.java
@@ -26,22 +26,23 @@
 import android.annotation.RequiresPermission;
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
-import android.content.ComponentName;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.os.Binder;
 import android.os.Bundle;
 import android.os.CancellationSignal;
+import android.os.IBinder;
 import android.os.ICancellationSignal;
 import android.os.OutcomeReceiver;
 import android.os.PersistableBundle;
 import android.os.RemoteCallback;
 import android.os.RemoteException;
 import android.system.OsConstants;
+import android.util.Log;
 
 import androidx.annotation.IntDef;
 
-import com.android.internal.R;
+import com.android.internal.infra.AndroidFuture;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
@@ -76,6 +77,8 @@
      */
     public static final String AUGMENT_REQUEST_CONTENT_BUNDLE_KEY =
             "AugmentRequestContentBundleKey";
+
+    private static final String TAG = "OnDeviceIntelligence";
     private final Context mContext;
     private final IOnDeviceIntelligenceManager mService;
 
@@ -121,9 +124,9 @@
     @RequiresPermission(Manifest.permission.USE_ON_DEVICE_INTELLIGENCE)
     public String getRemoteServicePackageName() {
         String result;
-        try{
-           result = mService.getRemoteServicePackageName();
-        } catch (RemoteException e){
+        try {
+            result = mService.getRemoteServicePackageName();
+        } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
         return result;
@@ -288,18 +291,15 @@
                 }
             };
 
-            ICancellationSignal transport = null;
-            if (cancellationSignal != null) {
-                transport = CancellationSignal.createTransport();
-                cancellationSignal.setRemote(transport);
-            }
-
-            mService.requestFeatureDownload(feature, transport, downloadCallback);
+            mService.requestFeatureDownload(feature,
+                    configureRemoteCancellationFuture(cancellationSignal, callbackExecutor),
+                    downloadCallback);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
     }
 
+
     /**
      * The methods computes the token related information for a given request payload using the
      * provided {@link Feature}.
@@ -337,13 +337,9 @@
                 }
             };
 
-            ICancellationSignal transport = null;
-            if (cancellationSignal != null) {
-                transport = CancellationSignal.createTransport();
-                cancellationSignal.setRemote(transport);
-            }
-
-            mService.requestTokenInfo(feature, request, transport, callback);
+            mService.requestTokenInfo(feature, request,
+                    configureRemoteCancellationFuture(cancellationSignal, callbackExecutor),
+                    callback);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -407,19 +403,9 @@
             };
 
 
-            IProcessingSignal transport = null;
-            if (processingSignal != null) {
-                transport = ProcessingSignal.createTransport();
-                processingSignal.setRemote(transport);
-            }
-
-            ICancellationSignal cancellationTransport = null;
-            if (cancellationSignal != null) {
-                cancellationTransport = CancellationSignal.createTransport();
-                cancellationSignal.setRemote(cancellationTransport);
-            }
-
-            mService.processRequest(feature, request, requestType, cancellationTransport, transport,
+            mService.processRequest(feature, request, requestType,
+                    configureRemoteCancellationFuture(cancellationSignal, callbackExecutor),
+                    configureRemoteProcessingSignalFuture(processingSignal, callbackExecutor),
                     callback);
 
         } catch (RemoteException e) {
@@ -449,7 +435,8 @@
      * @param callbackExecutor          executor to run the callback on.
      */
     @RequiresPermission(Manifest.permission.USE_ON_DEVICE_INTELLIGENCE)
-    public void processRequestStreaming(@NonNull Feature feature, @NonNull @InferenceParams Bundle request,
+    public void processRequestStreaming(@NonNull Feature feature,
+            @NonNull @InferenceParams Bundle request,
             @RequestType int requestType,
             @Nullable CancellationSignal cancellationSignal,
             @Nullable ProcessingSignal processingSignal,
@@ -500,20 +487,11 @@
                 }
             };
 
-            IProcessingSignal transport = null;
-            if (processingSignal != null) {
-                transport = ProcessingSignal.createTransport();
-                processingSignal.setRemote(transport);
-            }
-
-            ICancellationSignal cancellationTransport = null;
-            if (cancellationSignal != null) {
-                cancellationTransport = CancellationSignal.createTransport();
-                cancellationSignal.setRemote(cancellationTransport);
-            }
-
             mService.processRequestStreaming(
-                    feature, request, requestType, cancellationTransport, transport, callback);
+                    feature, request, requestType,
+                    configureRemoteCancellationFuture(cancellationSignal, callbackExecutor),
+                    configureRemoteProcessingSignalFuture(processingSignal, callbackExecutor),
+                    callback);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -574,4 +552,45 @@
     @Target({ElementType.PARAMETER, ElementType.FIELD})
     public @interface InferenceParams {
     }
+
+
+    @Nullable
+    private static AndroidFuture<IBinder> configureRemoteCancellationFuture(
+            @Nullable CancellationSignal cancellationSignal,
+            @NonNull Executor callbackExecutor) {
+        if (cancellationSignal == null) {
+            return null;
+        }
+        AndroidFuture<IBinder> cancellationFuture = new AndroidFuture<>();
+        cancellationFuture.whenCompleteAsync(
+                (cancellationTransport, error) -> {
+                    if (error != null || cancellationTransport == null) {
+                        Log.e(TAG, "Unable to receive the remote cancellation signal.", error);
+                    } else {
+                        cancellationSignal.setRemote(
+                                ICancellationSignal.Stub.asInterface(cancellationTransport));
+                    }
+                }, callbackExecutor);
+        return cancellationFuture;
+    }
+
+    @Nullable
+    private static AndroidFuture<IBinder> configureRemoteProcessingSignalFuture(
+            ProcessingSignal processingSignal, Executor executor) {
+        if (processingSignal == null) {
+            return null;
+        }
+        AndroidFuture<IBinder> processingSignalFuture = new AndroidFuture<>();
+        processingSignalFuture.whenCompleteAsync(
+                (transport, error) -> {
+                    if (error != null || transport == null) {
+                        Log.e(TAG, "Unable to receive the remote processing signal.", error);
+                    } else {
+                        processingSignal.setRemote(IProcessingSignal.Stub.asInterface(transport));
+                    }
+                }, executor);
+        return processingSignalFuture;
+    }
+
+
 }
diff --git a/core/java/android/app/ondeviceintelligence/ProcessingSignal.java b/core/java/android/app/ondeviceintelligence/ProcessingSignal.java
index c275cc7..733f4fa 100644
--- a/core/java/android/app/ondeviceintelligence/ProcessingSignal.java
+++ b/core/java/android/app/ondeviceintelligence/ProcessingSignal.java
@@ -123,10 +123,10 @@
      * Sets the processing signal callback to be called when signals are received.
      *
      * This method is intended to be used by the recipient of a processing signal
-     * such as the remote implementation for {@link OnDeviceIntelligenceManager} to handle
-     * cancellation requests while performing a long-running operation.  This method is not
-     * intended
-     * to be used by applications themselves.
+     * such as the remote implementation in
+     * {@link android.service.ondeviceintelligence.OnDeviceSandboxedInferenceService} to handle
+     * processing signals while performing a long-running operation.  This method is not
+     * intended to be used by the caller themselves.
      *
      * If {@link ProcessingSignal#sendSignal} has already been called, then the provided callback
      * is invoked immediately and all previously queued actions are passed to remote signal.
@@ -200,7 +200,7 @@
     }
 
     /**
-     * Given a locally created transport, returns its associated cancellation signal.
+     * Given a locally created transport, returns its associated processing signal.
      *
      * @param transport The locally created transport, or null if none.
      * @return The associated processing signal, or null if none.
diff --git a/core/java/android/companion/virtual/IVirtualDevice.aidl b/core/java/android/companion/virtual/IVirtualDevice.aidl
index 6eab363..30a1135 100644
--- a/core/java/android/companion/virtual/IVirtualDevice.aidl
+++ b/core/java/android/companion/virtual/IVirtualDevice.aidl
@@ -79,6 +79,11 @@
     int getDevicePolicy(int policyType);
 
     /**
+    * Returns whether the device has a valid microphone.
+    */
+    boolean hasCustomAudioInputSupport();
+
+    /**
      * Closes the virtual device and frees all associated resources.
      */
     @EnforcePermission("CREATE_VIRTUAL_DEVICE")
diff --git a/core/java/android/companion/virtual/VirtualDevice.java b/core/java/android/companion/virtual/VirtualDevice.java
index 97fa2ba..b9e9afe 100644
--- a/core/java/android/companion/virtual/VirtualDevice.java
+++ b/core/java/android/companion/virtual/VirtualDevice.java
@@ -17,7 +17,6 @@
 package android.companion.virtual;
 
 import static android.companion.virtual.VirtualDeviceParams.DEVICE_POLICY_CUSTOM;
-import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_AUDIO;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CAMERA;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_SENSORS;
 
@@ -176,8 +175,7 @@
     @FlaggedApi(Flags.FLAG_VDM_PUBLIC_APIS)
     public boolean hasCustomAudioInputSupport() {
         try {
-            return mVirtualDevice.getDevicePolicy(POLICY_TYPE_AUDIO) == DEVICE_POLICY_CUSTOM;
-            // TODO(b/291735254): also check for a custom audio injection mix for this device id.
+            return mVirtualDevice.hasCustomAudioInputSupport();
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
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/content/Context.java b/core/java/android/content/Context.java
index 89300e3..284e318 100644
--- a/core/java/android/content/Context.java
+++ b/core/java/android/content/Context.java
@@ -4208,7 +4208,7 @@
             MEDIA_COMMUNICATION_SERVICE,
             BATTERY_SERVICE,
             JOB_SCHEDULER_SERVICE,
-            //@hide: PERSISTENT_DATA_BLOCK_SERVICE,
+            PERSISTENT_DATA_BLOCK_SERVICE,
             //@hide: OEM_LOCK_SERVICE,
             MEDIA_PROJECTION_SERVICE,
             MIDI_SERVICE,
@@ -5930,9 +5930,8 @@
      *
      * @see #getSystemService(String)
      * @see android.service.persistentdata.PersistentDataBlockManager
-     * @hide
      */
-    @SystemApi
+    @FlaggedApi(android.security.Flags.FLAG_FRP_ENFORCEMENT)
     public static final String PERSISTENT_DATA_BLOCK_SERVICE = "persistent_data_block";
 
     /**
diff --git a/core/java/android/content/pm/PackageManager.java b/core/java/android/content/pm/PackageManager.java
index 9f2f74b..b5809cf 100644
--- a/core/java/android/content/pm/PackageManager.java
+++ b/core/java/android/content/pm/PackageManager.java
@@ -895,7 +895,7 @@
             GET_DISABLED_COMPONENTS,
             GET_DISABLED_UNTIL_USED_COMPONENTS,
             GET_UNINSTALLED_PACKAGES,
-            MATCH_CLONE_PROFILE,
+            MATCH_CLONE_PROFILE_LONG,
             MATCH_QUARANTINED_COMPONENTS,
     })
     @Retention(RetentionPolicy.SOURCE)
@@ -1235,10 +1235,11 @@
     public static final int MATCH_DEBUG_TRIAGED_MISSING = MATCH_DIRECT_BOOT_AUTO;
 
     /**
-     * Use {@link #MATCH_CLONE_PROFILE_LONG} instead.
+     * @deprecated Use {@link #MATCH_CLONE_PROFILE_LONG} instead.
      *
      * @hide
      */
+    @Deprecated
     @SystemApi
     public static final int MATCH_CLONE_PROFILE = 0x20000000;
 
diff --git a/core/java/android/credentials/flags.aconfig b/core/java/android/credentials/flags.aconfig
index 47edba6..16ca31f 100644
--- a/core/java/android/credentials/flags.aconfig
+++ b/core/java/android/credentials/flags.aconfig
@@ -47,6 +47,7 @@
     name: "configurable_selector_ui_enabled"
     description: "Enables OEM configurable Credential Selector UI"
     bug: "319448437"
+    is_exported: true
 }
 
 flag {
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/hardware/camera2/params/SessionConfiguration.java b/core/java/android/hardware/camera2/params/SessionConfiguration.java
index b0f354f..3b2913c 100644
--- a/core/java/android/hardware/camera2/params/SessionConfiguration.java
+++ b/core/java/android/hardware/camera2/params/SessionConfiguration.java
@@ -133,7 +133,7 @@
      * {@link CameraDeviceSetup.isSessionConfigurationSupported} and {@link
      * CameraDeviceSetup.getSessionCharacteristics} to query a camera device's feature
      * combination support and session specific characteristics. For the SessionConfiguration
-     * object to be used to create a capture session, {@link #setCallback} must be called to
+     * object to be used to create a capture session, {@link #setStateCallback} must be called to
      * specify the state callback function, and any incomplete OutputConfigurations must be
      * completed via {@link OutputConfiguration#addSurface} or
      * {@link OutputConfiguration#setSurfacesForMultiResolutionOutput} as appropriate.</p>
@@ -419,7 +419,7 @@
      * @param cb A state callback interface implementation.
      */
     @FlaggedApi(Flags.FLAG_CAMERA_DEVICE_SETUP)
-    public void setCallback(
+    public void setStateCallback(
             @NonNull @CallbackExecutor Executor executor,
             @NonNull CameraCaptureSession.StateCallback cb) {
         mStateCallback = cb;
diff --git a/core/java/android/hardware/camera2/params/StreamConfigurationMap.java b/core/java/android/hardware/camera2/params/StreamConfigurationMap.java
index b067095..978a8f9 100644
--- a/core/java/android/hardware/camera2/params/StreamConfigurationMap.java
+++ b/core/java/android/hardware/camera2/params/StreamConfigurationMap.java
@@ -1473,6 +1473,11 @@
      * <li>ImageFormat.DEPTH_JPEG => HAL_DATASPACE_DYNAMIC_DEPTH
      * <li>ImageFormat.HEIC => HAL_DATASPACE_HEIF
      * <li>ImageFormat.JPEG_R => HAL_DATASPACE_JPEG_R
+     * <li>ImageFormat.YUV_420_888 => HAL_DATASPACE_JFIF
+     * <li>ImageFormat.RAW_SENSOR => HAL_DATASPACE_ARBITRARY
+     * <li>ImageFormat.RAW_OPAQUE => HAL_DATASPACE_ARBITRARY
+     * <li>ImageFormat.RAW10 => HAL_DATASPACE_ARBITRARY
+     * <li>ImageFormat.RAW12 => HAL_DATASPACE_ARBITRARY
      * <li>others => HAL_DATASPACE_UNKNOWN
      * </ul>
      * </p>
@@ -1511,6 +1516,11 @@
                 return HAL_DATASPACE_JPEG_R;
             case ImageFormat.YUV_420_888:
                 return HAL_DATASPACE_JFIF;
+            case ImageFormat.RAW_SENSOR:
+            case ImageFormat.RAW_PRIVATE:
+            case ImageFormat.RAW10:
+            case ImageFormat.RAW12:
+                return HAL_DATASPACE_ARBITRARY;
             default:
                 return HAL_DATASPACE_UNKNOWN;
         }
@@ -2005,6 +2015,12 @@
     private static final int HAL_DATASPACE_RANGE_SHIFT = 27;
 
     private static final int HAL_DATASPACE_UNKNOWN = 0x0;
+
+    /**
+     * @hide
+     */
+    public static final int HAL_DATASPACE_ARBITRARY = 0x1;
+
     /** @hide */
     public static final int HAL_DATASPACE_V0_JFIF =
             (2 << HAL_DATASPACE_STANDARD_SHIFT) |
diff --git a/core/java/android/hardware/devicestate/DeviceState.java b/core/java/android/hardware/devicestate/DeviceState.java
index b214da2..689e343 100644
--- a/core/java/android/hardware/devicestate/DeviceState.java
+++ b/core/java/android/hardware/devicestate/DeviceState.java
@@ -173,7 +173,7 @@
     public static final int PROPERTY_FEATURE_DUAL_DISPLAY_INTERNAL_DEFAULT = 17;
 
     /** @hide */
-    @IntDef(prefix = {"PROPERTY_"}, flag = true, value = {
+    @IntDef(prefix = {"PROPERTY_"}, flag = false, value = {
             PROPERTY_FOLDABLE_HARDWARE_CONFIGURATION_FOLD_IN_CLOSED,
             PROPERTY_FOLDABLE_HARDWARE_CONFIGURATION_FOLD_IN_HALF_OPEN,
             PROPERTY_FOLDABLE_HARDWARE_CONFIGURATION_FOLD_IN_OPEN,
@@ -197,7 +197,7 @@
     public @interface DeviceStateProperties {}
 
     /** @hide */
-    @IntDef(prefix = {"PROPERTY_"}, flag = true, value = {
+    @IntDef(prefix = {"PROPERTY_"}, flag = false, value = {
             PROPERTY_FOLDABLE_HARDWARE_CONFIGURATION_FOLD_IN_CLOSED,
             PROPERTY_FOLDABLE_HARDWARE_CONFIGURATION_FOLD_IN_HALF_OPEN,
             PROPERTY_FOLDABLE_HARDWARE_CONFIGURATION_FOLD_IN_OPEN
@@ -207,7 +207,7 @@
     public @interface PhysicalDeviceStateProperties {}
 
     /** @hide */
-    @IntDef(prefix = {"PROPERTY_"}, flag = true, value = {
+    @IntDef(prefix = {"PROPERTY_"}, flag = false, value = {
             PROPERTY_POLICY_CANCEL_OVERRIDE_REQUESTS,
             PROPERTY_POLICY_CANCEL_WHEN_REQUESTER_NOT_ON_TOP,
             PROPERTY_POLICY_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL,
diff --git a/core/java/android/os/Process.java b/core/java/android/os/Process.java
index 7020a38..db06a6b 100644
--- a/core/java/android/os/Process.java
+++ b/core/java/android/os/Process.java
@@ -48,6 +48,7 @@
 import java.io.FileDescriptor;
 import java.io.IOException;
 import java.util.Map;
+import java.util.NoSuchElementException;
 import java.util.concurrent.TimeoutException;
 
 /**
@@ -588,6 +589,8 @@
      **/
     public static final int THREAD_GROUP_RESTRICTED = 7;
 
+    /** @hide */
+    public static final int SIGNAL_DEFAULT = 0;
     public static final int SIGNAL_QUIT = 3;
     public static final int SIGNAL_KILL = 9;
     public static final int SIGNAL_USR1 = 10;
@@ -1437,6 +1440,49 @@
         sendSignal(pid, SIGNAL_KILL);
     }
 
+    /**
+     * Check the tgid and tid pair to see if the tid still exists and belong to the tgid.
+     *
+     * TOCTOU warning: the status of the tid can change at the time this method returns. This should
+     * be used in very rare cases such as checking if a (tid, tgid) pair that is known to exist
+     * recently no longer exists now. As the possibility of the same tid to be reused under the same
+     * tgid during a short window is rare. And even if it happens the caller logic should be robust
+     * to handle it without error.
+     *
+     * @throws IllegalArgumentException if tgid or tid is not positive.
+     * @throws SecurityException if the caller doesn't have the permission, this method is expected
+     *                           to be used by system process with {@link #SYSTEM_UID} because it
+     *                           internally uses tkill(2).
+     * @throws NoSuchElementException if the Linux process with pid as the tid has exited or it
+     *                                doesn't belong to the tgid.
+     * @hide
+     */
+    public static final void checkTid(int tgid, int tid)
+            throws IllegalArgumentException, SecurityException, NoSuchElementException {
+        sendTgSignalThrows(tgid, tid, SIGNAL_DEFAULT);
+    }
+
+    /**
+     * Check if the pid still exists.
+     *
+     * TOCTOU warning: the status of the pid can change at the time this method returns. This should
+     * be used in very rare cases such as checking if a pid that belongs to an isolated process of a
+     * uid known to exist recently no longer exists now. As the possibility of the same pid to be
+     * reused again under the same uid during a short window is rare. And even if it happens the
+     * caller logic should be robust to handle it without error.
+     *
+     * @throws IllegalArgumentException if pid is not positive.
+     * @throws SecurityException if the caller doesn't have the permission, this method is expected
+     *                           to be used by system process with {@link #SYSTEM_UID} because it
+     *                           internally uses kill(2).
+     * @throws NoSuchElementException if the Linux process with the pid has exited.
+     * @hide
+     */
+    public static final void checkPid(int pid)
+            throws IllegalArgumentException, SecurityException, NoSuchElementException {
+        sendSignalThrows(pid, SIGNAL_DEFAULT);
+    }
+
     /** @hide */
     public static final native int setUid(int uid);
 
@@ -1451,6 +1497,12 @@
      */
     public static final native void sendSignal(int pid, int signal);
 
+    private static native void sendSignalThrows(int pid, int signal)
+            throws IllegalArgumentException, SecurityException, NoSuchElementException;
+
+    private static native void sendTgSignalThrows(int pid, int tgid, int signal)
+            throws IllegalArgumentException, SecurityException, NoSuchElementException;
+
     /**
      * @hide
      * Private impl for avoiding a log message...  DO NOT USE without doing
diff --git a/core/java/android/os/Trace.java b/core/java/android/os/Trace.java
index bebb912..edb3a64 100644
--- a/core/java/android/os/Trace.java
+++ b/core/java/android/os/Trace.java
@@ -125,15 +125,15 @@
     @UnsupportedAppUsage
     @CriticalNative
     @android.ravenwood.annotation.RavenwoodReplace
-    private static native long nativeGetEnabledTags();
+    private static native boolean nativeIsTagEnabled(long tag);
     @android.ravenwood.annotation.RavenwoodReplace
     private static native void nativeSetAppTracingAllowed(boolean allowed);
     @android.ravenwood.annotation.RavenwoodReplace
     private static native void nativeSetTracingEnabled(boolean allowed);
 
-    private static long nativeGetEnabledTags$ravenwood() {
+    private static boolean nativeIsTagEnabled$ravenwood(long traceTag) {
         // Tracing currently completely disabled under Ravenwood
-        return 0;
+        return false;
     }
 
     private static void nativeSetAppTracingAllowed$ravenwood(boolean allowed) {
@@ -181,8 +181,7 @@
     @UnsupportedAppUsage
     @SystemApi(client = MODULE_LIBRARIES)
     public static boolean isTagEnabled(long traceTag) {
-        long tags = nativeGetEnabledTags();
-        return (tags & traceTag) != 0;
+        return nativeIsTagEnabled(traceTag);
     }
 
     /**
diff --git a/core/java/android/os/UserManager.java b/core/java/android/os/UserManager.java
index 84619a0..f172c3e 100644
--- a/core/java/android/os/UserManager.java
+++ b/core/java/android/os/UserManager.java
@@ -3188,6 +3188,8 @@
      * @return whether the context user can add a private profile.
      * @hide
      */
+    @TestApi
+    @FlaggedApi(android.os.Flags.FLAG_ALLOW_PRIVATE_PROFILE)
     @RequiresPermission(anyOf = {
             Manifest.permission.MANAGE_USERS,
             Manifest.permission.CREATE_USERS},
diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java
index 6917d69..25c2b0e 100644
--- a/core/java/android/provider/Settings.java
+++ b/core/java/android/provider/Settings.java
@@ -11090,21 +11090,12 @@
                 "assist_long_press_home_enabled";
 
         /**
-         * Whether press and hold on nav handle can trigger search.
+         * Whether all entrypoints can trigger search. Replaces individual settings.
          *
          * @hide
          */
-        public static final String SEARCH_PRESS_HOLD_NAV_HANDLE_ENABLED =
-                "search_press_hold_nav_handle_enabled";
-
-        /**
-         * Whether long-pressing on the home button can trigger search.
-         *
-         * @hide
-         */
-        public static final String SEARCH_LONG_PRESS_HOME_ENABLED =
-                "search_long_press_home_enabled";
-
+        public static final String SEARCH_ALL_ENTRYPOINTS_ENABLED =
+                "search_all_entrypoints_enabled";
 
         /**
          * Whether or not the accessibility data streaming is enbled for the
diff --git a/core/java/android/service/chooser/flags.aconfig b/core/java/android/service/chooser/flags.aconfig
index d72441f..00236df 100644
--- a/core/java/android/service/chooser/flags.aconfig
+++ b/core/java/android/service/chooser/flags.aconfig
@@ -27,14 +27,3 @@
   description: "Provides additional callbacks with information about user actions in ChooserResult"
   bug: "263474465"
 }
-
-flag {
-  name: "legacy_chooser_pinning_removal"
-  namespace: "intentresolver"
-  description: "Removing pinning functionality from the legacy chooser (used by partial screenshare)"
-  bug: "301068735"
-  metadata {
-    purpose: PURPOSE_BUGFIX
-  }
-}
-
diff --git a/core/java/android/service/ondeviceintelligence/IOnDeviceIntelligenceService.aidl b/core/java/android/service/ondeviceintelligence/IOnDeviceIntelligenceService.aidl
index 6dbff71..908ab5f 100644
--- a/core/java/android/service/ondeviceintelligence/IOnDeviceIntelligenceService.aidl
+++ b/core/java/android/service/ondeviceintelligence/IOnDeviceIntelligenceService.aidl
@@ -41,7 +41,9 @@
     void getFeatureDetails(int callerUid, in Feature feature, in IFeatureDetailsCallback featureDetailsCallback);
     void getReadOnlyFileDescriptor(in String fileName, in AndroidFuture<ParcelFileDescriptor> future);
     void getReadOnlyFeatureFileDescriptorMap(in Feature feature, in RemoteCallback remoteCallback);
-    void requestFeatureDownload(int callerUid, in Feature feature, in ICancellationSignal cancellationSignal, in IDownloadCallback downloadCallback);
+    void requestFeatureDownload(int callerUid, in Feature feature,
+                                in AndroidFuture<ICancellationSignal> cancellationSignal,
+                                in IDownloadCallback downloadCallback);
     void registerRemoteServices(in IRemoteProcessingService remoteProcessingService);
     void notifyInferenceServiceConnected();
     void notifyInferenceServiceDisconnected();
diff --git a/core/java/android/service/ondeviceintelligence/IOnDeviceSandboxedInferenceService.aidl b/core/java/android/service/ondeviceintelligence/IOnDeviceSandboxedInferenceService.aidl
index 799c7545..4213a09 100644
--- a/core/java/android/service/ondeviceintelligence/IOnDeviceSandboxedInferenceService.aidl
+++ b/core/java/android/service/ondeviceintelligence/IOnDeviceSandboxedInferenceService.aidl
@@ -24,6 +24,7 @@
 import android.os.ICancellationSignal;
 import android.os.PersistableBundle;
 import android.os.Bundle;
+import com.android.internal.infra.AndroidFuture;
 import android.service.ondeviceintelligence.IRemoteStorageService;
 import android.service.ondeviceintelligence.IProcessingUpdateStatusCallback;
 
@@ -34,13 +35,16 @@
  */
 oneway interface IOnDeviceSandboxedInferenceService {
     void registerRemoteStorageService(in IRemoteStorageService storageService);
-    void requestTokenInfo(int callerUid, in Feature feature, in Bundle request, in ICancellationSignal cancellationSignal,
+    void requestTokenInfo(int callerUid, in Feature feature, in Bundle request,
+                            in AndroidFuture<ICancellationSignal> cancellationSignal,
                             in ITokenInfoCallback tokenInfoCallback);
     void processRequest(int callerUid, in Feature feature, in Bundle request, in int requestType,
-                        in ICancellationSignal cancellationSignal, in IProcessingSignal processingSignal,
+                        in AndroidFuture<ICancellationSignal> cancellationSignal,
+                        in AndroidFuture<IProcessingSignal> processingSignal,
                         in IResponseCallback callback);
     void processRequestStreaming(int callerUid, in Feature feature, in Bundle request, in int requestType,
-                                in ICancellationSignal cancellationSignal, in IProcessingSignal processingSignal,
+                                in AndroidFuture<ICancellationSignal> cancellationSignal,
+                                in AndroidFuture<IProcessingSignal> processingSignal,
                                 in IStreamingResponseCallback callback);
     void updateProcessingState(in Bundle processingState,
                                      in IProcessingUpdateStatusCallback callback);
diff --git a/core/java/android/service/ondeviceintelligence/OnDeviceIntelligenceService.java b/core/java/android/service/ondeviceintelligence/OnDeviceIntelligenceService.java
index 9321318..86320b8 100644
--- a/core/java/android/service/ondeviceintelligence/OnDeviceIntelligenceService.java
+++ b/core/java/android/service/ondeviceintelligence/OnDeviceIntelligenceService.java
@@ -148,14 +148,18 @@
 
                 @Override
                 public void requestFeatureDownload(int callerUid, Feature feature,
-                        ICancellationSignal cancellationSignal,
+                        AndroidFuture cancellationSignalFuture,
                         IDownloadCallback downloadCallback) {
                     Objects.requireNonNull(feature);
                     Objects.requireNonNull(downloadCallback);
-
+                    ICancellationSignal transport = null;
+                    if (cancellationSignalFuture != null) {
+                        transport = CancellationSignal.createTransport();
+                        cancellationSignalFuture.complete(transport);
+                    }
                     OnDeviceIntelligenceService.this.onDownloadFeature(callerUid,
                             feature,
-                            CancellationSignal.fromTransport(cancellationSignal),
+                            CancellationSignal.fromTransport(transport),
                             wrapDownloadCallback(downloadCallback));
                 }
 
diff --git a/core/java/android/service/ondeviceintelligence/OnDeviceSandboxedInferenceService.java b/core/java/android/service/ondeviceintelligence/OnDeviceSandboxedInferenceService.java
index fc7a4c8..96c45ee 100644
--- a/core/java/android/service/ondeviceintelligence/OnDeviceSandboxedInferenceService.java
+++ b/core/java/android/service/ondeviceintelligence/OnDeviceSandboxedInferenceService.java
@@ -122,46 +122,72 @@
 
                 @Override
                 public void requestTokenInfo(int callerUid, Feature feature, Bundle request,
-                        ICancellationSignal cancellationSignal,
+                        AndroidFuture cancellationSignalFuture,
                         ITokenInfoCallback tokenInfoCallback) {
                     Objects.requireNonNull(feature);
                     Objects.requireNonNull(tokenInfoCallback);
+                    ICancellationSignal transport = null;
+                    if (cancellationSignalFuture != null) {
+                        transport = CancellationSignal.createTransport();
+                        cancellationSignalFuture.complete(transport);
+                    }
                     OnDeviceSandboxedInferenceService.this.onTokenInfoRequest(callerUid,
                             feature,
                             request,
-                            CancellationSignal.fromTransport(cancellationSignal),
+                            CancellationSignal.fromTransport(transport),
                             wrapTokenInfoCallback(tokenInfoCallback));
                 }
 
                 @Override
                 public void processRequestStreaming(int callerUid, Feature feature, Bundle request,
-                        int requestType, ICancellationSignal cancellationSignal,
-                        IProcessingSignal processingSignal,
+                        int requestType,
+                        AndroidFuture cancellationSignalFuture,
+                        AndroidFuture processingSignalFuture,
                         IStreamingResponseCallback callback) {
                     Objects.requireNonNull(feature);
                     Objects.requireNonNull(callback);
 
+                    ICancellationSignal transport = null;
+                    if (cancellationSignalFuture != null) {
+                        transport = CancellationSignal.createTransport();
+                        cancellationSignalFuture.complete(transport);
+                    }
+                    IProcessingSignal processingSignalTransport = null;
+                    if (processingSignalFuture != null) {
+                        processingSignalTransport = ProcessingSignal.createTransport();
+                        processingSignalFuture.complete(processingSignalTransport);
+                    }
                     OnDeviceSandboxedInferenceService.this.onProcessRequestStreaming(callerUid,
                             feature,
                             request,
                             requestType,
-                            CancellationSignal.fromTransport(cancellationSignal),
-                            ProcessingSignal.fromTransport(processingSignal),
+                            CancellationSignal.fromTransport(transport),
+                            ProcessingSignal.fromTransport(processingSignalTransport),
                             wrapStreamingResponseCallback(callback));
                 }
 
                 @Override
                 public void processRequest(int callerUid, Feature feature, Bundle request,
-                        int requestType, ICancellationSignal cancellationSignal,
-                        IProcessingSignal processingSignal,
+                        int requestType,
+                        AndroidFuture cancellationSignalFuture,
+                        AndroidFuture processingSignalFuture,
                         IResponseCallback callback) {
                     Objects.requireNonNull(feature);
                     Objects.requireNonNull(callback);
-
+                    ICancellationSignal transport = null;
+                    if (cancellationSignalFuture != null) {
+                        transport = CancellationSignal.createTransport();
+                        cancellationSignalFuture.complete(transport);
+                    }
+                    IProcessingSignal processingSignalTransport = null;
+                    if (processingSignalFuture != null) {
+                        processingSignalTransport = ProcessingSignal.createTransport();
+                        processingSignalFuture.complete(processingSignalTransport);
+                    }
                     OnDeviceSandboxedInferenceService.this.onProcessRequest(callerUid, feature,
                             request, requestType,
-                            CancellationSignal.fromTransport(cancellationSignal),
-                            ProcessingSignal.fromTransport(processingSignal),
+                            CancellationSignal.fromTransport(transport),
+                            ProcessingSignal.fromTransport(processingSignalTransport),
                             wrapResponseCallback(callback));
                 }
 
@@ -206,7 +232,8 @@
      * Invoked when caller provides a request for a particular feature to be processed in a
      * streaming manner. The expectation from the implementation is that when processing the
      * request,
-     * it periodically populates the {@link StreamingProcessingCallback#onPartialResult} to continuously
+     * it periodically populates the {@link StreamingProcessingCallback#onPartialResult} to
+     * continuously
      * provide partial Bundle results for the caller to utilize. Optionally the implementation can
      * provide the complete response in the {@link StreamingProcessingCallback#onResult} upon
      * processing completion.
diff --git a/core/java/android/text/flags/flags.aconfig b/core/java/android/text/flags/flags.aconfig
index aff1d4a..30b1a2e 100644
--- a/core/java/android/text/flags/flags.aconfig
+++ b/core/java/android/text/flags/flags.aconfig
@@ -121,8 +121,22 @@
 }
 
 flag {
+  name: "handwriting_end_of_line_tap"
+  namespace: "text"
+  description: "Initiate handwriting when stylus taps at the end of a line in a focused non-empty TextView with the cursor at the end of that line"
+  bug: "323376217"
+}
+
+flag {
   name: "handwriting_cursor_position"
   namespace: "text"
   description: "When handwriting is initiated in an unfocused TextView, cursor is placed at the end of the closest paragraph."
   bug: "323376217"
 }
+
+flag {
+  name: "handwriting_unsupported_message"
+  namespace: "text"
+  description: "Feature flag for showing error message when user tries stylus handwriting on a text field which doesn't support it"
+  bug: "297962571"
+}
diff --git a/core/java/android/view/HandwritingInitiator.java b/core/java/android/view/HandwritingInitiator.java
index 29c8350..192b2ec 100644
--- a/core/java/android/view/HandwritingInitiator.java
+++ b/core/java/android/view/HandwritingInitiator.java
@@ -34,7 +34,9 @@
 import android.widget.EditText;
 import android.widget.Editor;
 import android.widget.TextView;
+import android.widget.Toast;
 
+import com.android.internal.R;
 import com.android.internal.annotations.VisibleForTesting;
 
 import java.lang.ref.WeakReference;
@@ -223,7 +225,24 @@
                     View candidateView = findBestCandidateView(mState.mStylusDownX,
                             mState.mStylusDownY, /* isHover */ false);
                     if (candidateView != null && candidateView.isEnabled()) {
-                        if (candidateView == getConnectedOrFocusedView()) {
+                        if (shouldShowHandwritingUnavailableMessageForView(candidateView)) {
+                            int messagesResId = (candidateView instanceof TextView tv
+                                    && tv.isAnyPasswordInputType())
+                                    ? R.string.error_handwriting_unsupported_password
+                                    : R.string.error_handwriting_unsupported;
+                            Toast.makeText(candidateView.getContext(), messagesResId,
+                                    Toast.LENGTH_SHORT).show();
+                            if (!candidateView.hasFocus()) {
+                                requestFocusWithoutReveal(candidateView);
+                            }
+                            mImm.showSoftInput(candidateView, 0);
+                            mState.mHandled = true;
+                            mState.mShouldInitHandwriting = false;
+                            motionEvent.setAction((motionEvent.getAction()
+                                    & MotionEvent.ACTION_POINTER_INDEX_MASK)
+                                    | MotionEvent.ACTION_CANCEL);
+                            candidateView.getRootView().dispatchTouchEvent(motionEvent);
+                        } else if (candidateView == getConnectedOrFocusedView()) {
                             if (!mInitiateWithoutConnection && !candidateView.hasFocus()) {
                                 requestFocusWithoutReveal(candidateView);
                             }
@@ -484,6 +503,15 @@
         return view.isStylusHandwritingAvailable();
     }
 
+    private static boolean shouldShowHandwritingUnavailableMessageForView(@NonNull View view) {
+        return (view instanceof TextView) && !shouldTriggerStylusHandwritingForView(view);
+    }
+
+    private static boolean shouldTriggerHandwritingOrShowUnavailableMessageForView(
+            @NonNull View view) {
+        return (view instanceof TextView) || shouldTriggerStylusHandwritingForView(view);
+    }
+
     /**
      * Returns the pointer icon for the motion event, or null if it doesn't specify the icon.
      * This gives HandwritingInitiator a chance to show the stylus handwriting icon over a
@@ -491,7 +519,7 @@
      */
     public PointerIcon onResolvePointerIcon(Context context, MotionEvent event) {
         final View hoverView = findHoverView(event);
-        if (hoverView == null) {
+        if (hoverView == null || !shouldTriggerStylusHandwritingForView(hoverView)) {
             return null;
         }
 
@@ -594,7 +622,7 @@
 
     /**
      * Given the location of the stylus event, return the best candidate view to initialize
-     * handwriting mode.
+     * handwriting mode or show the handwriting unavailable error message.
      *
      * @param x the x coordinates of the stylus event, in the coordinates of the window.
      * @param y the y coordinates of the stylus event, in the coordinates of the window.
@@ -610,7 +638,8 @@
             Rect handwritingArea = mTempRect;
             if (getViewHandwritingArea(connectedOrFocusedView, handwritingArea)
                     && isInHandwritingArea(handwritingArea, x, y, connectedOrFocusedView, isHover)
-                    && shouldTriggerStylusHandwritingForView(connectedOrFocusedView)) {
+                    && shouldTriggerHandwritingOrShowUnavailableMessageForView(
+                            connectedOrFocusedView)) {
                 if (!isHover && mState != null) {
                     mState.mStylusDownWithinEditorBounds =
                             contains(handwritingArea, x, y, 0f, 0f, 0f, 0f);
@@ -628,7 +657,7 @@
             final View view = viewInfo.getView();
             final Rect handwritingArea = viewInfo.getHandwritingArea();
             if (!isInHandwritingArea(handwritingArea, x, y, view, isHover)
-                    || !shouldTriggerStylusHandwritingForView(view)) {
+                    || !shouldTriggerHandwritingOrShowUnavailableMessageForView(view)) {
                 continue;
             }
 
@@ -856,7 +885,7 @@
     /** The helper method to check if the given view is still active for handwriting. */
     private static boolean isViewActive(@Nullable View view) {
         return view != null && view.isAttachedToWindow() && view.isAggregatedVisible()
-                && view.shouldInitiateHandwriting();
+                && view.shouldTrackHandwritingArea();
     }
 
     private CursorAnchorInfo getCursorAnchorInfoForConnectionless(View view) {
diff --git a/core/java/android/view/IWindowSession.aidl b/core/java/android/view/IWindowSession.aidl
index e126836..3a90841 100644
--- a/core/java/android/view/IWindowSession.aidl
+++ b/core/java/android/view/IWindowSession.aidl
@@ -47,6 +47,19 @@
  * {@hide}
  */
 interface IWindowSession {
+
+    /**
+     * Bundle key to store the latest sync seq id for the relayout configuration.
+     * @see #relayout
+     */
+    const String KEY_RELAYOUT_BUNDLE_SEQID = "seqid";
+    /**
+     * Bundle key to store the latest ActivityWindowInfo associated with the relayout configuration.
+     * Will only be set if the relayout window is an activity window.
+     * @see #relayout
+     */
+    const String KEY_RELAYOUT_BUNDLE_ACTIVITY_WINDOW_INFO = "activity_window_info";
+
     int addToDisplay(IWindow window, in WindowManager.LayoutParams attrs,
             in int viewVisibility, in int layerStackId, int requestedVisibleTypes,
             out InputChannel outInputChannel, out InsetsState insetsState,
@@ -92,7 +105,7 @@
      * @param outSurfaceControl Object in which is placed the new display surface.
      * @param insetsState The current insets state in the system.
      * @param activeControls Objects which allow controlling {@link InsetsSource}s.
-     * @param bundle A temporary object to obtain the latest SyncSeqId.
+     * @param bundle A Bundle to contain the latest SyncSeqId and any extra relayout optional infos.
      * @return int Result flags, defined in {@link WindowManagerGlobal}.
      */
     int relayout(IWindow window, in WindowManager.LayoutParams attrs,
diff --git a/core/java/android/view/OWNERS b/core/java/android/view/OWNERS
index a2f767d..07d05a4 100644
--- a/core/java/android/view/OWNERS
+++ b/core/java/android/view/OWNERS
@@ -75,12 +75,14 @@
 per-file View.java = file:/services/core/java/com/android/server/input/OWNERS
 per-file View.java = file:/services/core/java/com/android/server/wm/OWNERS
 per-file View.java = file:/core/java/android/view/inputmethod/OWNERS
+per-file View.java = file:/core/java/android/text/OWNERS
 per-file ViewRootImpl.java = file:/services/accessibility/OWNERS
 per-file ViewRootImpl.java = file:/core/java/android/service/autofill/OWNERS
 per-file ViewRootImpl.java = file:/graphics/java/android/graphics/OWNERS
 per-file ViewRootImpl.java = file:/services/core/java/com/android/server/input/OWNERS
 per-file ViewRootImpl.java = file:/services/core/java/com/android/server/wm/OWNERS
 per-file ViewRootImpl.java = file:/core/java/android/view/inputmethod/OWNERS
+per-file ViewRootImpl.java = file:/core/java/android/text/OWNERS
 per-file AccessibilityInteractionController.java = file:/services/accessibility/OWNERS
 per-file OnReceiveContentListener.java = file:/core/java/android/service/autofill/OWNERS
 per-file OnReceiveContentListener.java = file:/core/java/android/widget/OWNERS
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index 0a75f4e..41bfb24 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -12695,7 +12695,7 @@
         if (getSystemGestureExclusionRects().isEmpty()
                 && collectPreferKeepClearRects().isEmpty()
                 && collectUnrestrictedPreferKeepClearRects().isEmpty()
-                && (info.mHandwritingArea == null || !shouldInitiateHandwriting())) {
+                && (info.mHandwritingArea == null || !shouldTrackHandwritingArea())) {
             if (info.mPositionUpdateListener != null) {
                 mRenderNode.removePositionUpdateListener(info.mPositionUpdateListener);
                 info.mPositionUpdateListener = null;
@@ -13062,7 +13062,7 @@
 
     void updateHandwritingArea() {
         // If autoHandwritingArea is not enabled, do nothing.
-        if (!shouldInitiateHandwriting()) return;
+        if (!shouldTrackHandwritingArea()) return;
         final AttachInfo ai = mAttachInfo;
         if (ai != null) {
             ai.mViewRootImpl.getHandwritingInitiator().updateHandwritingAreasForView(this);
@@ -13080,6 +13080,16 @@
     }
 
     /**
+     * Returns whether the handwriting initiator should track the handwriting area for this view,
+     * either to initiate handwriting mode, or to prepare handwriting delegation, or to show the
+     * handwriting unsupported message.
+     * @hide
+     */
+    public boolean shouldTrackHandwritingArea() {
+        return shouldInitiateHandwriting();
+    }
+
+    /**
      * Sets a callback which should be called when a stylus {@link MotionEvent} occurs within this
      * view's bounds. The callback will be called from the UI thread.
      *
diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java
index cae6672..304e43e 100644
--- a/core/java/android/view/ViewRootImpl.java
+++ b/core/java/android/view/ViewRootImpl.java
@@ -8933,7 +8933,8 @@
                     mTempInsets, mTempControls, mRelayoutBundle);
             mRelayoutRequested = true;
 
-            final int maybeSyncSeqId = mRelayoutBundle.getInt("seqid");
+            final int maybeSyncSeqId = mRelayoutBundle.getInt(
+                    IWindowSession.KEY_RELAYOUT_BUNDLE_SEQID);
             if (maybeSyncSeqId > 0) {
                 mSyncSeqId = maybeSyncSeqId;
             }
diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java
index 0373539..dc060ba 100644
--- a/core/java/android/widget/TextView.java
+++ b/core/java/android/widget/TextView.java
@@ -13118,6 +13118,16 @@
             return superResult;
         }
 
+        // At this point, the event is not a long press, otherwise it would be handled above.
+        if (Flags.handwritingEndOfLineTap() && action == MotionEvent.ACTION_UP
+                && shouldStartHandwritingForEndOfLineTap(event)) {
+            InputMethodManager imm = getInputMethodManager();
+            if (imm != null) {
+                imm.startStylusHandwriting(this);
+                return true;
+            }
+        }
+
         final boolean touchIsFinished = (action == MotionEvent.ACTION_UP)
                 && (mEditor == null || !mEditor.mIgnoreActionUpEvent) && isFocused();
 
@@ -13167,6 +13177,46 @@
     }
 
     /**
+     * If handwriting is supported, the TextView is already focused and not empty, and the cursor is
+     * at the end of a line, a stylus tap after the end of the line will trigger handwriting.
+     */
+    private boolean shouldStartHandwritingForEndOfLineTap(MotionEvent actionUpEvent) {
+        if (!onCheckIsTextEditor()
+                || !isEnabled()
+                || !isAutoHandwritingEnabled()
+                || TextUtils.isEmpty(mText)
+                || didTouchFocusSelect()
+                || mLayout == null
+                || !actionUpEvent.isStylusPointer()) {
+            return false;
+        }
+        int cursorOffset = getSelectionStart();
+        if (cursorOffset < 0 || getSelectionEnd() != cursorOffset) {
+            return false;
+        }
+        int cursorLine = mLayout.getLineForOffset(cursorOffset);
+        int cursorLineEnd = mLayout.getLineEnd(cursorLine);
+        if (cursorLine != mLayout.getLineCount() - 1) {
+            cursorLineEnd--;
+        }
+        if (cursorLineEnd != cursorOffset) {
+            return false;
+        }
+        // Check that the stylus down point is within the same line as the cursor.
+        if (getLineAtCoordinate(actionUpEvent.getY()) != cursorLine) {
+            return false;
+        }
+        // Check that the stylus down point is after the end of the line.
+        float localX = convertToLocalHorizontalCoordinate(actionUpEvent.getX());
+        if (mLayout.getParagraphDirection(cursorLine) == Layout.DIR_RIGHT_TO_LEFT
+                ? localX >= mLayout.getLineLeft(cursorLine)
+                : localX <= mLayout.getLineRight(cursorLine)) {
+            return false;
+        }
+        return isStylusHandwritingAvailable();
+    }
+
+    /**
      * Returns true when need to show UIs, e.g. floating toolbar, etc, for finger based interaction.
      *
      * @return true if UIs need to show for finger interaciton. false if UIs are not necessary.
@@ -13565,6 +13615,15 @@
 
     /** @hide */
     @Override
+    public boolean shouldTrackHandwritingArea() {
+        // The handwriting initiator tracks all editable TextViews regardless of whether handwriting
+        // is supported, so that it can show an error message for unsupported editable TextViews.
+        return super.shouldTrackHandwritingArea()
+                || (Flags.handwritingUnsupportedMessage() && onCheckIsTextEditor());
+    }
+
+    /** @hide */
+    @Override
     public boolean isStylusHandwritingAvailable() {
         if (mTextOperationUser == null) {
             return super.isStylusHandwritingAvailable();
diff --git a/core/java/android/window/TaskFragmentOperation.java b/core/java/android/window/TaskFragmentOperation.java
index 7e77f15..43df4f9 100644
--- a/core/java/android/window/TaskFragmentOperation.java
+++ b/core/java/android/window/TaskFragmentOperation.java
@@ -112,10 +112,13 @@
     /**
      * Creates a decor surface in the parent Task of the TaskFragment. The created decor surface
      * will be provided in {@link TaskFragmentTransaction#TYPE_TASK_FRAGMENT_PARENT_INFO_CHANGED}
-     * event callback. The decor surface can be used to draw the divider between TaskFragments or
-     * other decorations.
+     * event callback. If a decor surface already exists in the parent Task, the current
+     * TaskFragment will become the new owner of the decor surface and the decor surface will be
+     * moved above the TaskFragment.
+     *
+     * The decor surface can be used to draw the divider between TaskFragments or other decorations.
      */
-    public static final int OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE = 14;
+    public static final int OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE = 14;
 
     /**
      * Removes the decor surface in the parent Task of the TaskFragment.
@@ -162,7 +165,7 @@
             OP_TYPE_SET_ISOLATED_NAVIGATION,
             OP_TYPE_REORDER_TO_BOTTOM_OF_TASK,
             OP_TYPE_REORDER_TO_TOP_OF_TASK,
-            OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE,
+            OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE,
             OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE,
             OP_TYPE_SET_DIM_ON_TASK,
             OP_TYPE_SET_MOVE_TO_BOTTOM_IF_CLEAR_WHEN_LAUNCH,
diff --git a/core/java/android/window/WindowTokenClient.java b/core/java/android/window/WindowTokenClient.java
index 7f5331b..4a3aba1 100644
--- a/core/java/android/window/WindowTokenClient.java
+++ b/core/java/android/window/WindowTokenClient.java
@@ -165,7 +165,8 @@
             Log.d(TAG, "Configuration not dispatch to IME because configuration is up"
                     + " to date. Current config=" + context.getResources().getConfiguration()
                     + ", reported config=" + currentConfig
-                    + ", updated config=" + newConfig);
+                    + ", updated config=" + newConfig
+                    + ", updated display ID=" + newDisplayId);
         }
         // Update display first. In case callers want to obtain display information(
         // ex: DisplayMetrics) in #onConfigurationChanged callback.
@@ -190,13 +191,18 @@
             if (mShouldDumpConfigForIme) {
                 if (!shouldReportConfigChange) {
                     Log.d(TAG, "Only apply configuration update to Resources because "
-                            + "shouldReportConfigChange is false.\n" + Debug.getCallers(5));
+                            + "shouldReportConfigChange is false. "
+                            + "context=" + context
+                            + ", config=" + context.getResources().getConfiguration()
+                            + ", display ID=" + context.getDisplayId() + "\n"
+                            + Debug.getCallers(5));
                 } else if (diff == 0) {
                     Log.d(TAG, "Configuration not dispatch to IME because configuration has no "
                             + " public difference with updated config. "
                             + " Current config=" + context.getResources().getConfiguration()
                             + ", reported config=" + currentConfig
-                            + ", updated config=" + newConfig);
+                            + ", updated config=" + newConfig
+                            + ", display ID=" + context.getDisplayId());
                 }
             }
         }
diff --git a/core/java/android/window/flags/windowing_frontend.aconfig b/core/java/android/window/flags/windowing_frontend.aconfig
index 14fb17c..65bf241 100644
--- a/core/java/android/window/flags/windowing_frontend.aconfig
+++ b/core/java/android/window/flags/windowing_frontend.aconfig
@@ -38,6 +38,17 @@
 }
 
 flag {
+  name: "skip_sleeping_when_switching_display"
+  namespace: "windowing_frontend"
+  description: "Reduce unnecessary visibility or lifecycle changes when changing fold state"
+  bug: "303241079"
+  is_fixed_read_only: true
+  metadata {
+    purpose: PURPOSE_BUGFIX
+  }
+}
+
+flag {
   name: "introduce_smoother_dimmer"
   namespace: "windowing_frontend"
   description: "Refactor dim to fix flickers"
diff --git a/core/java/com/android/internal/accessibility/dialog/AccessibilityShortcutChooserActivity.java b/core/java/com/android/internal/accessibility/dialog/AccessibilityShortcutChooserActivity.java
index 2e80b7e..c70febb 100644
--- a/core/java/com/android/internal/accessibility/dialog/AccessibilityShortcutChooserActivity.java
+++ b/core/java/com/android/internal/accessibility/dialog/AccessibilityShortcutChooserActivity.java
@@ -20,7 +20,6 @@
 import static android.view.accessibility.AccessibilityManager.ShortcutType;
 
 import static com.android.internal.accessibility.common.ShortcutConstants.ShortcutMenuMode;
-import static com.android.internal.accessibility.dialog.AccessibilityTargetHelper.createEnableDialogContentView;
 import static com.android.internal.accessibility.dialog.AccessibilityTargetHelper.getInstalledTargets;
 import static com.android.internal.accessibility.dialog.AccessibilityTargetHelper.getTargets;
 import static com.android.internal.accessibility.util.AccessibilityUtils.isUserSetupCompleted;
@@ -115,39 +114,22 @@
     private void onTargetChecked(AdapterView<?> parent, View view, int position, long id) {
         final AccessibilityTarget target = mTargets.get(position);
 
-        if (Flags.cleanupAccessibilityWarningDialog()) {
-            if (target instanceof AccessibilityServiceTarget serviceTarget) {
-                if (sendRestrictedDialogIntentIfNeeded(target)) {
-                    return;
-                }
-                final AccessibilityManager am = getSystemService(AccessibilityManager.class);
-                if (am.isAccessibilityServiceWarningRequired(
-                        serviceTarget.getAccessibilityServiceInfo())) {
-                    showPermissionDialogIfNeeded(this, (AccessibilityServiceTarget) target,
-                            position, mTargetAdapter);
-                    return;
-                }
+        if (target instanceof AccessibilityServiceTarget serviceTarget) {
+            if (sendRestrictedDialogIntentIfNeeded(target)) {
+                return;
             }
-            if (target instanceof AccessibilityActivityTarget activityTarget) {
-                if (!activityTarget.isShortcutEnabled()
-                        && sendRestrictedDialogIntentIfNeeded(activityTarget)) {
-                    return;
-                }
+            final AccessibilityManager am = getSystemService(AccessibilityManager.class);
+            if (am.isAccessibilityServiceWarningRequired(
+                    serviceTarget.getAccessibilityServiceInfo())) {
+                showPermissionDialogIfNeeded(this, (AccessibilityServiceTarget) target,
+                        position, mTargetAdapter);
+                return;
             }
-        } else {
-            if (!target.isShortcutEnabled()) {
-                if (target instanceof AccessibilityServiceTarget
-                        || target instanceof AccessibilityActivityTarget) {
-                    if (sendRestrictedDialogIntentIfNeeded(target)) {
-                        return;
-                    }
-                }
-
-                if (target instanceof AccessibilityServiceTarget) {
-                    showPermissionDialogIfNeeded(this, (AccessibilityServiceTarget) target,
-                            position, mTargetAdapter);
-                    return;
-                }
+        }
+        if (target instanceof AccessibilityActivityTarget activityTarget) {
+            if (!activityTarget.isShortcutEnabled()
+                    && sendRestrictedDialogIntentIfNeeded(activityTarget)) {
+                return;
             }
         }
 
@@ -178,37 +160,25 @@
             return;
         }
 
-        if (Flags.cleanupAccessibilityWarningDialog()) {
-            mPermissionDialog = AccessibilityServiceWarning
-                    .createAccessibilityServiceWarningDialog(context,
-                            serviceTarget.getAccessibilityServiceInfo(),
-                            v -> {
-                                serviceTarget.onCheckedChanged(true);
-                                targetAdapter.notifyDataSetChanged();
-                                mPermissionDialog.dismiss();
-                            }, v -> {
-                                serviceTarget.onCheckedChanged(false);
-                                mPermissionDialog.dismiss();
-                            },
-                            v -> {
-                                mTargets.remove(position);
-                                context.getPackageManager().getPackageInstaller().uninstall(
-                                        serviceTarget.getComponentName().getPackageName(), null);
-                                targetAdapter.notifyDataSetChanged();
-                                mPermissionDialog.dismiss();
-                            });
-            mPermissionDialog.setOnDismissListener(dialog -> mPermissionDialog = null);
-        } else {
-            mPermissionDialog = new AlertDialog.Builder(context)
-                    .setView(createEnableDialogContentView(context, serviceTarget,
-                            v -> {
-                                mPermissionDialog.dismiss();
-                                targetAdapter.notifyDataSetChanged();
-                            },
-                            v -> mPermissionDialog.dismiss()))
-                    .setOnDismissListener(dialog -> mPermissionDialog = null)
-                    .create();
-        }
+        mPermissionDialog = AccessibilityServiceWarning
+                .createAccessibilityServiceWarningDialog(context,
+                        serviceTarget.getAccessibilityServiceInfo(),
+                        v -> {
+                            serviceTarget.onCheckedChanged(true);
+                            targetAdapter.notifyDataSetChanged();
+                            mPermissionDialog.dismiss();
+                        }, v -> {
+                            serviceTarget.onCheckedChanged(false);
+                            mPermissionDialog.dismiss();
+                        },
+                        v -> {
+                            mTargets.remove(position);
+                            context.getPackageManager().getPackageInstaller().uninstall(
+                                    serviceTarget.getComponentName().getPackageName(), null);
+                            targetAdapter.notifyDataSetChanged();
+                            mPermissionDialog.dismiss();
+                        });
+        mPermissionDialog.setOnDismissListener(dialog -> mPermissionDialog = null);
         mPermissionDialog.show();
     }
 
diff --git a/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java b/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java
index 3d3db47..0d82d63 100644
--- a/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java
+++ b/core/java/com/android/internal/accessibility/dialog/AccessibilityTargetHelper.java
@@ -37,14 +37,8 @@
 import android.os.Build;
 import android.os.UserHandle;
 import android.provider.Settings;
-import android.text.BidiFormatter;
-import android.view.LayoutInflater;
-import android.view.View;
 import android.view.accessibility.AccessibilityManager;
 import android.view.accessibility.AccessibilityManager.ShortcutType;
-import android.widget.Button;
-import android.widget.ImageView;
-import android.widget.TextView;
 
 import com.android.internal.R;
 import com.android.internal.accessibility.common.ShortcutConstants.AccessibilityFragmentType;
@@ -52,7 +46,6 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.Locale;
 
 /**
  * Collection of utilities for accessibility target.
@@ -298,50 +291,6 @@
     }
 
     /**
-     * @deprecated Use {@link AccessibilityServiceWarning}.
-     */
-    @Deprecated
-    static View createEnableDialogContentView(Context context,
-            AccessibilityServiceTarget target, View.OnClickListener allowListener,
-            View.OnClickListener denyListener) {
-        final LayoutInflater inflater = (LayoutInflater) context.getSystemService(
-                Context.LAYOUT_INFLATER_SERVICE);
-
-        final View content = inflater.inflate(
-                R.layout.accessibility_enable_service_warning, /* root= */ null);
-
-        final ImageView dialogIcon = content.findViewById(
-                R.id.accessibility_permissionDialog_icon);
-        dialogIcon.setImageDrawable(target.getIcon());
-
-        final TextView dialogTitle = content.findViewById(
-                R.id.accessibility_permissionDialog_title);
-        dialogTitle.setText(context.getString(R.string.accessibility_enable_service_title,
-                getServiceName(context, target.getLabel())));
-
-        final Button allowButton = content.findViewById(
-                R.id.accessibility_permission_enable_allow_button);
-        final Button denyButton = content.findViewById(
-                R.id.accessibility_permission_enable_deny_button);
-        allowButton.setOnClickListener((view) -> {
-            target.onCheckedChanged(/* isChecked= */ true);
-            allowListener.onClick(view);
-        });
-        denyButton.setOnClickListener((view) -> {
-            target.onCheckedChanged(/* isChecked= */ false);
-            denyListener.onClick(view);
-        });
-
-        return content;
-    }
-
-    // Gets the service name and bidi wrap it to protect from bidi side effects.
-    private static CharSequence getServiceName(Context context, CharSequence label) {
-        final Locale locale = context.getResources().getConfiguration().getLocales().get(0);
-        return BidiFormatter.getInstance(locale).unicodeWrap(label);
-    }
-
-    /**
      * Determines if the{@link AccessibilityTarget} is allowed.
      */
     public static boolean isAccessibilityTargetAllowed(Context context, String packageName,
diff --git a/core/java/com/android/internal/app/ChooserActivity.java b/core/java/com/android/internal/app/ChooserActivity.java
index 29669d3..ab456a8 100644
--- a/core/java/com/android/internal/app/ChooserActivity.java
+++ b/core/java/com/android/internal/app/ChooserActivity.java
@@ -96,7 +96,6 @@
 import android.provider.OpenableColumns;
 import android.provider.Settings;
 import android.service.chooser.ChooserTarget;
-import android.service.chooser.Flags;
 import android.text.TextUtils;
 import android.util.AttributeSet;
 import android.util.HashedStringCache;
@@ -1801,54 +1800,6 @@
         return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true);
     }
 
-    private void showTargetDetails(TargetInfo targetInfo) {
-        if (targetInfo == null) return;
-
-        ArrayList<DisplayResolveInfo> targetList;
-        ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment();
-        Bundle bundle = new Bundle();
-
-        if (targetInfo instanceof SelectableTargetInfo) {
-            SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo;
-            if (selectableTargetInfo.getDisplayResolveInfo() == null
-                    || selectableTargetInfo.getChooserTarget() == null) {
-                Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null");
-                return;
-            }
-            targetList = new ArrayList<>();
-            targetList.add(selectableTargetInfo.getDisplayResolveInfo());
-            bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY,
-                    selectableTargetInfo.getChooserTarget().getIntentExtras().getString(
-                            Intent.EXTRA_SHORTCUT_ID));
-            bundle.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY,
-                    selectableTargetInfo.isPinned());
-            bundle.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY,
-                    getTargetIntentFilter());
-            if (selectableTargetInfo.getDisplayLabel() != null) {
-                bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY,
-                        selectableTargetInfo.getDisplayLabel().toString());
-            }
-        } else if (targetInfo instanceof MultiDisplayResolveInfo) {
-            // For multiple targets, include info on all targets
-            MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo;
-            targetList = mti.getTargets();
-        } else {
-            targetList = new ArrayList<DisplayResolveInfo>();
-            targetList.add((DisplayResolveInfo) targetInfo);
-        }
-        // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be
-        // resolved correctly.
-        bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY,
-                getResolveInfoUserHandle(
-                        targetInfo.getResolveInfo(),
-                        mChooserMultiProfilePagerAdapter.getCurrentUserHandle()));
-        bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY,
-                targetList);
-        fragment.setArguments(bundle);
-
-        fragment.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG);
-    }
-
     private void modifyTargetIntent(Intent in) {
         if (isSendAction(in)) {
             in.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT |
@@ -2544,10 +2495,7 @@
 
         @Override
         public boolean isComponentPinned(ComponentName name) {
-            if (Flags.legacyChooserPinningRemoval()) {
-                return false;
-            }
-            return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false);
+            return false;
         }
 
         @Override
@@ -3135,34 +3083,10 @@
             if (isClickable) {
                 itemView.setOnClickListener(v -> startSelected(mListPosition,
                         false/* always */, true/* filterd */));
-
-                itemView.setOnLongClickListener(v -> {
-                    final TargetInfo ti = mChooserMultiProfilePagerAdapter.getActiveListAdapter()
-                            .targetInfoForPosition(mListPosition, /* filtered */ true);
-
-                    // This should always be the case for ItemViewHolder, check for validity
-                    if (ti instanceof DisplayResolveInfo && shouldShowTargetDetails(ti)) {
-                        showTargetDetails((DisplayResolveInfo) ti);
-                    }
-                    return true;
-                });
             }
         }
     }
 
-    private boolean shouldShowTargetDetails(TargetInfo ti) {
-        if (Flags.legacyChooserPinningRemoval()) {
-            // Never show the long press menu if we've removed pinning.
-            return false;
-        }
-        ComponentName nearbyShare = getNearbySharingComponent();
-        //  Suppress target details for nearby share to hide pin/unpin action
-        boolean isNearbyShare = nearbyShare != null && nearbyShare.equals(
-                ti.getResolvedComponentName()) && shouldNearbyShareBeFirstInRankedRow();
-        return ti instanceof SelectableTargetInfo
-                || (ti instanceof DisplayResolveInfo && !isNearbyShare);
-    }
-
     /**
      * Add a footer to the list, to support scrolling behavior below the navbar.
      */
@@ -3517,16 +3441,6 @@
                     }
                 });
 
-                // Show menu for both direct share and app share targets after long click.
-                v.setOnLongClickListener(v1 -> {
-                    TargetInfo ti = mChooserListAdapter.targetInfoForPosition(
-                            holder.getItemIndex(column), true);
-                    if (shouldShowTargetDetails(ti)) {
-                        showTargetDetails(ti);
-                    }
-                    return true;
-                });
-
                 holder.addView(i, v);
 
                 // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll =
diff --git a/core/jni/android_media_AudioSystem.cpp b/core/jni/android_media_AudioSystem.cpp
index 5223798..d48cdc4 100644
--- a/core/jni/android_media_AudioSystem.cpp
+++ b/core/jni/android_media_AudioSystem.cpp
@@ -161,6 +161,7 @@
     jfieldID mMixType;
     jfieldID mCallbackFlags;
     jfieldID mToken;
+    jfieldID mVirtualDeviceId;
 } gAudioMixFields;
 
 static jclass gAudioFormatClass;
@@ -2312,7 +2313,7 @@
     jstring deviceAddress = env->NewStringUTF(nAudioMix.mDeviceAddress.c_str());
     *jAudioMix = env->NewObject(gAudioMixClass, gAudioMixCstor, jAudioMixingRule, jAudioFormat,
                                 nAudioMix.mRouteFlags, nAudioMix.mCbFlags, nAudioMix.mDeviceType,
-                                deviceAddress, jBinderToken);
+                                deviceAddress, jBinderToken, nAudioMix.mVirtualDeviceId);
     return AUDIO_JAVA_SUCCESS;
 }
 
@@ -2347,6 +2348,7 @@
             aiBinder(AIBinder_fromJavaBinder(env, jToken), &AIBinder_decStrong);
     nAudioMix->mToken = AIBinder_toPlatformBinder(aiBinder.get());
 
+    nAudioMix->mVirtualDeviceId = env->GetIntField(jAudioMix, gAudioMixFields.mVirtualDeviceId);
     jint status = convertAudioMixingRuleToNative(env, jRule, &(nAudioMix->mCriteria));
 
     env->DeleteLocalRef(jRule);
@@ -3676,7 +3678,7 @@
         gAudioMixCstor =
                 GetMethodIDOrDie(env, audioMixClass, "<init>",
                                  "(Landroid/media/audiopolicy/AudioMixingRule;Landroid/"
-                                 "media/AudioFormat;IIILjava/lang/String;Landroid/os/IBinder;)V");
+                                 "media/AudioFormat;IIILjava/lang/String;Landroid/os/IBinder;I)V");
     }
     gAudioMixFields.mRule = GetFieldIDOrDie(env, audioMixClass, "mRule",
                                                 "Landroid/media/audiopolicy/AudioMixingRule;");
@@ -3689,6 +3691,7 @@
     gAudioMixFields.mMixType = GetFieldIDOrDie(env, audioMixClass, "mMixType", "I");
     gAudioMixFields.mCallbackFlags = GetFieldIDOrDie(env, audioMixClass, "mCallbackFlags", "I");
     gAudioMixFields.mToken = GetFieldIDOrDie(env, audioMixClass, "mToken", "Landroid/os/IBinder;");
+    gAudioMixFields.mVirtualDeviceId = GetFieldIDOrDie(env, audioMixClass, "mVirtualDeviceId", "I");
 
     jclass audioFormatClass = FindClassOrDie(env, "android/media/AudioFormat");
     gAudioFormatClass = MakeGlobalRefOrDie(env, audioFormatClass);
diff --git a/core/jni/android_os_Trace.cpp b/core/jni/android_os_Trace.cpp
index b579daf..4387a4c 100644
--- a/core/jni/android_os_Trace.cpp
+++ b/core/jni/android_os_Trace.cpp
@@ -124,8 +124,8 @@
     });
 }
 
-static jlong android_os_Trace_nativeGetEnabledTags(JNIEnv* env) {
-    return tracing_perfetto::getEnabledCategories();
+static jboolean android_os_Trace_nativeIsTagEnabled(jlong tag) {
+    return tracing_perfetto::isTagEnabled(tag);
 }
 
 static void android_os_Trace_nativeRegisterWithPerfetto(JNIEnv* env) {
@@ -157,7 +157,7 @@
         {"nativeRegisterWithPerfetto", "()V", (void*)android_os_Trace_nativeRegisterWithPerfetto},
 
         // ----------- @CriticalNative  ----------------
-        {"nativeGetEnabledTags", "()J", (void*)android_os_Trace_nativeGetEnabledTags},
+        {"nativeIsTagEnabled", "(J)Z", (void*)android_os_Trace_nativeIsTagEnabled},
 };
 
 int register_android_os_Trace(JNIEnv* env) {
diff --git a/core/jni/android_util_Process.cpp b/core/jni/android_util_Process.cpp
index d2e58bb..982189e 100644
--- a/core/jni/android_util_Process.cpp
+++ b/core/jni/android_util_Process.cpp
@@ -1137,6 +1137,41 @@
     }
 }
 
+void android_os_Process_sendSignalThrows(JNIEnv* env, jobject clazz, jint pid, jint sig) {
+    if (pid <= 0) {
+        jniThrowExceptionFmt(env, "java/lang/IllegalArgumentException", "Invalid argument: pid(%d)",
+                             pid);
+        return;
+    }
+    int ret = kill(pid, sig);
+    if (ret < 0) {
+        if (errno == ESRCH) {
+            jniThrowExceptionFmt(env, "java/util/NoSuchElementException",
+                                 "Process with pid %d not found", pid);
+        } else {
+            signalExceptionForError(env, errno, pid);
+        }
+    }
+}
+
+void android_os_Process_sendTgSignalThrows(JNIEnv* env, jobject clazz, jint tgid, jint tid,
+                                           jint sig) {
+    if (tgid <= 0 || tid <= 0) {
+        jniThrowExceptionFmt(env, "java/lang/IllegalArgumentException",
+                             "Invalid argument: tgid(%d), tid(%d)", tid, tgid);
+        return;
+    }
+    int ret = tgkill(tgid, tid, sig);
+    if (ret < 0) {
+        if (errno == ESRCH) {
+            jniThrowExceptionFmt(env, "java/util/NoSuchElementException",
+                                 "Process with tid %d and tgid %d not found", tid, tgid);
+        } else {
+            signalExceptionForError(env, errno, tid);
+        }
+    }
+}
+
 static jlong android_os_Process_getElapsedCpuTime(JNIEnv* env, jobject clazz)
 {
     struct timespec ts;
@@ -1357,6 +1392,8 @@
         {"setGid", "(I)I", (void*)android_os_Process_setGid},
         {"sendSignal", "(II)V", (void*)android_os_Process_sendSignal},
         {"sendSignalQuiet", "(II)V", (void*)android_os_Process_sendSignalQuiet},
+        {"sendSignalThrows", "(II)V", (void*)android_os_Process_sendSignalThrows},
+        {"sendTgSignalThrows", "(III)V", (void*)android_os_Process_sendTgSignalThrows},
         {"setProcessFrozen", "(IIZ)V", (void*)android_os_Process_setProcessFrozen},
         {"getFreeMemory", "()J", (void*)android_os_Process_getFreeMemory},
         {"getTotalMemory", "()J", (void*)android_os_Process_getTotalMemory},
diff --git a/core/proto/android/providers/settings/secure.proto b/core/proto/android/providers/settings/secure.proto
index 763d9ce..6b0c2d2 100644
--- a/core/proto/android/providers/settings/secure.proto
+++ b/core/proto/android/providers/settings/secure.proto
@@ -143,9 +143,11 @@
         optional SettingProto gesture_setup_complete = 9 [ (android.privacy).dest = DEST_AUTOMATIC ];
         optional SettingProto touch_gesture_enabled = 10 [ (android.privacy).dest = DEST_AUTOMATIC ];
         optional SettingProto long_press_home_enabled = 11 [ (android.privacy).dest = DEST_AUTOMATIC ];
-        optional SettingProto search_press_hold_nav_handle_enabled = 12 [ (android.privacy).dest = DEST_AUTOMATIC ];
-        optional SettingProto search_long_press_home_enabled = 13 [ (android.privacy).dest = DEST_AUTOMATIC ];
+        // Deprecated - use search_all_entrypoints_enabled instead
+        optional SettingProto search_press_hold_nav_handle_enabled = 12 [ (android.privacy).dest = DEST_AUTOMATIC, deprecated = true  ];
+        optional SettingProto search_long_press_home_enabled = 13 [ (android.privacy).dest = DEST_AUTOMATIC, deprecated = true  ];
         optional SettingProto visual_query_accessibility_detection_enabled = 14 [ (android.privacy).dest = DEST_AUTOMATIC ];
+        optional SettingProto search_all_entrypoints_enabled = 15 [ (android.privacy).dest = DEST_AUTOMATIC ];
     }
     optional Assist assist = 7;
 
diff --git a/core/res/res/drawable/activity_embedding_divider_handle.xml b/core/res/res/drawable/activity_embedding_divider_handle.xml
new file mode 100644
index 0000000..d9f363c
--- /dev/null
+++ b/core/res/res/drawable/activity_embedding_divider_handle.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 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.
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_pressed="true"
+        android:drawable="@drawable/activity_embedding_divider_handle_pressed" />
+    <item android:drawable="@drawable/activity_embedding_divider_handle_default" />
+</selector>
\ No newline at end of file
diff --git a/core/res/res/drawable/activity_embedding_divider_handle_default.xml b/core/res/res/drawable/activity_embedding_divider_handle_default.xml
new file mode 100644
index 0000000..565f671
--- /dev/null
+++ b/core/res/res/drawable/activity_embedding_divider_handle_default.xml
@@ -0,0 +1,23 @@
+<!--
+  Copyright 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.
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <corners android:radius="@dimen/activity_embedding_divider_handle_radius" />
+    <size
+        android:width="@dimen/activity_embedding_divider_handle_width"
+        android:height="@dimen/activity_embedding_divider_handle_height" />
+    <solid android:color="@color/activity_embedding_divider_color" />
+</shape>
\ No newline at end of file
diff --git a/core/res/res/drawable/activity_embedding_divider_handle_pressed.xml b/core/res/res/drawable/activity_embedding_divider_handle_pressed.xml
new file mode 100644
index 0000000..e5cca239
--- /dev/null
+++ b/core/res/res/drawable/activity_embedding_divider_handle_pressed.xml
@@ -0,0 +1,23 @@
+<!--
+  Copyright 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.
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <corners android:radius="@dimen/activity_embedding_divider_handle_radius_pressed" />
+    <size
+        android:width="@dimen/activity_embedding_divider_handle_width_pressed"
+        android:height="@dimen/activity_embedding_divider_handle_height_pressed" />
+    <solid android:color="@color/activity_embedding_divider_color_pressed" />
+</shape>
\ No newline at end of file
diff --git a/core/res/res/drawable/autofill_dataset_picker_background.xml b/core/res/res/drawable/autofill_dataset_picker_background.xml
index d574970..6c4ef11 100644
--- a/core/res/res/drawable/autofill_dataset_picker_background.xml
+++ b/core/res/res/drawable/autofill_dataset_picker_background.xml
@@ -16,7 +16,7 @@
 
 <inset xmlns:android="http://schemas.android.com/apk/res/android">
     <shape android:shape="rectangle">
-        <corners android:radius="@dimen/config_bottomDialogCornerRadius" />
+        <corners android:radius="@dimen/config_buttonCornerRadius" />
         <solid android:color="?attr/colorBackground" />
     </shape>
 </inset>
diff --git a/core/res/res/layout/transient_notification_with_icon.xml b/core/res/res/layout/transient_notification_with_icon.xml
index 0dfb3ad..04518b2 100644
--- a/core/res/res/layout/transient_notification_with_icon.xml
+++ b/core/res/res/layout/transient_notification_with_icon.xml
@@ -22,7 +22,7 @@
     android:orientation="horizontal"
     android:gravity="center_vertical"
     android:maxWidth="@dimen/toast_width"
-    android:background="?android:attr/colorBackground"
+    android:background="@android:drawable/toast_frame"
     android:elevation="@dimen/toast_elevation"
     android:layout_marginEnd="16dp"
     android:layout_marginStart="16dp"
@@ -31,8 +31,11 @@
 
     <ImageView
         android:id="@android:id/icon"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content" />
+        android:layout_width="24dp"
+        android:layout_height="24dp"
+        android:layout_marginTop="10dp"
+        android:layout_marginBottom="10dp"
+        android:layout_marginEnd="10dp" />
 
     <TextView
         android:id="@android:id/message"
diff --git a/core/res/res/values/colors.xml b/core/res/res/values/colors.xml
index 417c6df..e671919 100644
--- a/core/res/res/values/colors.xml
+++ b/core/res/res/values/colors.xml
@@ -593,6 +593,10 @@
     <color name="accessibility_magnification_thumbnail_container_background_color">#99000000</color>
     <color name="accessibility_magnification_thumbnail_container_stroke_color">#FFFFFF</color>
 
+    <!-- Activity Embedding divider -->
+    <color name="activity_embedding_divider_color">#8e918f</color>
+    <color name="activity_embedding_divider_color_pressed">#e3e3e3</color>
+
     <!-- Lily Language Picker language item view colors -->
     <color name="language_picker_item_text_color">#202124</color>
     <color name="language_picker_item_text_color_secondary">#5F6368</color>
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index efba709..89ac81e 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -6419,10 +6419,8 @@
     <!-- Default value for Settings.ASSIST_TOUCH_GESTURE_ENABLED -->
     <bool name="config_assistTouchGestureEnabledDefault">true</bool>
 
-    <!-- Default value for Settings.SEARCH_PRESS_HOLD_NAV_HANDLE_ENABLED -->
-    <bool name="config_searchPressHoldNavHandleEnabledDefault">true</bool>
-    <!-- Default value for Settings.ASSIST_LONG_PRESS_HOME_ENABLED for search overlay -->
-    <bool name="config_searchLongPressHomeEnabledDefault">true</bool>
+    <!-- Default value for Settings.SEARCH_ALL_ENTRYPOINTS_ENABLED -->
+    <bool name="config_searchAllEntrypointsEnabledDefault">true</bool>
 
     <!-- The maximum byte size of the information contained in the bundle of
     HotwordDetectedResult. -->
diff --git a/core/res/res/values/dimens.xml b/core/res/res/values/dimens.xml
index 291a593..4aa741d 100644
--- a/core/res/res/values/dimens.xml
+++ b/core/res/res/values/dimens.xml
@@ -1028,6 +1028,16 @@
     <dimen name="popup_enter_animation_from_y_delta">20dp</dimen>
     <dimen name="popup_exit_animation_to_y_delta">-10dp</dimen>
 
+    <!-- Dimensions for the activity embedding divider. -->
+    <dimen name="activity_embedding_divider_handle_width">4dp</dimen>
+    <dimen name="activity_embedding_divider_handle_height">48dp</dimen>
+    <dimen name="activity_embedding_divider_handle_radius">2dp</dimen>
+    <dimen name="activity_embedding_divider_handle_width_pressed">12dp</dimen>
+    <dimen name="activity_embedding_divider_handle_height_pressed">53dp</dimen>
+    <dimen name="activity_embedding_divider_handle_radius_pressed">6dp</dimen>
+    <dimen name="activity_embedding_divider_touch_target_width">24dp</dimen>
+    <dimen name="activity_embedding_divider_touch_target_height">64dp</dimen>
+
     <!-- Default handwriting bounds offsets for editors. -->
     <dimen name="handwriting_bounds_offset_left">10dp</dimen>
     <dimen name="handwriting_bounds_offset_top">40dp</dimen>
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index 5639a58..a3dba48 100644
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -3249,6 +3249,12 @@
     <!-- Title for EditText context menu [CHAR LIMIT=20] -->
     <string name="editTextMenuTitle">Text actions</string>
 
+    <!-- Error shown when a user uses a stylus to try handwriting on a text field which doesn't support stylus handwriting. [CHAR LIMIT=TOAST] -->
+    <string name="error_handwriting_unsupported">Handwriting is not supported in this field</string>
+
+    <!-- Error shown when a user uses a stylus to try handwriting on a password text field which doesn't support stylus handwriting. [CHAR LIMIT=TOAST] -->
+    <string name="error_handwriting_unsupported_password">Handwriting is not supported in password fields</string>
+
     <!-- Content description of the back button for accessibility (not shown on the screen). [CHAR LIMIT=NONE] -->
     <string name="input_method_nav_back_button_desc">Back</string>
     <!-- Content description of the switch input method button for accessibility (not shown on the screen). [CHAR LIMIT=NONE] -->
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 668a88c..2e029b2 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -3122,6 +3122,8 @@
   <!-- TextView -->
   <java-symbol type="bool" name="config_textShareSupported" />
   <java-symbol type="string" name="failed_to_copy_to_clipboard" />
+  <java-symbol type="string" name="error_handwriting_unsupported" />
+  <java-symbol type="string" name="error_handwriting_unsupported_password" />
 
   <java-symbol type="id" name="notification_material_reply_container" />
   <java-symbol type="id" name="notification_material_reply_text_1" />
@@ -5017,8 +5019,7 @@
   <java-symbol type="bool" name="config_assistLongPressHomeEnabledDefault" />
   <java-symbol type="bool" name="config_assistTouchGestureEnabledDefault" />
 
-  <java-symbol type="bool" name="config_searchPressHoldNavHandleEnabledDefault" />
-  <java-symbol type="bool" name="config_searchLongPressHomeEnabledDefault" />
+  <java-symbol type="bool" name="config_searchAllEntrypointsEnabledDefault" />
 
   <java-symbol type="integer" name="config_hotwordDetectedResultMaxBundleSize" />
 
@@ -5336,6 +5337,11 @@
 
   <java-symbol type="raw" name="default_ringtone_vibration_effect" />
 
+  <!-- For activity embedding divider -->
+  <java-symbol type="drawable" name="activity_embedding_divider_handle" />
+  <java-symbol type="dimen" name="activity_embedding_divider_touch_target_width" />
+  <java-symbol type="dimen" name="activity_embedding_divider_touch_target_height" />
+
   <!-- Whether we order unlocking and waking -->
   <java-symbol type="bool" name="config_orderUnlockAndWake" />
 
diff --git a/core/tests/coretests/src/android/view/stylus/HandwritingInitiatorTest.java b/core/tests/coretests/src/android/view/stylus/HandwritingInitiatorTest.java
index a5c9624..6c00fd8 100644
--- a/core/tests/coretests/src/android/view/stylus/HandwritingInitiatorTest.java
+++ b/core/tests/coretests/src/android/view/stylus/HandwritingInitiatorTest.java
@@ -55,6 +55,7 @@
 import android.view.inputmethod.InputMethodManager;
 import android.widget.EditText;
 
+import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 import androidx.test.platform.app.InstrumentationRegistry;
@@ -72,6 +73,7 @@
  */
 @Presubmit
 @SmallTest
+@UiThreadTest
 @RunWith(AndroidJUnit4.class)
 public class HandwritingInitiatorTest {
     private static final long TIMEOUT = ViewConfiguration.getLongPressTimeout();
diff --git a/core/tests/coretests/src/com/android/internal/accessibility/AccessibilityShortcutChooserActivityTest.java b/core/tests/coretests/src/com/android/internal/accessibility/AccessibilityShortcutChooserActivityTest.java
index 60a436e..745390d 100644
--- a/core/tests/coretests/src/com/android/internal/accessibility/AccessibilityShortcutChooserActivityTest.java
+++ b/core/tests/coretests/src/com/android/internal/accessibility/AccessibilityShortcutChooserActivityTest.java
@@ -25,7 +25,6 @@
 import static androidx.test.espresso.matcher.RootMatchers.isDialog;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
 import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
-import static androidx.test.espresso.matcher.ViewMatchers.withId;
 import static androidx.test.espresso.matcher.ViewMatchers.withText;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -54,7 +53,6 @@
 import android.content.pm.ServiceInfo;
 import android.os.Bundle;
 import android.os.Handler;
-import android.platform.test.annotations.RequiresFlagsDisabled;
 import android.platform.test.annotations.RequiresFlagsEnabled;
 import android.platform.test.flag.junit.CheckFlagsRule;
 import android.platform.test.flag.junit.DeviceFlagsValueProvider;
@@ -176,21 +174,6 @@
     }
 
     @Test
-    @RequiresFlagsDisabled(Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
-    public void selectTestService_oldPermissionDialog_deny_dialogIsHidden() {
-        launchActivity();
-        openShortcutsList();
-
-        mDevice.findObject(By.text(TEST_LABEL)).clickAndWait(Until.newWindow(), UI_TIMEOUT_MS);
-        onView(withText(DENY_LABEL)).perform(scrollTo(), click());
-        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
-
-        onView(withId(R.id.accessibility_permissionDialog_title)).inRoot(isDialog()).check(
-                doesNotExist());
-    }
-
-    @Test
-    @RequiresFlagsEnabled(Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void selectTestService_permissionDialog_allow_rowChecked() {
         launchActivity();
         openShortcutsList();
@@ -202,7 +185,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void selectTestService_permissionDialog_deny_rowNotChecked() {
         launchActivity();
         openShortcutsList();
@@ -214,7 +196,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void selectTestService_permissionDialog_uninstall_callsUninstaller_rowRemoved() {
         launchActivity();
         openShortcutsList();
@@ -228,7 +209,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void selectTestService_permissionDialog_notShownWhenNotRequired() throws Exception {
         when(mAccessibilityManagerService.isAccessibilityServiceWarningRequired(any()))
                 .thenReturn(false);
@@ -243,7 +223,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void selectTestService_notPermittedByAdmin_blockedEvenIfNoWarningRequired()
             throws Exception {
         when(mAccessibilityManagerService.isAccessibilityServiceWarningRequired(any()))
@@ -380,11 +359,9 @@
         @Override
         public void onCreate(Bundle savedInstanceState) {
             super.onCreate(savedInstanceState);
-            if (Flags.cleanupAccessibilityWarningDialog()) {
-                // Setting the Theme is necessary here for the dialog to use the proper style
-                // resources as designated in its layout XML.
-                setTheme(R.style.Theme_DeviceDefault_DayNight);
-            }
+            // Setting the Theme is necessary here for the dialog to use the proper style
+            // resources as designated in its layout XML.
+            setTheme(R.style.Theme_DeviceDefault_DayNight);
         }
 
         @Override
diff --git a/core/tests/coretests/src/com/android/internal/accessibility/dialog/AccessibilityServiceWarningTest.java b/core/tests/coretests/src/com/android/internal/accessibility/dialog/AccessibilityServiceWarningTest.java
index 24aab61..362eeea 100644
--- a/core/tests/coretests/src/com/android/internal/accessibility/dialog/AccessibilityServiceWarningTest.java
+++ b/core/tests/coretests/src/com/android/internal/accessibility/dialog/AccessibilityServiceWarningTest.java
@@ -25,7 +25,6 @@
 import android.app.AlertDialog;
 import android.content.Context;
 import android.os.RemoteException;
-import android.platform.test.annotations.RequiresFlagsEnabled;
 import android.platform.test.flag.junit.CheckFlagsRule;
 import android.platform.test.flag.junit.DeviceFlagsValueProvider;
 import android.testing.AndroidTestingRunner;
@@ -57,8 +56,6 @@
  */
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
-@RequiresFlagsEnabled(
-        android.view.accessibility.Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
 public class AccessibilityServiceWarningTest {
     private static final String A11Y_SERVICE_PACKAGE_LABEL = "TestA11yService";
     private static final String A11Y_SERVICE_SUMMARY = "TestA11yService summary";
diff --git a/data/etc/privapp-permissions-platform.xml b/data/etc/privapp-permissions-platform.xml
index 9c1c7006..ea3235b 100644
--- a/data/etc/privapp-permissions-platform.xml
+++ b/data/etc/privapp-permissions-platform.xml
@@ -588,6 +588,8 @@
         <permission name="android.permission.OVERRIDE_SYSTEM_KEY_BEHAVIOR_IN_FOCUSED_WINDOW" />
         <!-- Permission required for CTS test - PackageManagerShellCommandInstallTest -->
         <permission name="android.permission.EMERGENCY_INSTALL_PACKAGES" />
+        <!-- Permission required for Cts test - CtsSettingsTestCases -->
+        <permission name="android.permission.PREPARE_FACTORY_RESET" />
     </privapp-permissions>
 
     <privapp-permissions package="com.android.statementservice">
diff --git a/data/keyboards/Vendor_054c_Product_05c4.idc b/data/keyboards/Vendor_054c_Product_05c4.idc
index 9576e8d..2da6227 100644
--- a/data/keyboards/Vendor_054c_Product_05c4.idc
+++ b/data/keyboards/Vendor_054c_Product_05c4.idc
@@ -45,14 +45,15 @@
 # This uneven timing causes the apparent speed of a finger (calculated using
 # time deltas between received reports) to vary dramatically even if it's
 # actually moving smoothly across the touchpad, triggering the touchpad stack's
-# drumroll detection logic, which causes the finger's single smooth movement to
-# be treated as many small movements of consecutive touches, which are then
-# inhibited by the click wiggle filter.
+# drumroll detection logic. For moving fingers, the drumroll detection logic
+# splits the finger's single movement into many small movements of consecutive
+# touches, which are then inhibited by the click wiggle filter. For tapping
+# fingers, it prevents tapping to click because it thinks the finger's moving
+# too fast.
 #
-# Since this touchpad does not seem vulnerable to click wiggle, we can safely
-# disable drumroll detection due to speed changes (by setting the speed change
-# threshold very high, since there's no boolean control property).
-gestureProp.Drumroll_Max_Speed_Change_Factor = 1000000000
+# Since this touchpad doesn't seem to have to drumroll issues, we can safely
+# disable drumroll detection.
+gestureProp.Drumroll_Suppression_Enable = 0
 
 # Because of the way this touchpad is positioned, touches around the edges are
 # no more likely to be palms than ones in the middle, so remove the edge zones
diff --git a/data/keyboards/Vendor_054c_Product_09cc.idc b/data/keyboards/Vendor_054c_Product_09cc.idc
index 9576e8d..2a1a4fc 100644
--- a/data/keyboards/Vendor_054c_Product_09cc.idc
+++ b/data/keyboards/Vendor_054c_Product_09cc.idc
@@ -45,14 +45,15 @@
 # This uneven timing causes the apparent speed of a finger (calculated using
 # time deltas between received reports) to vary dramatically even if it's
 # actually moving smoothly across the touchpad, triggering the touchpad stack's
-# drumroll detection logic, which causes the finger's single smooth movement to
-# be treated as many small movements of consecutive touches, which are then
-# inhibited by the click wiggle filter.
+# drumroll detection logic. For moving fingers, the drumroll detection logic
+# splits the finger's single movement into many small movements of consecutive
+# touches, which are then inhibited by the click wiggle filter. For tapping
+# fingers, it prevents tapping to click because it thinks the finger's moving
+# too fast.
 #
-# Since this touchpad does not seem vulnerable to click wiggle, we can safely
-# disable drumroll detection due to speed changes (by setting the speed change
-# threshold very high, since there's no boolean control property).
-gestureProp.Drumroll_Max_Speed_Change_Factor = 1000000000
+# Since this touchpad doesn't seem to have drumroll issues, we can safely
+# disable drumroll detection.
+gestureProp.Drumroll_Suppression_Enable = 0
 
 # Because of the way this touchpad is positioned, touches around the edges are
 # no more likely to be palms than ones in the middle, so remove the edge zones
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/WindowExtensionsImpl.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/WindowExtensionsImpl.java
index 9756278..16c77d0 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/WindowExtensionsImpl.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/WindowExtensionsImpl.java
@@ -53,7 +53,7 @@
      * The min version of the WM Extensions that must be supported in the current platform version.
      */
     @VisibleForTesting
-    static final int EXTENSIONS_VERSION_CURRENT_PLATFORM = 5;
+    static final int EXTENSIONS_VERSION_CURRENT_PLATFORM = 6;
 
     private final Object mLock = new Object();
     private volatile DeviceStateManagerFoldingFeatureProducer mFoldingFeatureProducer;
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/DividerPresenter.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/DividerPresenter.java
index 100185b..cae232e 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/DividerPresenter.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/DividerPresenter.java
@@ -17,6 +17,12 @@
 package androidx.window.extensions.embedding;
 
 import static android.util.TypedValue.COMPLEX_UNIT_DIP;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
+import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY;
+import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;
+import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE;
+import static android.window.TaskFragmentOperation.OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE;
 
 import static androidx.window.extensions.embedding.DividerAttributes.RATIO_UNSET;
 import static androidx.window.extensions.embedding.DividerAttributes.WIDTH_UNSET;
@@ -28,34 +34,253 @@
 import android.annotation.Nullable;
 import android.app.ActivityThread;
 import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.RotateDrawable;
+import android.hardware.display.DisplayManager;
+import android.os.IBinder;
 import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.SurfaceControl;
+import android.view.SurfaceControlViewHost;
+import android.view.WindowManager;
+import android.view.WindowlessWindowManager;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.window.InputTransferToken;
+import android.window.TaskFragmentOperation;
+import android.window.TaskFragmentParentInfo;
+import android.window.WindowContainerTransaction;
 
+import androidx.annotation.IdRes;
 import androidx.annotation.NonNull;
 
+import com.android.internal.R;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.window.flags.Flags;
 
+import java.util.Objects;
+
 /**
  * Manages the rendering and interaction of the divider.
  */
 class DividerPresenter {
+    private static final String WINDOW_NAME = "AE Divider";
+
     // TODO(b/327067596) Update based on UX guidance.
-    @VisibleForTesting static final float DEFAULT_MIN_RATIO = 0.35f;
-    @VisibleForTesting static final float DEFAULT_MAX_RATIO = 0.65f;
-    @VisibleForTesting static final int DEFAULT_DIVIDER_WIDTH_DP = 24;
+    private static final Color DEFAULT_DIVIDER_COLOR = Color.valueOf(Color.BLACK);
+    @VisibleForTesting
+    static final float DEFAULT_MIN_RATIO = 0.35f;
+    @VisibleForTesting
+    static final float DEFAULT_MAX_RATIO = 0.65f;
+    @VisibleForTesting
+    static final int DEFAULT_DIVIDER_WIDTH_DP = 24;
 
-    static int getDividerWidthPx(@NonNull DividerAttributes dividerAttributes) {
+    /**
+     * The {@link Properties} of the divider. This field is {@code null} when no divider should be
+     * drawn, e.g. when the split doesn't have {@link DividerAttributes} or when the decor surface
+     * is not available.
+     */
+    @Nullable
+    @VisibleForTesting
+    Properties mProperties;
+
+    /**
+     * The {@link Renderer} of the divider. This field is {@code null} when no divider should be
+     * drawn, i.e. when {@link #mProperties} is {@code null}. The {@link Renderer} is recreated or
+     * updated when {@link #mProperties} is changed.
+     */
+    @Nullable
+    @VisibleForTesting
+    Renderer mRenderer;
+
+    /**
+     * The owner TaskFragment token of the decor surface. The decor surface is placed right above
+     * the owner TaskFragment surface and is removed if the owner TaskFragment is destroyed.
+     */
+    @Nullable
+    @VisibleForTesting
+    IBinder mDecorSurfaceOwner;
+
+    /** Updates the divider when external conditions are changed. */
+    void updateDivider(
+            @NonNull WindowContainerTransaction wct,
+            @NonNull TaskFragmentParentInfo parentInfo,
+            @Nullable SplitContainer topSplitContainer) {
+        if (!Flags.activityEmbeddingInteractiveDividerFlag()) {
+            return;
+        }
+
+        // Clean up the decor surface if top SplitContainer is null.
+        if (topSplitContainer == null) {
+            removeDecorSurfaceAndDivider(wct);
+            return;
+        }
+
+        // Clean up the decor surface if DividerAttributes is null.
+        final DividerAttributes dividerAttributes =
+                topSplitContainer.getCurrentSplitAttributes().getDividerAttributes();
+        if (dividerAttributes == null) {
+            removeDecorSurfaceAndDivider(wct);
+            return;
+        }
+
+        if (topSplitContainer.getCurrentSplitAttributes().getSplitType()
+                instanceof SplitAttributes.SplitType.ExpandContainersSplitType) {
+            // No divider is needed for ExpandContainersSplitType.
+            removeDivider();
+            return;
+        }
+
+        // Skip updating when the TFs have not been updated to match the SplitAttributes.
+        if (topSplitContainer.getPrimaryContainer().getLastRequestedBounds().isEmpty()
+                || topSplitContainer.getSecondaryContainer().getLastRequestedBounds().isEmpty()) {
+            return;
+        }
+
+        final SurfaceControl decorSurface = parentInfo.getDecorSurface();
+        if (decorSurface == null) {
+            // Clean up when the decor surface is currently unavailable.
+            removeDivider();
+            // Request to create the decor surface
+            createOrMoveDecorSurface(wct, topSplitContainer.getPrimaryContainer());
+            return;
+        }
+
+        // make the top primary container the owner of the decor surface.
+        if (!Objects.equals(mDecorSurfaceOwner,
+                topSplitContainer.getPrimaryContainer().getTaskFragmentToken())) {
+            createOrMoveDecorSurface(wct, topSplitContainer.getPrimaryContainer());
+        }
+
+        updateProperties(
+                new Properties(
+                        parentInfo.getConfiguration(),
+                        dividerAttributes,
+                        decorSurface,
+                        getInitialDividerPosition(topSplitContainer),
+                        isVerticalSplit(topSplitContainer),
+                        parentInfo.getDisplayId()));
+    }
+
+    private void updateProperties(@NonNull Properties properties) {
+        if (Properties.equalsForDivider(mProperties, properties)) {
+            return;
+        }
+        final Properties previousProperties = mProperties;
+        mProperties = properties;
+
+        if (mRenderer == null) {
+            // Create a new renderer when a renderer doesn't exist yet.
+            mRenderer = new Renderer();
+        } else if (!Properties.areSameSurfaces(
+                previousProperties.mDecorSurface, mProperties.mDecorSurface)
+                || previousProperties.mDisplayId != mProperties.mDisplayId) {
+            // Release and recreate the renderer if the decor surface or the display has changed.
+            mRenderer.release();
+            mRenderer = new Renderer();
+        } else {
+            // Otherwise, update the renderer for the new properties.
+            mRenderer.update();
+        }
+    }
+
+    /**
+     * Creates a decor surface for the TaskFragment if no decor surface exists, or changes the owner
+     * of the existing decor surface to be the specified TaskFragment.
+     *
+     * See {@link TaskFragmentOperation#OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE}.
+     */
+    private void createOrMoveDecorSurface(
+            @NonNull WindowContainerTransaction wct, @NonNull TaskFragmentContainer container) {
+        final TaskFragmentOperation operation = new TaskFragmentOperation.Builder(
+                OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE)
+                .build();
+        wct.addTaskFragmentOperation(container.getTaskFragmentToken(), operation);
+        mDecorSurfaceOwner = container.getTaskFragmentToken();
+    }
+
+    private void removeDecorSurfaceAndDivider(@NonNull WindowContainerTransaction wct) {
+        if (mDecorSurfaceOwner != null) {
+            final TaskFragmentOperation operation = new TaskFragmentOperation.Builder(
+                    OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE)
+                    .build();
+            wct.addTaskFragmentOperation(mDecorSurfaceOwner, operation);
+            mDecorSurfaceOwner = null;
+        }
+        removeDivider();
+    }
+
+    private void removeDivider() {
+        if (mRenderer != null) {
+            mRenderer.release();
+        }
+        mProperties = null;
+        mRenderer = null;
+    }
+
+    @VisibleForTesting
+    static int getInitialDividerPosition(@NonNull SplitContainer splitContainer) {
+        final Rect primaryBounds =
+                splitContainer.getPrimaryContainer().getLastRequestedBounds();
+        final Rect secondaryBounds =
+                splitContainer.getSecondaryContainer().getLastRequestedBounds();
+        if (isVerticalSplit(splitContainer)) {
+            return Math.min(primaryBounds.right, secondaryBounds.right);
+        } else {
+            return Math.min(primaryBounds.bottom, secondaryBounds.bottom);
+        }
+    }
+
+    private static boolean isVerticalSplit(@NonNull SplitContainer splitContainer) {
+        final int layoutDirection = splitContainer.getCurrentSplitAttributes().getLayoutDirection();
+        switch(layoutDirection) {
+            case SplitAttributes.LayoutDirection.LEFT_TO_RIGHT:
+            case SplitAttributes.LayoutDirection.RIGHT_TO_LEFT:
+            case SplitAttributes.LayoutDirection.LOCALE:
+                return true;
+            case SplitAttributes.LayoutDirection.TOP_TO_BOTTOM:
+            case SplitAttributes.LayoutDirection.BOTTOM_TO_TOP:
+                return false;
+            default:
+                throw new IllegalArgumentException("Invalid layout direction:" + layoutDirection);
+        }
+    }
+
+    private static void safeReleaseSurfaceControl(@Nullable SurfaceControl sc) {
+        if (sc != null) {
+            sc.release();
+        }
+    }
+
+    private static int getDividerWidthPx(@NonNull DividerAttributes dividerAttributes) {
         int dividerWidthDp = dividerAttributes.getWidthDp();
+        return convertDpToPixel(dividerWidthDp);
+    }
 
+    private static int convertDpToPixel(int dp) {
         // TODO(b/329193115) support divider on secondary display
         final Context applicationContext = ActivityThread.currentActivityThread().getApplication();
 
         return (int) TypedValue.applyDimension(
                 COMPLEX_UNIT_DIP,
-                dividerWidthDp,
+                dp,
                 applicationContext.getResources().getDisplayMetrics());
     }
 
+    private static int getDimensionDp(@IdRes int resId) {
+        final Context context = ActivityThread.currentActivityThread().getApplication();
+        final int px = context.getResources().getDimensionPixelSize(resId);
+        return (int) TypedValue.convertPixelsToDimension(
+                COMPLEX_UNIT_DIP,
+                px,
+                context.getResources().getDisplayMetrics());
+    }
+
     /**
      * Returns the container bound offset that is a result of the presence of a divider.
      *
@@ -140,6 +365,12 @@
             widthDp = DEFAULT_DIVIDER_WIDTH_DP;
         }
 
+        if (dividerAttributes.getDividerType() == DividerAttributes.DIVIDER_TYPE_DRAGGABLE) {
+            // Draggable divider width must be larger than the drag handle size.
+            widthDp = Math.max(widthDp,
+                    getDimensionDp(R.dimen.activity_embedding_divider_touch_target_width));
+        }
+
         float minRatio = dividerAttributes.getPrimaryMinRatio();
         if (minRatio == RATIO_UNSET) {
             minRatio = DEFAULT_MIN_RATIO;
@@ -156,4 +387,231 @@
                 .setPrimaryMaxRatio(maxRatio)
                 .build();
     }
+
+    /**
+     * Properties for the {@link DividerPresenter}. The rendering of the divider solely depends on
+     * these properties. When any value is updated, the divider is re-rendered. The Properties
+     * instance is created only when all the pre-conditions of drawing a divider are met.
+     */
+    @VisibleForTesting
+    static class Properties {
+        private static final int CONFIGURATION_MASK_FOR_DIVIDER =
+                ActivityInfo.CONFIG_DENSITY | ActivityInfo.CONFIG_WINDOW_CONFIGURATION;
+        @NonNull
+        private final Configuration mConfiguration;
+        @NonNull
+        private final DividerAttributes mDividerAttributes;
+        @NonNull
+        private final SurfaceControl mDecorSurface;
+
+        /** The initial position of the divider calculated based on container bounds. */
+        private final int mInitialDividerPosition;
+
+        /** Whether the split is vertical, such as left-to-right or right-to-left split. */
+        private final boolean mIsVerticalSplit;
+
+        private final int mDisplayId;
+
+        @VisibleForTesting
+        Properties(
+                @NonNull Configuration configuration,
+                @NonNull DividerAttributes dividerAttributes,
+                @NonNull SurfaceControl decorSurface,
+                int initialDividerPosition,
+                boolean isVerticalSplit,
+                int displayId) {
+            mConfiguration = configuration;
+            mDividerAttributes = dividerAttributes;
+            mDecorSurface = decorSurface;
+            mInitialDividerPosition = initialDividerPosition;
+            mIsVerticalSplit = isVerticalSplit;
+            mDisplayId = displayId;
+        }
+
+        /**
+         * Compares whether two Properties objects are equal for rendering the divider. The
+         * Configuration is checked for rendering related fields, and other fields are checked for
+         * regular equality.
+         */
+        private static boolean equalsForDivider(@Nullable Properties a, @Nullable Properties b) {
+            if (a == b) {
+                return true;
+            }
+            if (a == null || b == null) {
+                return false;
+            }
+            return areSameSurfaces(a.mDecorSurface, b.mDecorSurface)
+                    && Objects.equals(a.mDividerAttributes, b.mDividerAttributes)
+                    && areConfigurationsEqualForDivider(a.mConfiguration, b.mConfiguration)
+                    && a.mInitialDividerPosition == b.mInitialDividerPosition
+                    && a.mIsVerticalSplit == b.mIsVerticalSplit
+                    && a.mDisplayId == b.mDisplayId;
+        }
+
+        private static boolean areSameSurfaces(
+                @Nullable SurfaceControl sc1, @Nullable SurfaceControl sc2) {
+            if (sc1 == sc2) {
+                // If both are null or both refer to the same object.
+                return true;
+            }
+            if (sc1 == null || sc2 == null) {
+                return false;
+            }
+            return sc1.isSameSurface(sc2);
+        }
+
+        private static boolean areConfigurationsEqualForDivider(
+                @NonNull Configuration a, @NonNull Configuration b) {
+            final int diff = a.diff(b);
+            return (diff & CONFIGURATION_MASK_FOR_DIVIDER) == 0;
+        }
+    }
+
+    /**
+     * Handles the rendering of the divider. When the decor surface is updated, the renderer is
+     * recreated. When other fields in the Properties are changed, the renderer is updated.
+     */
+    @VisibleForTesting
+    class Renderer {
+        @NonNull
+        private final SurfaceControl mDividerSurface;
+        @NonNull
+        private final WindowlessWindowManager mWindowlessWindowManager;
+        @NonNull
+        private final SurfaceControlViewHost mViewHost;
+        @NonNull
+        private final FrameLayout mDividerLayout;
+        private final int mDividerWidthPx;
+
+        private Renderer() {
+            mDividerWidthPx = getDividerWidthPx(mProperties.mDividerAttributes);
+
+            mDividerSurface = createChildSurface("DividerSurface", true /* visible */);
+            mWindowlessWindowManager = new WindowlessWindowManager(
+                    mProperties.mConfiguration,
+                    mDividerSurface,
+                    new InputTransferToken());
+
+            final Context context = ActivityThread.currentActivityThread().getApplication();
+            final DisplayManager displayManager = context.getSystemService(DisplayManager.class);
+            mViewHost = new SurfaceControlViewHost(
+                    context, displayManager.getDisplay(mProperties.mDisplayId),
+                    mWindowlessWindowManager, "DividerContainer");
+            mDividerLayout = new FrameLayout(context);
+
+            update();
+        }
+
+        /** Updates the divider when properties are changed */
+        @VisibleForTesting
+        void update() {
+            mWindowlessWindowManager.setConfiguration(mProperties.mConfiguration);
+            updateSurface();
+            updateLayout();
+            updateDivider();
+        }
+
+        @VisibleForTesting
+        void release() {
+            mViewHost.release();
+            // TODO handle synchronization between surface transactions and WCT.
+            new SurfaceControl.Transaction().remove(mDividerSurface).apply();
+            safeReleaseSurfaceControl(mDividerSurface);
+        }
+
+        private void updateSurface() {
+            final Rect taskBounds = mProperties.mConfiguration.windowConfiguration.getBounds();
+            // TODO handle synchronization between surface transactions and WCT.
+            final SurfaceControl.Transaction t = new SurfaceControl.Transaction();
+            if (mProperties.mIsVerticalSplit) {
+                t.setPosition(mDividerSurface, mProperties.mInitialDividerPosition, 0.0f);
+                t.setWindowCrop(mDividerSurface, mDividerWidthPx, taskBounds.height());
+            } else {
+                t.setPosition(mDividerSurface, 0.0f, mProperties.mInitialDividerPosition);
+                t.setWindowCrop(mDividerSurface, taskBounds.width(), mDividerWidthPx);
+            }
+            t.apply();
+        }
+
+        private void updateLayout() {
+            final Rect taskBounds = mProperties.mConfiguration.windowConfiguration.getBounds();
+            final WindowManager.LayoutParams lp = mProperties.mIsVerticalSplit
+                    ? new WindowManager.LayoutParams(
+                            mDividerWidthPx,
+                            taskBounds.height(),
+                            TYPE_APPLICATION_PANEL,
+                            FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_SLIPPERY,
+                            PixelFormat.TRANSLUCENT)
+                    : new WindowManager.LayoutParams(
+                            taskBounds.width(),
+                            mDividerWidthPx,
+                            TYPE_APPLICATION_PANEL,
+                            FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_SLIPPERY,
+                            PixelFormat.TRANSLUCENT);
+            lp.setTitle(WINDOW_NAME);
+            mViewHost.setView(mDividerLayout, lp);
+        }
+
+        private void updateDivider() {
+            mDividerLayout.removeAllViews();
+            mDividerLayout.setBackgroundColor(DEFAULT_DIVIDER_COLOR.toArgb());
+            if (mProperties.mDividerAttributes.getDividerType()
+                    == DividerAttributes.DIVIDER_TYPE_DRAGGABLE) {
+                drawDragHandle();
+            }
+            mViewHost.getView().invalidate();
+        }
+
+        private void drawDragHandle() {
+            final Context context = mDividerLayout.getContext();
+            final ImageButton button = new ImageButton(context);
+            final FrameLayout.LayoutParams params = mProperties.mIsVerticalSplit
+                    ? new FrameLayout.LayoutParams(
+                            context.getResources().getDimensionPixelSize(
+                                    R.dimen.activity_embedding_divider_touch_target_width),
+                            context.getResources().getDimensionPixelSize(
+                                    R.dimen.activity_embedding_divider_touch_target_height))
+                    : new FrameLayout.LayoutParams(
+                            context.getResources().getDimensionPixelSize(
+                                    R.dimen.activity_embedding_divider_touch_target_height),
+                            context.getResources().getDimensionPixelSize(
+                                    R.dimen.activity_embedding_divider_touch_target_width));
+            params.gravity = Gravity.CENTER;
+            button.setLayoutParams(params);
+            button.setBackgroundColor(R.color.transparent);
+
+            final Drawable handle =  context.getResources().getDrawable(
+                    R.drawable.activity_embedding_divider_handle, context.getTheme());
+            if (mProperties.mIsVerticalSplit) {
+                button.setImageDrawable(handle);
+            } else {
+                // Rotate the handle drawable
+                RotateDrawable rotatedHandle = new RotateDrawable();
+                rotatedHandle.setFromDegrees(90f);
+                rotatedHandle.setToDegrees(90f);
+                rotatedHandle.setPivotXRelative(true);
+                rotatedHandle.setPivotYRelative(true);
+                rotatedHandle.setPivotX(0.5f);
+                rotatedHandle.setPivotY(0.5f);
+                rotatedHandle.setLevel(1);
+                rotatedHandle.setDrawable(handle);
+
+                button.setImageDrawable(rotatedHandle);
+            }
+            mDividerLayout.addView(button);
+        }
+
+        @NonNull
+        private SurfaceControl createChildSurface(@NonNull String name, boolean visible) {
+            final Rect bounds = mProperties.mConfiguration.windowConfiguration.getBounds();
+            return new SurfaceControl.Builder()
+                    .setParent(mProperties.mDecorSurface)
+                    .setName(name)
+                    .setHidden(!visible)
+                    .setCallsite("DividerManager.createChildSurface")
+                    .setBufferSize(bounds.width(), bounds.height())
+                    .setColorLayer()
+                    .build();
+        }
+    }
 }
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizer.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizer.java
index 80afb16d..3f4dddf 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizer.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizer.java
@@ -168,11 +168,14 @@
      * @param fragmentToken token of an existing TaskFragment.
      */
     void expandTaskFragment(@NonNull WindowContainerTransaction wct,
-            @NonNull IBinder fragmentToken) {
+            @NonNull TaskFragmentContainer container) {
+        final IBinder fragmentToken = container.getTaskFragmentToken();
         resizeTaskFragment(wct, fragmentToken, new Rect());
         clearAdjacentTaskFragments(wct, fragmentToken);
         updateWindowingMode(wct, fragmentToken, WINDOWING_MODE_UNDEFINED);
         updateAnimationParams(wct, fragmentToken, TaskFragmentAnimationParams.DEFAULT);
+
+        container.getTaskContainer().updateDivider(wct);
     }
 
     /**
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitController.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitController.java
index 0cc4b1f..1bc8264 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitController.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitController.java
@@ -844,6 +844,7 @@
         // Checks if container should be updated before apply new parentInfo.
         final boolean shouldUpdateContainer = taskContainer.shouldUpdateContainer(parentInfo);
         taskContainer.updateTaskFragmentParentInfo(parentInfo);
+        taskContainer.updateDivider(wct);
 
         // If the last direct activity of the host task is dismissed and the overlay container is
         // the only taskFragment, the overlay container should also be dismissed.
@@ -1224,7 +1225,7 @@
         final TaskFragmentContainer container = getContainerWithActivity(activity);
         if (shouldContainerBeExpanded(container)) {
             // Make sure that the existing container is expanded.
-            mPresenter.expandTaskFragment(wct, container.getTaskFragmentToken());
+            mPresenter.expandTaskFragment(wct, container);
         } else {
             // Put activity into a new expanded container.
             final TaskFragmentContainer newContainer = newContainer(activity, getTaskId(activity));
@@ -1928,7 +1929,7 @@
         }
         if (shouldContainerBeExpanded(container)) {
             if (container.getInfo() != null) {
-                mPresenter.expandTaskFragment(wct, container.getTaskFragmentToken());
+                mPresenter.expandTaskFragment(wct, container);
             }
             // If the info is not available yet the task fragment will be expanded when it's ready
             return;
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitPresenter.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitPresenter.java
index f680694..20bc820 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitPresenter.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/SplitPresenter.java
@@ -368,6 +368,7 @@
         updateTaskFragmentWindowingModeIfRegistered(wct, secondaryContainer, windowingMode);
         updateAnimationParams(wct, primaryContainer.getTaskFragmentToken(), splitAttributes);
         updateAnimationParams(wct, secondaryContainer.getTaskFragmentToken(), splitAttributes);
+        taskContainer.updateDivider(wct);
     }
 
     private void setAdjacentTaskFragments(@NonNull WindowContainerTransaction wct,
@@ -686,8 +687,8 @@
                     splitContainer.getPrimaryContainer().getTaskFragmentToken();
             final IBinder secondaryToken =
                     splitContainer.getSecondaryContainer().getTaskFragmentToken();
-            expandTaskFragment(wct, primaryToken);
-            expandTaskFragment(wct, secondaryToken);
+            expandTaskFragment(wct, splitContainer.getPrimaryContainer());
+            expandTaskFragment(wct, splitContainer.getSecondaryContainer());
             // Set the companion TaskFragment when the two containers stacked.
             setCompanionTaskFragment(wct, primaryToken, secondaryToken,
                     splitContainer.getSplitRule(), true /* isStacked */);
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskContainer.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskContainer.java
index 73109e2..e75a317 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskContainer.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskContainer.java
@@ -77,6 +77,9 @@
 
     private boolean mHasDirectActivity;
 
+    @Nullable
+    private TaskFragmentParentInfo mTaskFragmentParentInfo;
+
     /**
      * TaskFragments that the organizer has requested to be closed. They should be removed when
      * the organizer receives
@@ -85,14 +88,17 @@
      */
     final Set<IBinder> mFinishedContainer = new ArraySet<>();
 
+    // TODO(b/293654166): move DividerPresenter to SplitController.
+    @NonNull
+    final DividerPresenter mDividerPresenter;
+
     /**
      * The {@link TaskContainer} constructor
      *
-     * @param taskId The ID of the Task, which must match {@link Activity#getTaskId()} with
-     *               {@code activityInTask}.
+     * @param taskId         The ID of the Task, which must match {@link Activity#getTaskId()} with
+     *                       {@code activityInTask}.
      * @param activityInTask The {@link Activity} in the Task with {@code taskId}. It is used to
      *                       initialize the {@link TaskContainer} properties.
-     *
      */
     TaskContainer(int taskId, @NonNull Activity activityInTask) {
         if (taskId == INVALID_TASK_ID) {
@@ -107,6 +113,7 @@
         // the host task is visible and has an activity in the task.
         mIsVisible = true;
         mHasDirectActivity = true;
+        mDividerPresenter = new DividerPresenter();
     }
 
     int getTaskId() {
@@ -136,10 +143,12 @@
     }
 
     void updateTaskFragmentParentInfo(@NonNull TaskFragmentParentInfo info) {
+        // TODO(b/293654166): cache the TaskFragmentParentInfo and remove these fields.
         mConfiguration.setTo(info.getConfiguration());
         mDisplayId = info.getDisplayId();
         mIsVisible = info.isVisible();
         mHasDirectActivity = info.hasDirectActivity();
+        mTaskFragmentParentInfo = info;
     }
 
     /**
@@ -161,8 +170,8 @@
      * Returns the windowing mode for the TaskFragments below this Task, which should be split with
      * other TaskFragments.
      *
-     * @param taskFragmentBounds    Requested bounds for the TaskFragment. It will be empty when
-     *                              the pair of TaskFragments are stacked due to the limited space.
+     * @param taskFragmentBounds Requested bounds for the TaskFragment. It will be empty when
+     *                           the pair of TaskFragments are stacked due to the limited space.
      */
     @WindowingMode
     int getWindowingModeForTaskFragment(@Nullable Rect taskFragmentBounds) {
@@ -228,7 +237,7 @@
 
     @Nullable
     TaskFragmentContainer getTopNonFinishingTaskFragmentContainer(boolean includePin,
-                                                                  boolean includeOverlay) {
+            boolean includeOverlay) {
         for (int i = mContainers.size() - 1; i >= 0; i--) {
             final TaskFragmentContainer container = mContainers.get(i);
             if (!includePin && isTaskFragmentContainerPinned(container)) {
@@ -283,7 +292,7 @@
         return mContainers.indexOf(child);
     }
 
-    /** Whether the Task is in an intermediate state waiting for the server update.*/
+    /** Whether the Task is in an intermediate state waiting for the server update. */
     boolean isInIntermediateState() {
         for (TaskFragmentContainer container : mContainers) {
             if (container.isInIntermediateState()) {
@@ -389,6 +398,26 @@
         return mContainers;
     }
 
+    void updateDivider(@NonNull WindowContainerTransaction wct) {
+        if (mTaskFragmentParentInfo != null) {
+            // Update divider only if TaskFragmentParentInfo is available.
+            mDividerPresenter.updateDivider(
+                    wct, mTaskFragmentParentInfo, getTopNonFinishingSplitContainer());
+        }
+    }
+
+    @Nullable
+    private SplitContainer getTopNonFinishingSplitContainer() {
+        for (int i = mSplitContainers.size() - 1; i >= 0; i--) {
+            final SplitContainer splitContainer = mSplitContainers.get(i);
+            if (!splitContainer.getPrimaryContainer().isFinished()
+                    && !splitContainer.getSecondaryContainer().isFinished()) {
+                return splitContainer;
+            }
+        }
+        return null;
+    }
+
     private void onTaskFragmentContainerUpdated() {
         // TODO(b/300211704): Find a better mechanism to handle the z-order in case we introduce
         //  another special container that should also be on top in the future.
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskFragmentContainer.java b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskFragmentContainer.java
index a6bf99d..e20a3e0 100644
--- a/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskFragmentContainer.java
+++ b/libs/WindowManager/Jetpack/src/androidx/window/extensions/embedding/TaskFragmentContainer.java
@@ -748,6 +748,10 @@
         }
     }
 
+    @NonNull Rect getLastRequestedBounds() {
+        return mLastRequestedBounds;
+    }
+
     /**
      * Checks if last requested windowing mode is equal to the provided value.
      * @see WindowContainerTransaction#setWindowingMode
diff --git a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/DividerPresenterTest.java b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/DividerPresenterTest.java
index 2a277f4..4d1d807 100644
--- a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/DividerPresenterTest.java
+++ b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/DividerPresenterTest.java
@@ -16,22 +16,49 @@
 
 package androidx.window.extensions.embedding;
 
+import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE;
+import static android.window.TaskFragmentOperation.OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE;
+
 import static androidx.window.extensions.embedding.DividerPresenter.getBoundsOffsetForDivider;
+import static androidx.window.extensions.embedding.DividerPresenter.getInitialDividerPosition;
 import static androidx.window.extensions.embedding.SplitPresenter.CONTAINER_POSITION_BOTTOM;
 import static androidx.window.extensions.embedding.SplitPresenter.CONTAINER_POSITION_LEFT;
 import static androidx.window.extensions.embedding.SplitPresenter.CONTAINER_POSITION_RIGHT;
 import static androidx.window.extensions.embedding.SplitPresenter.CONTAINER_POSITION_TOP;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.Binder;
+import android.os.IBinder;
 import android.platform.test.annotations.Presubmit;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.view.Display;
+import android.view.SurfaceControl;
+import android.window.TaskFragmentOperation;
+import android.window.TaskFragmentParentInfo;
+import android.window.WindowContainerTransaction;
 
 import androidx.annotation.NonNull;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import com.android.window.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
 
 /**
  * Test class for {@link DividerPresenter}.
@@ -43,6 +70,167 @@
 @SmallTest
 @RunWith(AndroidJUnit4.class)
 public class DividerPresenterTest {
+    @Rule
+    public final SetFlagsRule mSetFlagRule = new SetFlagsRule();
+
+    @Mock
+    private DividerPresenter.Renderer mRenderer;
+
+    @Mock
+    private WindowContainerTransaction mTransaction;
+
+    @Mock
+    private TaskFragmentParentInfo mParentInfo;
+
+    @Mock
+    private SplitContainer mSplitContainer;
+
+    @Mock
+    private SurfaceControl mSurfaceControl;
+
+    private DividerPresenter mDividerPresenter;
+
+    private final IBinder mPrimaryContainerToken = new Binder();
+
+    private final IBinder mSecondaryContainerToken = new Binder();
+
+    private final IBinder mAnotherContainerToken = new Binder();
+
+    private DividerPresenter.Properties mProperties;
+
+    private static final DividerAttributes DEFAULT_DIVIDER_ATTRIBUTES =
+            new DividerAttributes.Builder(DividerAttributes.DIVIDER_TYPE_DRAGGABLE).build();
+
+    private static final DividerAttributes ANOTHER_DIVIDER_ATTRIBUTES =
+            new DividerAttributes.Builder(DividerAttributes.DIVIDER_TYPE_DRAGGABLE)
+                    .setWidthDp(10).build();
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mSetFlagRule.enableFlags(Flags.FLAG_ACTIVITY_EMBEDDING_INTERACTIVE_DIVIDER_FLAG);
+
+        when(mParentInfo.getDisplayId()).thenReturn(Display.DEFAULT_DISPLAY);
+        when(mParentInfo.getConfiguration()).thenReturn(new Configuration());
+        when(mParentInfo.getDecorSurface()).thenReturn(mSurfaceControl);
+
+        when(mSplitContainer.getCurrentSplitAttributes()).thenReturn(
+                new SplitAttributes.Builder()
+                        .setDividerAttributes(DEFAULT_DIVIDER_ATTRIBUTES)
+                        .build());
+        final TaskFragmentContainer mockPrimaryContainer =
+                createMockTaskFragmentContainer(
+                        mPrimaryContainerToken, new Rect(0, 0, 950, 1000));
+        final TaskFragmentContainer mockSecondaryContainer =
+                createMockTaskFragmentContainer(
+                        mSecondaryContainerToken, new Rect(1000, 0, 2000, 1000));
+        when(mSplitContainer.getPrimaryContainer()).thenReturn(mockPrimaryContainer);
+        when(mSplitContainer.getSecondaryContainer()).thenReturn(mockSecondaryContainer);
+
+        mProperties = new DividerPresenter.Properties(
+                new Configuration(),
+                DEFAULT_DIVIDER_ATTRIBUTES,
+                mSurfaceControl,
+                getInitialDividerPosition(mSplitContainer),
+                true /* isVerticalSplit */,
+                Display.DEFAULT_DISPLAY);
+
+        mDividerPresenter = new DividerPresenter();
+        mDividerPresenter.mProperties = mProperties;
+        mDividerPresenter.mRenderer = mRenderer;
+        mDividerPresenter.mDecorSurfaceOwner = mPrimaryContainerToken;
+    }
+
+    @Test
+    public void testUpdateDivider() {
+        when(mSplitContainer.getCurrentSplitAttributes()).thenReturn(
+                new SplitAttributes.Builder()
+                        .setDividerAttributes(ANOTHER_DIVIDER_ATTRIBUTES)
+                        .build());
+        mDividerPresenter.updateDivider(
+                mTransaction,
+                mParentInfo,
+                mSplitContainer);
+
+        assertNotEquals(mProperties, mDividerPresenter.mProperties);
+        verify(mRenderer).update();
+        verify(mTransaction, never()).addTaskFragmentOperation(any(), any());
+    }
+
+    @Test
+    public void testUpdateDivider_updateDecorSurfaceOwnerIfPrimaryContainerChanged() {
+        final TaskFragmentContainer mockPrimaryContainer =
+                createMockTaskFragmentContainer(
+                        mAnotherContainerToken, new Rect(0, 0, 750, 1000));
+        final TaskFragmentContainer mockSecondaryContainer =
+                createMockTaskFragmentContainer(
+                        mSecondaryContainerToken, new Rect(800, 0, 2000, 1000));
+        when(mSplitContainer.getPrimaryContainer()).thenReturn(mockPrimaryContainer);
+        when(mSplitContainer.getSecondaryContainer()).thenReturn(mockSecondaryContainer);
+        mDividerPresenter.updateDivider(
+                mTransaction,
+                mParentInfo,
+                mSplitContainer);
+
+        assertNotEquals(mProperties, mDividerPresenter.mProperties);
+        verify(mRenderer).update();
+        final TaskFragmentOperation operation = new TaskFragmentOperation.Builder(
+                OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE)
+                .build();
+        assertEquals(mAnotherContainerToken, mDividerPresenter.mDecorSurfaceOwner);
+        verify(mTransaction).addTaskFragmentOperation(mAnotherContainerToken, operation);
+    }
+
+    @Test
+    public void testUpdateDivider_noChangeIfPropertiesIdentical() {
+        mDividerPresenter.updateDivider(
+                mTransaction,
+                mParentInfo,
+                mSplitContainer);
+
+        assertEquals(mProperties, mDividerPresenter.mProperties);
+        verify(mRenderer, never()).update();
+        verify(mTransaction, never()).addTaskFragmentOperation(any(), any());
+    }
+
+    @Test
+    public void testUpdateDivider_dividerRemovedWhenSplitContainerIsNull() {
+        mDividerPresenter.updateDivider(
+                mTransaction,
+                mParentInfo,
+                null /* splitContainer */);
+        final TaskFragmentOperation taskFragmentOperation = new TaskFragmentOperation.Builder(
+                OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE)
+                .build();
+
+        verify(mTransaction).addTaskFragmentOperation(
+                mPrimaryContainerToken, taskFragmentOperation);
+        verify(mRenderer).release();
+        assertNull(mDividerPresenter.mRenderer);
+        assertNull(mDividerPresenter.mProperties);
+        assertNull(mDividerPresenter.mDecorSurfaceOwner);
+    }
+
+    @Test
+    public void testUpdateDivider_dividerRemovedWhenDividerAttributesIsNull() {
+        when(mSplitContainer.getCurrentSplitAttributes()).thenReturn(
+                new SplitAttributes.Builder().setDividerAttributes(null).build());
+        mDividerPresenter.updateDivider(
+                mTransaction,
+                mParentInfo,
+                mSplitContainer);
+        final TaskFragmentOperation taskFragmentOperation = new TaskFragmentOperation.Builder(
+                OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE)
+                .build();
+
+        verify(mTransaction).addTaskFragmentOperation(
+                mPrimaryContainerToken, taskFragmentOperation);
+        verify(mRenderer).release();
+        assertNull(mDividerPresenter.mRenderer);
+        assertNull(mDividerPresenter.mProperties);
+        assertNull(mDividerPresenter.mDecorSurfaceOwner);
+    }
+
     @Test
     public void testSanitizeDividerAttributes_setDefaultValues() {
         DividerAttributes attributes =
@@ -61,7 +249,7 @@
     public void testSanitizeDividerAttributes_notChangingValidValues() {
         DividerAttributes attributes =
                 new DividerAttributes.Builder(DividerAttributes.DIVIDER_TYPE_DRAGGABLE)
-                        .setWidthDp(10)
+                        .setWidthDp(24)
                         .setPrimaryMinRatio(0.3f)
                         .setPrimaryMaxRatio(0.7f)
                         .build();
@@ -123,6 +311,14 @@
                 dividerWidthPx, splitType, expectedTopLeftOffset, expectedBottomRightOffset);
     }
 
+    private TaskFragmentContainer createMockTaskFragmentContainer(
+            @NonNull IBinder token, @NonNull Rect bounds) {
+        final TaskFragmentContainer container = mock(TaskFragmentContainer.class);
+        when(container.getTaskFragmentToken()).thenReturn(token);
+        when(container.getLastRequestedBounds()).thenReturn(bounds);
+        return container;
+    }
+
     private void assertDividerOffsetEquals(
             int dividerWidthPx,
             @NonNull SplitAttributes.SplitType splitType,
diff --git a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizerTest.java b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizerTest.java
index dd087e8..6f37e9c 100644
--- a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizerTest.java
+++ b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/JetpackTaskFragmentOrganizerTest.java
@@ -107,7 +107,7 @@
         mOrganizer.mFragmentInfos.put(container.getTaskFragmentToken(), info);
         container.setInfo(mTransaction, info);
 
-        mOrganizer.expandTaskFragment(mTransaction, container.getTaskFragmentToken());
+        mOrganizer.expandTaskFragment(mTransaction, container);
 
         verify(mTransaction).setWindowingMode(container.getInfo().getToken(),
                 WINDOWING_MODE_UNDEFINED);
diff --git a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitControllerTest.java b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitControllerTest.java
index cdb37ac..c246a19 100644
--- a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitControllerTest.java
+++ b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitControllerTest.java
@@ -642,7 +642,7 @@
                 false /* isOnReparent */);
 
         assertTrue(result);
-        verify(mSplitPresenter).expandTaskFragment(mTransaction, container.getTaskFragmentToken());
+        verify(mSplitPresenter).expandTaskFragment(mTransaction, container);
     }
 
     @Test
diff --git a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitPresenterTest.java b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitPresenterTest.java
index 941b4e1..62d8aa3 100644
--- a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitPresenterTest.java
+++ b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/extensions/embedding/SplitPresenterTest.java
@@ -665,8 +665,8 @@
 
         assertEquals(RESULT_EXPANDED, mPresenter.expandSplitContainerIfNeeded(mTransaction,
                 splitContainer, mActivity, secondaryActivity, null /* secondaryIntent */));
-        verify(mPresenter).expandTaskFragment(mTransaction, primaryTf.getTaskFragmentToken());
-        verify(mPresenter).expandTaskFragment(mTransaction, secondaryTf.getTaskFragmentToken());
+        verify(mPresenter).expandTaskFragment(mTransaction, primaryTf);
+        verify(mPresenter).expandTaskFragment(mTransaction, secondaryTf);
 
         splitContainer.updateCurrentSplitAttributes(SPLIT_ATTRIBUTES);
         clearInvocations(mPresenter);
@@ -675,8 +675,8 @@
                 splitContainer, mActivity, null /* secondaryActivity */,
                 new Intent(ApplicationProvider.getApplicationContext(),
                         MinimumDimensionActivity.class)));
-        verify(mPresenter).expandTaskFragment(mTransaction, primaryTf.getTaskFragmentToken());
-        verify(mPresenter).expandTaskFragment(mTransaction, secondaryTf.getTaskFragmentToken());
+        verify(mPresenter).expandTaskFragment(mTransaction, primaryTf);
+        verify(mPresenter).expandTaskFragment(mTransaction, secondaryTf);
     }
 
     @Test
diff --git a/libs/WindowManager/Shell/multivalentTests/Android.bp b/libs/WindowManager/Shell/multivalentTests/Android.bp
index 1686d0d..1ad19c9 100644
--- a/libs/WindowManager/Shell/multivalentTests/Android.bp
+++ b/libs/WindowManager/Shell/multivalentTests/Android.bp
@@ -46,6 +46,7 @@
     exclude_srcs: ["src/com/android/wm/shell/bubbles/BubbleStackViewTest.kt"],
     static_libs: [
         "junit",
+        "androidx.core_core-animation-testing",
         "androidx.test.runner",
         "androidx.test.rules",
         "androidx.test.ext.junit",
@@ -64,6 +65,7 @@
     static_libs: [
         "WindowManager-Shell",
         "junit",
+        "androidx.core_core-animation-testing",
         "androidx.test.runner",
         "androidx.test.rules",
         "androidx.test.ext.junit",
diff --git a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetControllerTest.kt b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetControllerTest.kt
new file mode 100644
index 0000000..2ac7791
--- /dev/null
+++ b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetControllerTest.kt
@@ -0,0 +1,180 @@
+/*
+ * 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.wm.shell.bubbles.bar
+
+import android.content.Context
+import android.graphics.Insets
+import android.graphics.Rect
+import android.view.View
+import android.view.WindowManager
+import android.widget.FrameLayout
+import androidx.core.animation.AnimatorTestRule
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.internal.protolog.common.ProtoLog
+import com.android.wm.shell.R
+import com.android.wm.shell.bubbles.BubblePositioner
+import com.android.wm.shell.bubbles.DeviceConfig
+import com.android.wm.shell.bubbles.bar.BubbleBarDropTargetController.Companion.DROP_TARGET_ALPHA_IN_DURATION
+import com.android.wm.shell.bubbles.bar.BubbleBarDropTargetController.Companion.DROP_TARGET_ALPHA_OUT_DURATION
+import com.android.wm.shell.bubbles.bar.BubbleBarDropTargetController.Companion.DROP_TARGET_SCALE
+import com.android.wm.shell.common.bubbles.BubbleBarLocation
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.ClassRule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [BubbleBarDropTargetController] */
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BubbleBarDropTargetControllerTest {
+
+    companion object {
+        @JvmField @ClassRule val animatorTestRule: AnimatorTestRule = AnimatorTestRule()
+    }
+
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+    private lateinit var controller: BubbleBarDropTargetController
+    private lateinit var positioner: BubblePositioner
+    private lateinit var container: FrameLayout
+
+    @Before
+    fun setUp() {
+        ProtoLog.REQUIRE_PROTOLOGTOOL = false
+        container = FrameLayout(context)
+        val windowManager = context.getSystemService(WindowManager::class.java)
+        positioner = BubblePositioner(context, windowManager)
+        positioner.setShowingInBubbleBar(true)
+        val deviceConfig =
+            DeviceConfig(
+                windowBounds = Rect(0, 0, 2000, 2600),
+                isLargeScreen = true,
+                isSmallTablet = false,
+                isLandscape = true,
+                isRtl = false,
+                insets = Insets.of(10, 20, 30, 40)
+            )
+        positioner.update(deviceConfig)
+        positioner.bubbleBarBounds = Rect(1800, 2400, 1970, 2560)
+
+        controller = BubbleBarDropTargetController(context, container, positioner)
+    }
+
+    @Test
+    fun show_moveLeftToRight_isVisibleWithExpectedBounds() {
+        val expectedBoundsOnLeft = getExpectedDropTargetBounds(onLeft = true)
+        val expectedBoundsOnRight = getExpectedDropTargetBounds(onLeft = false)
+
+        runOnMainSync { controller.show(BubbleBarLocation.LEFT) }
+        waitForAnimateIn()
+        val viewOnLeft = getDropTargetView()
+        assertThat(viewOnLeft).isNotNull()
+        assertThat(viewOnLeft!!.alpha).isEqualTo(1f)
+        assertThat(viewOnLeft.layoutParams.width).isEqualTo(expectedBoundsOnLeft.width())
+        assertThat(viewOnLeft.layoutParams.height).isEqualTo(expectedBoundsOnLeft.height())
+        assertThat(viewOnLeft.x).isEqualTo(expectedBoundsOnLeft.left)
+        assertThat(viewOnLeft.y).isEqualTo(expectedBoundsOnLeft.top)
+
+        runOnMainSync { controller.show(BubbleBarLocation.RIGHT) }
+        waitForAnimateOut()
+        waitForAnimateIn()
+        val viewOnRight = getDropTargetView()
+        assertThat(viewOnRight).isNotNull()
+        assertThat(viewOnRight!!.alpha).isEqualTo(1f)
+        assertThat(viewOnRight.layoutParams.width).isEqualTo(expectedBoundsOnRight.width())
+        assertThat(viewOnRight.layoutParams.height).isEqualTo(expectedBoundsOnRight.height())
+        assertThat(viewOnRight.x).isEqualTo(expectedBoundsOnRight.left)
+        assertThat(viewOnRight.y).isEqualTo(expectedBoundsOnRight.top)
+    }
+
+    @Test
+    fun toggleSetHidden_dropTargetShown_updatesAlpha() {
+        runOnMainSync { controller.show(BubbleBarLocation.RIGHT) }
+        waitForAnimateIn()
+        val view = getDropTargetView()
+        assertThat(view).isNotNull()
+        assertThat(view!!.alpha).isEqualTo(1f)
+
+        runOnMainSync { controller.setHidden(true) }
+        waitForAnimateOut()
+        val hiddenView = getDropTargetView()
+        assertThat(hiddenView).isNotNull()
+        assertThat(hiddenView!!.alpha).isEqualTo(0f)
+
+        runOnMainSync { controller.setHidden(false) }
+        waitForAnimateIn()
+        val shownView = getDropTargetView()
+        assertThat(shownView).isNotNull()
+        assertThat(shownView!!.alpha).isEqualTo(1f)
+    }
+
+    @Test
+    fun toggleSetHidden_dropTargetNotShown_viewNotCreated() {
+        runOnMainSync { controller.setHidden(true) }
+        waitForAnimateOut()
+        assertThat(getDropTargetView()).isNull()
+        runOnMainSync { controller.setHidden(false) }
+        waitForAnimateIn()
+        assertThat(getDropTargetView()).isNull()
+    }
+
+    @Test
+    fun dismiss_dropTargetShown_viewRemoved() {
+        runOnMainSync { controller.show(BubbleBarLocation.LEFT) }
+        waitForAnimateIn()
+        assertThat(getDropTargetView()).isNotNull()
+        runOnMainSync { controller.dismiss() }
+        waitForAnimateOut()
+        assertThat(getDropTargetView()).isNull()
+    }
+
+    @Test
+    fun dismiss_dropTargetNotShown_doesNothing() {
+        runOnMainSync { controller.dismiss() }
+        waitForAnimateOut()
+        assertThat(getDropTargetView()).isNull()
+    }
+
+    private fun getDropTargetView(): View? = container.findViewById(R.id.bubble_bar_drop_target)
+
+    private fun getExpectedDropTargetBounds(onLeft: Boolean): Rect {
+        val rect = Rect()
+        positioner.getBubbleBarExpandedViewBounds(onLeft, false /* isOveflowExpanded */, rect)
+        // Scale the rect to expected size, but keep the center point the same
+        val centerX = rect.centerX()
+        val centerY = rect.centerY()
+        rect.scale(DROP_TARGET_SCALE)
+        rect.offset(centerX - rect.centerX(), centerY - rect.centerY())
+        return rect
+    }
+
+    private fun runOnMainSync(runnable: Runnable) {
+        InstrumentationRegistry.getInstrumentation().runOnMainSync(runnable)
+    }
+
+    private fun waitForAnimateIn() {
+        // Advance animator for on-device test
+        runOnMainSync { animatorTestRule.advanceTimeBy(DROP_TARGET_ALPHA_IN_DURATION) }
+    }
+
+    private fun waitForAnimateOut() {
+        // Advance animator for on-device test
+        runOnMainSync { animatorTestRule.advanceTimeBy(DROP_TARGET_ALPHA_OUT_DURATION) }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetController.kt
index 55ec6cd..f6b4653 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarDropTargetController.kt
@@ -21,6 +21,10 @@
 import android.view.View
 import android.widget.FrameLayout
 import android.widget.FrameLayout.LayoutParams
+import androidx.annotation.VisibleForTesting
+import androidx.core.animation.Animator
+import androidx.core.animation.AnimatorListenerAdapter
+import androidx.core.animation.ObjectAnimator
 import com.android.wm.shell.R
 import com.android.wm.shell.bubbles.BubblePositioner
 import com.android.wm.shell.common.bubbles.BubbleBarLocation
@@ -33,6 +37,7 @@
 ) {
 
     private var dropTargetView: View? = null
+    private var animator: ObjectAnimator? = null
     private val tempRect: Rect by lazy(LazyThreadSafetyMode.NONE) { Rect() }
 
     /**
@@ -57,7 +62,8 @@
     /**
      * Set the view hidden or not
      *
-     * Requires the drop target to be first shown by calling [show]. Otherwise does not do anything.
+     * Requires the drop target to be first shown by calling [animateIn]. Otherwise does not do
+     * anything.
      */
     fun setHidden(hidden: Boolean) {
         val targetView = dropTargetView ?: return
@@ -106,20 +112,40 @@
     }
 
     private fun View.animateIn() {
-        animate().alpha(1f).setDuration(DROP_TARGET_ALPHA_IN_DURATION).start()
+        animator?.cancel()
+        animator =
+            ObjectAnimator.ofFloat(this, View.ALPHA, 1f)
+                .setDuration(DROP_TARGET_ALPHA_IN_DURATION)
+                .addEndAction { animator = null }
+        animator?.start()
     }
 
     private fun View.animateOut(endAction: Runnable? = null) {
-        animate()
-            .alpha(0f)
-            .setDuration(DROP_TARGET_ALPHA_OUT_DURATION)
-            .withEndAction(endAction)
-            .start()
+        animator?.cancel()
+        animator =
+            ObjectAnimator.ofFloat(this, View.ALPHA, 0f)
+                .setDuration(DROP_TARGET_ALPHA_OUT_DURATION)
+                .addEndAction {
+                    endAction?.run()
+                    animator = null
+                }
+        animator?.start()
+    }
+
+    private fun <T : Animator> T.addEndAction(runnable: Runnable): T {
+        addListener(
+            object : AnimatorListenerAdapter() {
+                override fun onAnimationEnd(animation: Animator) {
+                    runnable.run()
+                }
+            }
+        )
+        return this
     }
 
     companion object {
-        private const val DROP_TARGET_ALPHA_IN_DURATION = 150L
-        private const val DROP_TARGET_ALPHA_OUT_DURATION = 100L
-        private const val DROP_TARGET_SCALE = 0.9f
+        @VisibleForTesting const val DROP_TARGET_ALPHA_IN_DURATION = 150L
+        @VisibleForTesting const val DROP_TARGET_ALPHA_OUT_DURATION = 100L
+        @VisibleForTesting const val DROP_TARGET_SCALE = 0.9f
     }
 }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/DefaultTransitionHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/DefaultTransitionHandler.java
index 9130edf..74e85f8 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/DefaultTransitionHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/DefaultTransitionHandler.java
@@ -334,6 +334,7 @@
         boolean isDisplayRotationAnimationStarted = false;
         final boolean isDreamTransition = isDreamTransition(info);
         final boolean isOnlyTranslucent = isOnlyTranslucent(info);
+        final boolean isActivityLevel = isActivityLevelOnly(info);
 
         for (int i = info.getChanges().size() - 1; i >= 0; --i) {
             final TransitionInfo.Change change = info.getChanges().get(i);
@@ -502,8 +503,35 @@
                         : new Rect(change.getEndAbsBounds());
                 clipRect.offsetTo(0, 0);
 
+                final TransitionInfo.Root animRoot = TransitionUtil.getRootFor(change, info);
+                final Point animRelOffset = new Point(
+                        change.getEndAbsBounds().left - animRoot.getOffset().x,
+                        change.getEndAbsBounds().top - animRoot.getOffset().y);
+                if (change.getActivityComponent() != null && !isActivityLevel) {
+                    // At this point, this is an independent activity change in a non-activity
+                    // transition. This means that an activity transition got erroneously combined
+                    // with another ongoing transition. This then means that the animation root may
+                    // not tightly fit the activities, so we have to put them in a separate crop.
+                    final int layer = Transitions.calculateAnimLayer(change, i,
+                            info.getChanges().size(), info.getType());
+                    final SurfaceControl leash = new SurfaceControl.Builder()
+                            .setName("Transition ActivityWrap: "
+                                    + change.getActivityComponent().toShortString())
+                            .setParent(animRoot.getLeash())
+                            .setContainerLayer().build();
+                    startTransaction.setCrop(leash, clipRect);
+                    startTransaction.setPosition(leash, animRelOffset.x, animRelOffset.y);
+                    startTransaction.setLayer(leash, layer);
+                    startTransaction.show(leash);
+                    startTransaction.reparent(change.getLeash(), leash);
+                    startTransaction.setPosition(change.getLeash(), 0, 0);
+                    animRelOffset.set(0, 0);
+                    finishTransaction.reparent(leash, null);
+                    leash.release();
+                }
+
                 buildSurfaceAnimation(animations, a, change.getLeash(), onAnimFinish,
-                        mTransactionPool, mMainExecutor, change.getEndRelOffset(), cornerRadius,
+                        mTransactionPool, mMainExecutor, animRelOffset, cornerRadius,
                         clipRect);
 
                 if (info.getAnimationOptions() != null) {
@@ -612,6 +640,18 @@
         return (translucentOpen + translucentClose) > 0;
     }
 
+    /**
+     * Does `info` only contain activity-level changes? This kinda assumes that if so, they are
+     * all in one task.
+     */
+    private static boolean isActivityLevelOnly(@NonNull TransitionInfo info) {
+        for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+            final TransitionInfo.Change change = info.getChanges().get(i);
+            if (change.getActivityComponent() == null) return false;
+        }
+        return true;
+    }
+
     @Override
     public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
             @NonNull SurfaceControl.Transaction t, @NonNull IBinder mergeTarget,
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
index ccd0b2d..6a53d33 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
@@ -31,7 +31,6 @@
 import static android.window.TransitionInfo.FLAG_BACK_GESTURE_ANIMATED;
 import static android.window.TransitionInfo.FLAG_IS_BEHIND_STARTING_WINDOW;
 import static android.window.TransitionInfo.FLAG_IS_OCCLUDED;
-import static android.window.TransitionInfo.FLAG_IS_WALLPAPER;
 import static android.window.TransitionInfo.FLAG_MOVED_TO_TOP;
 import static android.window.TransitionInfo.FLAG_NO_ANIMATION;
 import static android.window.TransitionInfo.FLAG_STARTING_WINDOW_TRANSFER_RECIPIENT;
@@ -530,6 +529,44 @@
         }
     }
 
+    static int calculateAnimLayer(@NonNull TransitionInfo.Change change, int i,
+            int numChanges, @WindowManager.TransitionType int transitType) {
+        // Put animating stuff above this line and put static stuff below it.
+        final int zSplitLine = numChanges + 1;
+        final boolean isOpening = isOpeningType(transitType);
+        final boolean isClosing = isClosingType(transitType);
+        final int mode = change.getMode();
+        // Put all the OPEN/SHOW on top
+        if (mode == TRANSIT_OPEN || mode == TRANSIT_TO_FRONT) {
+            if (isOpening
+                    // This is for when an activity launches while a different transition is
+                    // collecting.
+                    || change.hasFlags(FLAG_MOVED_TO_TOP)) {
+                // put on top
+                return zSplitLine + numChanges - i;
+            } else {
+                // put on bottom
+                return zSplitLine - i;
+            }
+        } else if (mode == TRANSIT_CLOSE || mode == TRANSIT_TO_BACK) {
+            if (isOpening) {
+                // put on bottom and leave visible
+                return zSplitLine - i;
+            } else {
+                // put on top
+                return zSplitLine + numChanges - i;
+            }
+        } else { // CHANGE or other
+            if (isClosing || TransitionUtil.isOrderOnly(change)) {
+                // Put below CLOSE mode (in the "static" section).
+                return zSplitLine - i;
+            } else {
+                // Put above CLOSE mode.
+                return zSplitLine + numChanges - i;
+            }
+        }
+    }
+
     /**
      * Reparents all participants into a shared parent and orders them based on: the global transit
      * type, their transit mode, and their destination z-order.
@@ -537,19 +574,14 @@
     private static void setupAnimHierarchy(@NonNull TransitionInfo info,
             @NonNull SurfaceControl.Transaction t, @NonNull SurfaceControl.Transaction finishT) {
         final int type = info.getType();
-        final boolean isOpening = isOpeningType(type);
-        final boolean isClosing = isClosingType(type);
         for (int i = 0; i < info.getRootCount(); ++i) {
             t.show(info.getRoot(i).getLeash());
         }
         final int numChanges = info.getChanges().size();
-        // Put animating stuff above this line and put static stuff below it.
-        final int zSplitLine = numChanges + 1;
         // changes should be ordered top-to-bottom in z
         for (int i = numChanges - 1; i >= 0; --i) {
             final TransitionInfo.Change change = info.getChanges().get(i);
             final SurfaceControl leash = change.getLeash();
-            final int mode = change.getMode();
 
             // Don't reparent anything that isn't independent within its parents
             if (!TransitionInfo.isIndependent(change, info)) {
@@ -558,50 +590,14 @@
 
             boolean hasParent = change.getParent() != null;
 
-            final int rootIdx = TransitionUtil.rootIndexFor(change, info);
+            final TransitionInfo.Root root = TransitionUtil.getRootFor(change, info);
             if (!hasParent) {
-                t.reparent(leash, info.getRoot(rootIdx).getLeash());
+                t.reparent(leash, root.getLeash());
                 t.setPosition(leash,
-                        change.getStartAbsBounds().left - info.getRoot(rootIdx).getOffset().x,
-                        change.getStartAbsBounds().top - info.getRoot(rootIdx).getOffset().y);
+                        change.getStartAbsBounds().left - root.getOffset().x,
+                        change.getStartAbsBounds().top - root.getOffset().y);
             }
-            final int layer;
-            // Put all the OPEN/SHOW on top
-            if ((change.getFlags() & FLAG_IS_WALLPAPER) != 0) {
-                // Wallpaper is always at the bottom, opening wallpaper on top of closing one.
-                if (mode == TRANSIT_OPEN || mode == TRANSIT_TO_FRONT) {
-                    layer = -zSplitLine + numChanges - i;
-                } else {
-                    layer = -zSplitLine - i;
-                }
-            } else if (mode == TRANSIT_OPEN || mode == TRANSIT_TO_FRONT) {
-                if (isOpening
-                        // This is for when an activity launches while a different transition is
-                        // collecting.
-                        || change.hasFlags(FLAG_MOVED_TO_TOP)) {
-                    // put on top
-                    layer = zSplitLine + numChanges - i;
-                } else {
-                    // put on bottom
-                    layer = zSplitLine - i;
-                }
-            } else if (mode == TRANSIT_CLOSE || mode == TRANSIT_TO_BACK) {
-                if (isOpening) {
-                    // put on bottom and leave visible
-                    layer = zSplitLine - i;
-                } else {
-                    // put on top
-                    layer = zSplitLine + numChanges - i;
-                }
-            } else { // CHANGE or other
-                if (isClosing || TransitionUtil.isOrderOnly(change)) {
-                    // Put below CLOSE mode (in the "static" section).
-                    layer = zSplitLine - i;
-                } else {
-                    // Put above CLOSE mode.
-                    layer = zSplitLine + numChanges - i;
-                }
-            }
+            final int layer = calculateAnimLayer(change, i, numChanges, type);
             t.setLayer(leash, layer);
         }
     }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositioner.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositioner.java
index 6f8b3d5..76096b0 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositioner.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositioner.java
@@ -18,6 +18,7 @@
 
 import static android.view.WindowManager.TRANSIT_CHANGE;
 
+import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
 import android.os.IBinder;
@@ -178,10 +179,11 @@
         for (TransitionInfo.Change change: info.getChanges()) {
             final SurfaceControl sc = change.getLeash();
             final Rect endBounds = change.getEndAbsBounds();
+            final Point endPosition = change.getEndRelOffset();
             startTransaction.setWindowCrop(sc, endBounds.width(), endBounds.height())
-                    .setPosition(sc, endBounds.left, endBounds.top);
+                    .setPosition(sc, endPosition.x, endPosition.y);
             finishTransaction.setWindowCrop(sc, endBounds.width(), endBounds.height())
-                    .setPosition(sc, endBounds.left, endBounds.top);
+                    .setPosition(sc, endPosition.x, endPosition.y);
         }
 
         startTransaction.apply();
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositioner.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositioner.java
index c12a93e..5fce5d2 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositioner.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositioner.java
@@ -18,6 +18,7 @@
 
 import static android.view.WindowManager.TRANSIT_CHANGE;
 
+import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
 import android.os.IBinder;
@@ -179,10 +180,11 @@
         for (TransitionInfo.Change change: info.getChanges()) {
             final SurfaceControl sc = change.getLeash();
             final Rect endBounds = change.getEndAbsBounds();
+            final Point endPosition = change.getEndRelOffset();
             startTransaction.setWindowCrop(sc, endBounds.width(), endBounds.height())
-                    .setPosition(sc, endBounds.left, endBounds.top);
+                    .setPosition(sc, endPosition.x, endPosition.y);
             finishTransaction.setWindowCrop(sc, endBounds.width(), endBounds.height())
-                    .setPosition(sc, endBounds.left, endBounds.top);
+                    .setPosition(sc, endPosition.x, endPosition.y);
         }
 
         startTransaction.apply();
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/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositionerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositionerTest.kt
index ce7b633..9174556 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositionerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/FluidResizeTaskPositionerTest.kt
@@ -2,6 +2,7 @@
 
 import android.app.ActivityManager
 import android.app.WindowConfiguration
+import android.graphics.Point
 import android.graphics.Rect
 import android.os.IBinder
 import android.testing.AndroidTestingRunner
@@ -11,6 +12,7 @@
 import android.view.Surface.ROTATION_90
 import android.view.SurfaceControl
 import android.view.WindowManager
+import android.window.TransitionInfo
 import android.window.WindowContainerToken
 import android.window.WindowContainerTransaction
 import android.window.WindowContainerTransaction.Change.CHANGE_DRAG_RESIZING
@@ -41,6 +43,8 @@
 import org.mockito.MockitoAnnotations
 import org.mockito.kotlin.doReturn
 import java.util.function.Supplier
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.mock
 import org.mockito.Mockito.`when` as whenever
 
 /**
@@ -575,6 +579,32 @@
         })
     }
 
+    @Test
+    fun testStartAnimation_useEndRelOffset() {
+        val mockTransitionInfo = mock(TransitionInfo::class.java)
+        val changeMock = mock(TransitionInfo.Change::class.java)
+        val startTransaction = mock(SurfaceControl.Transaction::class.java)
+        val finishTransaction = mock(SurfaceControl.Transaction::class.java)
+        val point = Point(10, 20)
+        val bounds = Rect(1, 2, 3, 4)
+        `when`(changeMock.endRelOffset).thenReturn(point)
+        `when`(changeMock.endAbsBounds).thenReturn(bounds)
+        `when`(mockTransitionInfo.changes).thenReturn(listOf(changeMock))
+        `when`(startTransaction.setWindowCrop(any(),
+            eq(bounds.width()),
+            eq(bounds.height()))).thenReturn(startTransaction)
+        `when`(finishTransaction.setWindowCrop(any(),
+            eq(bounds.width()),
+            eq(bounds.height()))).thenReturn(finishTransaction)
+
+        taskPositioner.startAnimation(mockTransitionBinder, mockTransitionInfo, startTransaction,
+            finishTransaction, { _ -> })
+
+        verify(startTransaction).setPosition(any(), eq(point.x.toFloat()), eq(point.y.toFloat()))
+        verify(finishTransaction).setPosition(any(), eq(point.x.toFloat()), eq(point.y.toFloat()))
+        verify(changeMock).endRelOffset
+    }
+
     private fun WindowContainerTransaction.Change.ofBounds(bounds: Rect): Boolean {
         return ((windowSetMask and WindowConfiguration.WINDOW_CONFIG_BOUNDS) != 0) &&
                 bounds == configuration.windowConfiguration.bounds
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositionerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositionerTest.kt
index 7f6e538..a9f4492 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositionerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/VeiledResizeTaskPositionerTest.kt
@@ -17,6 +17,7 @@
 
 import android.app.ActivityManager
 import android.app.WindowConfiguration
+import android.graphics.Point
 import android.graphics.Rect
 import android.os.IBinder
 import android.testing.AndroidTestingRunner
@@ -25,6 +26,7 @@
 import android.view.Surface.ROTATION_270
 import android.view.Surface.ROTATION_90
 import android.view.SurfaceControl
+import android.view.SurfaceControl.Transaction
 import android.view.WindowManager.TRANSIT_CHANGE
 import android.window.TransitionInfo
 import android.window.WindowContainerToken
@@ -39,6 +41,7 @@
 import com.android.wm.shell.windowdecor.DragPositioningCallback.CTRL_TYPE_RIGHT
 import com.android.wm.shell.windowdecor.DragPositioningCallback.CTRL_TYPE_TOP
 import com.android.wm.shell.windowdecor.DragPositioningCallback.CTRL_TYPE_UNDEFINED
+import java.util.function.Supplier
 import junit.framework.Assert
 import org.junit.Before
 import org.junit.Test
@@ -47,13 +50,13 @@
 import org.mockito.Mockito.any
 import org.mockito.Mockito.argThat
 import org.mockito.Mockito.eq
+import org.mockito.Mockito.mock
 import org.mockito.Mockito.never
 import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
-import org.mockito.MockitoAnnotations
-import java.util.function.Supplier
 import org.mockito.Mockito.`when` as whenever
+import org.mockito.MockitoAnnotations
 
 /**
  * Tests for [VeiledResizeTaskPositioner].
@@ -439,6 +442,40 @@
         Assert.assertFalse(taskPositioner.isResizingOrAnimating)
     }
 
+    @Test
+    fun testStartAnimation_useEndRelOffset() {
+        val changeMock = mock(TransitionInfo.Change::class.java)
+        val startTransaction = mock(Transaction::class.java)
+        val finishTransaction = mock(Transaction::class.java)
+        val point = Point(10, 20)
+        val bounds = Rect(1, 2, 3, 4)
+        `when`(changeMock.endRelOffset).thenReturn(point)
+        `when`(changeMock.endAbsBounds).thenReturn(bounds)
+        `when`(mockTransitionInfo.changes).thenReturn(listOf(changeMock))
+        `when`(startTransaction.setWindowCrop(
+            any(),
+            eq(bounds.width()),
+            eq(bounds.height())
+        )).thenReturn(startTransaction)
+        `when`(finishTransaction.setWindowCrop(
+            any(),
+            eq(bounds.width()),
+            eq(bounds.height())
+        )).thenReturn(finishTransaction)
+
+        taskPositioner.startAnimation(
+            mockTransitionBinder,
+            mockTransitionInfo,
+            startTransaction,
+            finishTransaction,
+            mockFinishCallback
+        )
+
+        verify(startTransaction).setPosition(any(), eq(point.x.toFloat()), eq(point.y.toFloat()))
+        verify(finishTransaction).setPosition(any(), eq(point.x.toFloat()), eq(point.y.toFloat()))
+        verify(changeMock).endRelOffset
+    }
+
     private fun performDrag(
         startX: Float,
         startY: Float,
diff --git a/media/java/android/media/AudioManager.java b/media/java/android/media/AudioManager.java
index 6f7024a..1fe3c2e 100644
--- a/media/java/android/media/AudioManager.java
+++ b/media/java/android/media/AudioManager.java
@@ -5453,7 +5453,8 @@
             String regId = service.registerAudioPolicy(policy.getConfig(), policy.cb(),
                     policy.hasFocusListener(), policy.isFocusPolicy(), policy.isTestFocusPolicy(),
                     policy.isVolumeController(),
-                    projection == null ? null : projection.getProjection());
+                    projection == null ? null : projection.getProjection(),
+                    policy.getAttributionSource());
             if (regId == null) {
                 return ERROR;
             } else {
diff --git a/media/java/android/media/AudioRecord.java b/media/java/android/media/AudioRecord.java
index 447d3bb..80e5719 100644
--- a/media/java/android/media/AudioRecord.java
+++ b/media/java/android/media/AudioRecord.java
@@ -789,7 +789,7 @@
         private @NonNull AudioRecord buildAudioPlaybackCaptureRecord() {
             AudioMix audioMix = mAudioPlaybackCaptureConfiguration.createAudioMix(mFormat);
             MediaProjection projection = mAudioPlaybackCaptureConfiguration.getMediaProjection();
-            AudioPolicy audioPolicy = new AudioPolicy.Builder(/*context=*/ null)
+            AudioPolicy audioPolicy = new AudioPolicy.Builder(/*context=*/ mContext)
                     .setMediaProjection(projection)
                     .addMix(audioMix).build();
 
@@ -853,7 +853,7 @@
                     .setFormat(mFormat)
                     .setRouteFlags(AudioMix.ROUTE_FLAG_LOOP_BACK)
                     .build();
-            AudioPolicy audioPolicy = new AudioPolicy.Builder(null).addMix(audioMix).build();
+            AudioPolicy audioPolicy = new AudioPolicy.Builder(mContext).addMix(audioMix).build();
             if (AudioManager.registerAudioPolicyStatic(audioPolicy) != 0) {
                 throw new UnsupportedOperationException("Error: could not register audio policy");
             }
diff --git a/media/java/android/media/AudioTrack.java b/media/java/android/media/AudioTrack.java
index 194da21..73deb17 100644
--- a/media/java/android/media/AudioTrack.java
+++ b/media/java/android/media/AudioTrack.java
@@ -1353,7 +1353,8 @@
                     .setRouteFlags(AudioMix.ROUTE_FLAG_LOOP_BACK)
                     .build();
             AudioPolicy audioPolicy =
-                    new AudioPolicy.Builder(/*context=*/ null).addMix(audioMix).build();
+                    new AudioPolicy.Builder(/*context=*/ mContext).addMix(audioMix).build();
+
             if (AudioManager.registerAudioPolicyStatic(audioPolicy) != 0) {
                 throw new UnsupportedOperationException("Error: could not register audio policy");
             }
diff --git a/media/java/android/media/IAudioService.aidl b/media/java/android/media/IAudioService.aidl
index 98bd3ca..e612645 100644
--- a/media/java/android/media/IAudioService.aidl
+++ b/media/java/android/media/IAudioService.aidl
@@ -18,6 +18,7 @@
 
 import android.bluetooth.BluetoothDevice;
 import android.content.ComponentName;
+import android.content.AttributionSource;
 import android.media.AudioAttributes;
 import android.media.AudioDeviceAttributes;
 import android.media.AudioFormat;
@@ -361,7 +362,8 @@
     String registerAudioPolicy(in AudioPolicyConfig policyConfig,
             in IAudioPolicyCallback pcb, boolean hasFocusListener, boolean isFocusPolicy,
             boolean isTestFocusPolicy,
-            boolean isVolumeController, in IMediaProjection projection);
+            boolean isVolumeController, in IMediaProjection projection,
+            in AttributionSource attributionSource);
 
     oneway void unregisterAudioPolicyAsync(in IAudioPolicyCallback pcb);
 
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/media/java/android/media/audiopolicy/AudioMix.java b/media/java/android/media/audiopolicy/AudioMix.java
index a53a8ce..e4eaaa3 100644
--- a/media/java/android/media/audiopolicy/AudioMix.java
+++ b/media/java/android/media/audiopolicy/AudioMix.java
@@ -24,6 +24,7 @@
 import android.annotation.Nullable;
 import android.annotation.SystemApi;
 import android.compat.annotation.UnsupportedAppUsage;
+import android.content.Context;
 import android.media.AudioDeviceInfo;
 import android.media.AudioFormat;
 import android.media.AudioSystem;
@@ -67,12 +68,19 @@
     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
     final int mDeviceSystemType; // an AudioSystem.DEVICE_* value, not AudioDeviceInfo.TYPE_*
 
+    // The (virtual) device ID that this AudioMix was registered for. This value is overwritten
+    // when registering this AudioMix with an AudioPolicy or attaching this AudioMix to an
+    // AudioPolicy to match the AudioPolicy attribution. Does not imply that it only modifies
+    // audio routing for this device ID.
+    private int mVirtualDeviceId;
+
     /**
      * All parameters are guaranteed valid through the Builder.
      */
     private AudioMix(@NonNull AudioMixingRule rule, @NonNull AudioFormat format,
             int routeFlags, int callbackFlags,
-            int deviceType, @Nullable String deviceAddress, IBinder token) {
+            int deviceType, @Nullable String deviceAddress, IBinder token,
+            int virtualDeviceId) {
         mRule = Objects.requireNonNull(rule);
         mFormat = Objects.requireNonNull(format);
         mRouteFlags = routeFlags;
@@ -81,6 +89,7 @@
         mDeviceSystemType = deviceType;
         mDeviceAddress = (deviceAddress == null) ? new String("") : deviceAddress;
         mToken = token;
+        mVirtualDeviceId = virtualDeviceId;
     }
 
     // CALLBACK_FLAG_* values: keep in sync with AudioMix::kCbFlag* values defined
@@ -269,6 +278,11 @@
     }
 
     /** @hide */
+    public boolean matchesVirtualDeviceId(int deviceId) {
+        return mVirtualDeviceId == deviceId;
+    }
+
+    /** @hide */
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
@@ -311,6 +325,7 @@
         mFormat.writeToParcel(dest, flags);
         mRule.writeToParcel(dest, flags);
         dest.writeStrongBinder(mToken);
+        dest.writeInt(mVirtualDeviceId);
     }
 
     public static final @NonNull Parcelable.Creator<AudioMix> CREATOR = new Parcelable.Creator<>() {
@@ -331,6 +346,7 @@
             mixBuilder.setFormat(AudioFormat.CREATOR.createFromParcel(p));
             mixBuilder.setMixingRule(AudioMixingRule.CREATOR.createFromParcel(p));
             mixBuilder.setToken(p.readStrongBinder());
+            mixBuilder.setVirtualDeviceId(p.readInt());
             return mixBuilder.build();
         }
 
@@ -339,6 +355,15 @@
         }
     };
 
+    /**
+     * Updates the deviceId of the AudioMix to match with the AudioPolicy the mix is registered
+     * through.
+     * @hide
+     */
+    public void setVirtualDeviceId(int virtualDeviceId) {
+        mVirtualDeviceId = virtualDeviceId;
+    }
+
     /** @hide */
     @IntDef(flag = true,
             value = { ROUTE_FLAG_RENDER, ROUTE_FLAG_LOOP_BACK } )
@@ -354,6 +379,7 @@
         private int mRouteFlags = 0;
         private int mCallbackFlags = 0;
         private IBinder mToken = null;
+        private int mVirtualDeviceId = Context.DEVICE_ID_DEFAULT;
         // an AudioSystem.DEVICE_* value, not AudioDeviceInfo.TYPE_*
         private int mDeviceSystemType = AudioSystem.DEVICE_NONE;
         private String mDeviceAddress = null;
@@ -404,6 +430,15 @@
 
         /**
          * @hide
+         * Only used by AudioMix internally.
+         */
+        Builder setVirtualDeviceId(int virtualDeviceId) {
+            mVirtualDeviceId = virtualDeviceId;
+            return this;
+        }
+
+        /**
+         * @hide
          * Only used by AudioPolicyConfig, not a public API.
          * @param callbackFlags which callbacks are called from native
          * @return the same Builder instance.
@@ -570,7 +605,7 @@
             }
 
             return new AudioMix(mRule, mFormat, mRouteFlags, mCallbackFlags, mDeviceSystemType,
-                    mDeviceAddress, mToken);
+                    mDeviceAddress, mToken, mVirtualDeviceId);
         }
 
         private int getLoopbackDeviceSystemTypeForAudioMixingRule(AudioMixingRule rule) {
diff --git a/media/java/android/media/audiopolicy/AudioPolicy.java b/media/java/android/media/audiopolicy/AudioPolicy.java
index 508c0a2b..293a8f8 100644
--- a/media/java/android/media/audiopolicy/AudioPolicy.java
+++ b/media/java/android/media/audiopolicy/AudioPolicy.java
@@ -27,6 +27,7 @@
 import android.annotation.TestApi;
 import android.annotation.UserIdInt;
 import android.app.ActivityManager;
+import android.content.AttributionSource;
 import android.content.Context;
 import android.content.pm.PackageManager;
 import android.media.AudioAttributes;
@@ -146,6 +147,16 @@
         return mProjection;
     }
 
+    /** @hide */
+    public AttributionSource getAttributionSource() {
+        return getAttributionSource(mContext);
+    }
+
+    private static AttributionSource getAttributionSource(Context context) {
+        return context == null
+                ? AttributionSource.myAttributionSource() : context.getAttributionSource();
+    }
+
     /**
      * The parameters are guaranteed non-null through the Builder
      */
@@ -208,6 +219,9 @@
             if (mix == null) {
                 throw new IllegalArgumentException("Illegal null AudioMix argument");
             }
+            if (android.permission.flags.Flags.deviceAwarePermissionApisEnabled()) {
+                mix.setVirtualDeviceId(getAttributionSource(mContext).getDeviceId());
+            }
             mMixes.add(mix);
             return this;
         }
@@ -358,6 +372,9 @@
                 if (mix == null) {
                     throw new IllegalArgumentException("Illegal null AudioMix in attachMixes");
                 } else {
+                    if (android.permission.flags.Flags.deviceAwarePermissionApisEnabled()) {
+                        mix.setVirtualDeviceId(getAttributionSource(mContext).getDeviceId());
+                    }
                     zeMixes.add(mix);
                 }
             }
@@ -400,6 +417,9 @@
                 if (mix == null) {
                     throw new IllegalArgumentException("Illegal null AudioMix in detachMixes");
                 } else {
+                    if (android.permission.flags.Flags.deviceAwarePermissionApisEnabled()) {
+                        mix.setVirtualDeviceId(getAttributionSource(mContext).getDeviceId());
+                    }
                     zeMixes.add(mix);
                 }
             }
diff --git a/native/android/OWNERS b/native/android/OWNERS
index 0b86909..9a3527d 100644
--- a/native/android/OWNERS
+++ b/native/android/OWNERS
@@ -16,6 +16,8 @@
 per-file native_window_jni.cpp = file:/services/core/java/com/android/server/wm/OWNERS
 per-file native_activity.cpp = file:/services/core/java/com/android/server/wm/OWNERS
 per-file surface_control.cpp = file:/services/core/java/com/android/server/wm/OWNERS
+per-file surface_control_input_receiver.cpp = file:/services/core/java/com/android/server/wm/OWNERS
+per-file input_transfer_token.cpp = file:/services/core/java/com/android/server/wm/OWNERS
 
 # Graphics
 per-file choreographer.cpp = file:/graphics/java/android/graphics/OWNERS
diff --git a/native/android/surface_control_input_receiver.cpp b/native/android/surface_control_input_receiver.cpp
index d178abc..a84ec73 100644
--- a/native/android/surface_control_input_receiver.cpp
+++ b/native/android/surface_control_input_receiver.cpp
@@ -192,7 +192,9 @@
 
 void AInputReceiver_release(AInputReceiver* aInputReceiver) {
     InputReceiver* inputReceiver = AInputReceiver_to_InputReceiver(aInputReceiver);
-    inputReceiver->remove();
+    if (inputReceiver != nullptr) {
+        inputReceiver->remove();
+    }
     delete inputReceiver;
 }
 
diff --git a/nfc/java/android/nfc/cardemulation/ApduServiceInfo.java b/nfc/java/android/nfc/cardemulation/ApduServiceInfo.java
index be3c248..a353df7 100644
--- a/nfc/java/android/nfc/cardemulation/ApduServiceInfo.java
+++ b/nfc/java/android/nfc/cardemulation/ApduServiceInfo.java
@@ -723,6 +723,7 @@
      * delivered to {@link HostApduService#processPollingFrames(List)}. Adding a key with this
      * multiple times will cause the value to be overwritten each time.
      * @param pollingLoopFilter the polling loop filter to add, must be a valid hexadecimal string
+     * @param autoTransact whether Observe Mode should be disabled when this filter matches or not
      */
     @FlaggedApi(Flags.FLAG_NFC_READ_POLLING_LOOP)
     public void addPollingLoopFilter(@NonNull String pollingLoopFilter,
@@ -747,6 +748,7 @@
      * multiple times will cause the value to be overwritten each time.
      * @param pollingLoopPatternFilter the polling loop pattern filter to add, must be a valid
      *                                regex to match a hexadecimal string
+     * @param autoTransact whether Observe Mode should be disabled when this filter matches or not
      */
     @FlaggedApi(Flags.FLAG_NFC_READ_POLLING_LOOP)
     public void addPollingLoopPatternFilter(@NonNull String pollingLoopPatternFilter,
diff --git a/packages/CrashRecovery/services/java/com/android/server/PackageWatchdog.java b/packages/CrashRecovery/services/java/com/android/server/PackageWatchdog.java
index 37b5d40..a8d8f9a 100644
--- a/packages/CrashRecovery/services/java/com/android/server/PackageWatchdog.java
+++ b/packages/CrashRecovery/services/java/com/android/server/PackageWatchdog.java
@@ -26,6 +26,7 @@
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.VersionedPackage;
+import android.crashrecovery.flags.Flags;
 import android.net.ConnectivityModuleConnector;
 import android.os.Environment;
 import android.os.Handler;
@@ -57,16 +58,20 @@
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.FileReader;
 import java.io.FileWriter;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -130,8 +135,25 @@
 
     @VisibleForTesting
     static final int DEFAULT_BOOT_LOOP_TRIGGER_COUNT = 5;
-    @VisibleForTesting
+
     static final long DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS = TimeUnit.MINUTES.toMillis(10);
+    // Boot loop at which packageWatchdog starts first mitigation
+    private static final String BOOT_LOOP_THRESHOLD =
+            "persist.device_config.configuration.boot_loop_threshold";
+    @VisibleForTesting
+    static final int DEFAULT_BOOT_LOOP_THRESHOLD = 15;
+    // Once boot_loop_threshold is surpassed next mitigation would be triggered after
+    // specified number of reboots.
+    private static final String BOOT_LOOP_MITIGATION_INCREMENT =
+            "persist.device_config.configuration..boot_loop_mitigation_increment";
+    @VisibleForTesting
+    static final int DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT = 2;
+
+    // Threshold level at which or above user might experience significant disruption.
+    private static final String MAJOR_USER_IMPACT_LEVEL_THRESHOLD =
+            "persist.device_config.configuration.major_user_impact_level_threshold";
+    private static final int DEFAULT_MAJOR_USER_IMPACT_LEVEL_THRESHOLD =
+            PackageHealthObserverImpact.USER_IMPACT_LEVEL_71;
 
     private long mNumberOfNativeCrashPollsRemaining;
 
@@ -145,6 +167,7 @@
     private static final String ATTR_EXPLICIT_HEALTH_CHECK_DURATION = "health-check-duration";
     private static final String ATTR_PASSED_HEALTH_CHECK = "passed-health-check";
     private static final String ATTR_MITIGATION_CALLS = "mitigation-calls";
+    private static final String ATTR_MITIGATION_COUNT = "mitigation-count";
 
     // A file containing information about the current mitigation count in the case of a boot loop.
     // This allows boot loop information to persist in the case of an fs-checkpoint being
@@ -230,8 +253,16 @@
         mConnectivityModuleConnector = connectivityModuleConnector;
         mSystemClock = clock;
         mNumberOfNativeCrashPollsRemaining = NUMBER_OF_NATIVE_CRASH_POLLS;
-        mBootThreshold = new BootThreshold(DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
-                DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS);
+        if (Flags.recoverabilityDetection()) {
+            mBootThreshold = new BootThreshold(DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS,
+                    SystemProperties.getInt(BOOT_LOOP_MITIGATION_INCREMENT,
+                            DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT));
+        } else {
+            mBootThreshold = new BootThreshold(DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS);
+        }
+
         loadFromFile();
         sPackageWatchdog = this;
     }
@@ -436,8 +467,13 @@
                                 mitigationCount =
                                         currentMonitoredPackage.getMitigationCountLocked();
                             }
-                            currentObserverToNotify.execute(versionedPackage,
-                                    failureReason, mitigationCount);
+                            if (Flags.recoverabilityDetection()) {
+                                maybeExecute(currentObserverToNotify, versionedPackage,
+                                        failureReason, currentObserverImpact, mitigationCount);
+                            } else {
+                                currentObserverToNotify.execute(versionedPackage,
+                                        failureReason, mitigationCount);
+                            }
                         }
                     }
                 }
@@ -467,37 +503,76 @@
             }
         }
         if (currentObserverToNotify != null) {
-            currentObserverToNotify.execute(failingPackage,  failureReason, 1);
+            if (Flags.recoverabilityDetection()) {
+                maybeExecute(currentObserverToNotify, failingPackage, failureReason,
+                        currentObserverImpact, /*mitigationCount=*/ 1);
+            } else {
+                currentObserverToNotify.execute(failingPackage,  failureReason, 1);
+            }
         }
     }
 
+    private void maybeExecute(PackageHealthObserver currentObserverToNotify,
+                              VersionedPackage versionedPackage,
+                              @FailureReasons int failureReason,
+                              int currentObserverImpact,
+                              int mitigationCount) {
+        if (currentObserverImpact < getUserImpactLevelLimit()) {
+            currentObserverToNotify.execute(versionedPackage, failureReason, mitigationCount);
+        }
+    }
+
+
     /**
      * Called when the system server boots. If the system server is detected to be in a boot loop,
      * query each observer and perform the mitigation action with the lowest user impact.
      */
+    @SuppressWarnings("GuardedBy")
     public void noteBoot() {
         synchronized (mLock) {
-            if (mBootThreshold.incrementAndTest()) {
-                mBootThreshold.reset();
+            boolean mitigate = mBootThreshold.incrementAndTest();
+            if (mitigate) {
+                if (!Flags.recoverabilityDetection()) {
+                    mBootThreshold.reset();
+                }
                 int mitigationCount = mBootThreshold.getMitigationCount() + 1;
                 PackageHealthObserver currentObserverToNotify = null;
+                ObserverInternal currentObserverInternal = null;
                 int currentObserverImpact = Integer.MAX_VALUE;
                 for (int i = 0; i < mAllObservers.size(); i++) {
                     final ObserverInternal observer = mAllObservers.valueAt(i);
                     PackageHealthObserver registeredObserver = observer.registeredObserver;
                     if (registeredObserver != null) {
-                        int impact = registeredObserver.onBootLoop(mitigationCount);
+                        int impact = Flags.recoverabilityDetection()
+                                ? registeredObserver.onBootLoop(
+                                        observer.getBootMitigationCount() + 1)
+                                : registeredObserver.onBootLoop(mitigationCount);
                         if (impact != PackageHealthObserverImpact.USER_IMPACT_LEVEL_0
                                 && impact < currentObserverImpact) {
                             currentObserverToNotify = registeredObserver;
+                            currentObserverInternal = observer;
                             currentObserverImpact = impact;
                         }
                     }
                 }
                 if (currentObserverToNotify != null) {
-                    mBootThreshold.setMitigationCount(mitigationCount);
-                    mBootThreshold.saveMitigationCountToMetadata();
-                    currentObserverToNotify.executeBootLoopMitigation(mitigationCount);
+                    if (Flags.recoverabilityDetection()) {
+                        if (currentObserverImpact < getUserImpactLevelLimit()
+                                || (currentObserverImpact >= getUserImpactLevelLimit()
+                                        && mBootThreshold.getCount() >= getBootLoopThreshold())) {
+                            int currentObserverMitigationCount =
+                                    currentObserverInternal.getBootMitigationCount() + 1;
+                            currentObserverInternal.setBootMitigationCount(
+                                    currentObserverMitigationCount);
+                            saveAllObserversBootMitigationCountToMetadata(METADATA_FILE);
+                            currentObserverToNotify.executeBootLoopMitigation(
+                                    currentObserverMitigationCount);
+                        }
+                    } else {
+                        mBootThreshold.setMitigationCount(mitigationCount);
+                        mBootThreshold.saveMitigationCountToMetadata();
+                        currentObserverToNotify.executeBootLoopMitigation(mitigationCount);
+                    }
                 }
             }
         }
@@ -567,13 +642,27 @@
         mShortTaskHandler.post(()->checkAndMitigateNativeCrashes());
     }
 
+    private int getUserImpactLevelLimit() {
+        return SystemProperties.getInt(MAJOR_USER_IMPACT_LEVEL_THRESHOLD,
+                DEFAULT_MAJOR_USER_IMPACT_LEVEL_THRESHOLD);
+    }
+
+    private int getBootLoopThreshold() {
+        return SystemProperties.getInt(BOOT_LOOP_THRESHOLD,
+                DEFAULT_BOOT_LOOP_THRESHOLD);
+    }
+
     /** Possible severity values of the user impact of a {@link PackageHealthObserver#execute}. */
     @Retention(SOURCE)
     @IntDef(value = {PackageHealthObserverImpact.USER_IMPACT_LEVEL_0,
                      PackageHealthObserverImpact.USER_IMPACT_LEVEL_10,
+                     PackageHealthObserverImpact.USER_IMPACT_LEVEL_20,
                      PackageHealthObserverImpact.USER_IMPACT_LEVEL_30,
                      PackageHealthObserverImpact.USER_IMPACT_LEVEL_50,
                      PackageHealthObserverImpact.USER_IMPACT_LEVEL_70,
+                     PackageHealthObserverImpact.USER_IMPACT_LEVEL_71,
+                     PackageHealthObserverImpact.USER_IMPACT_LEVEL_75,
+                     PackageHealthObserverImpact.USER_IMPACT_LEVEL_80,
                      PackageHealthObserverImpact.USER_IMPACT_LEVEL_90,
                      PackageHealthObserverImpact.USER_IMPACT_LEVEL_100})
     public @interface PackageHealthObserverImpact {
@@ -582,11 +671,15 @@
         /* Action has low user impact, user of a device will barely notice. */
         int USER_IMPACT_LEVEL_10 = 10;
         /* Actions having medium user impact, user of a device will likely notice. */
+        int USER_IMPACT_LEVEL_20 = 20;
         int USER_IMPACT_LEVEL_30 = 30;
         int USER_IMPACT_LEVEL_50 = 50;
         int USER_IMPACT_LEVEL_70 = 70;
-        int USER_IMPACT_LEVEL_90 = 90;
         /* Action has high user impact, a last resort, user of a device will be very frustrated. */
+        int USER_IMPACT_LEVEL_71 = 71;
+        int USER_IMPACT_LEVEL_75 = 75;
+        int USER_IMPACT_LEVEL_80 = 80;
+        int USER_IMPACT_LEVEL_90 = 90;
         int USER_IMPACT_LEVEL_100 = 100;
     }
 
@@ -1144,6 +1237,12 @@
         }
     }
 
+    @VisibleForTesting
+    @GuardedBy("mLock")
+    void registerObserverInternal(ObserverInternal observerInternal) {
+        mAllObservers.put(observerInternal.name, observerInternal);
+    }
+
     /**
      * Represents an observer monitoring a set of packages along with the failure thresholds for
      * each package.
@@ -1151,17 +1250,23 @@
      * <p> Note, the PackageWatchdog#mLock must always be held when reading or writing
      * instances of this class.
      */
-    private static class ObserverInternal {
+    static class ObserverInternal {
         public final String name;
         @GuardedBy("mLock")
         private final ArrayMap<String, MonitoredPackage> mPackages = new ArrayMap<>();
         @Nullable
         @GuardedBy("mLock")
         public PackageHealthObserver registeredObserver;
+        private int mMitigationCount;
 
         ObserverInternal(String name, List<MonitoredPackage> packages) {
+            this(name, packages, /*mitigationCount=*/ 0);
+        }
+
+        ObserverInternal(String name, List<MonitoredPackage> packages, int mitigationCount) {
             this.name = name;
             updatePackagesLocked(packages);
+            this.mMitigationCount = mitigationCount;
         }
 
         /**
@@ -1173,6 +1278,9 @@
             try {
                 out.startTag(null, TAG_OBSERVER);
                 out.attribute(null, ATTR_NAME, name);
+                if (Flags.recoverabilityDetection()) {
+                    out.attributeInt(null, ATTR_MITIGATION_COUNT, mMitigationCount);
+                }
                 for (int i = 0; i < mPackages.size(); i++) {
                     MonitoredPackage p = mPackages.valueAt(i);
                     p.writeLocked(out);
@@ -1185,6 +1293,14 @@
             }
         }
 
+        public int getBootMitigationCount() {
+            return mMitigationCount;
+        }
+
+        public void setBootMitigationCount(int mitigationCount) {
+            mMitigationCount = mitigationCount;
+        }
+
         @GuardedBy("mLock")
         public void updatePackagesLocked(List<MonitoredPackage> packages) {
             for (int pIndex = 0; pIndex < packages.size(); pIndex++) {
@@ -1289,6 +1405,7 @@
          **/
         public static ObserverInternal read(TypedXmlPullParser parser, PackageWatchdog watchdog) {
             String observerName = null;
+            int observerMitigationCount = 0;
             if (TAG_OBSERVER.equals(parser.getName())) {
                 observerName = parser.getAttributeValue(null, ATTR_NAME);
                 if (TextUtils.isEmpty(observerName)) {
@@ -1299,6 +1416,9 @@
             List<MonitoredPackage> packages = new ArrayList<>();
             int innerDepth = parser.getDepth();
             try {
+                if (Flags.recoverabilityDetection()) {
+                    observerMitigationCount = parser.getAttributeInt(null, ATTR_MITIGATION_COUNT);
+                }
                 while (XmlUtils.nextElementWithin(parser, innerDepth)) {
                     if (TAG_PACKAGE.equals(parser.getName())) {
                         try {
@@ -1319,7 +1439,7 @@
             if (packages.isEmpty()) {
                 return null;
             }
-            return new ObserverInternal(observerName, packages);
+            return new ObserverInternal(observerName, packages, observerMitigationCount);
         }
 
         /** Dumps information about this observer and the packages it watches. */
@@ -1679,6 +1799,27 @@
         }
     }
 
+    @GuardedBy("mLock")
+    @SuppressWarnings("GuardedBy")
+    void saveAllObserversBootMitigationCountToMetadata(String filePath) {
+        HashMap<String, Integer> bootMitigationCounts = new HashMap<>();
+        for (int i = 0; i < mAllObservers.size(); i++) {
+            final ObserverInternal observer = mAllObservers.valueAt(i);
+            bootMitigationCounts.put(observer.name, observer.getBootMitigationCount());
+        }
+
+        try {
+            FileOutputStream fileStream = new FileOutputStream(new File(filePath));
+            ObjectOutputStream objectStream = new ObjectOutputStream(fileStream);
+            objectStream.writeObject(bootMitigationCounts);
+            objectStream.flush();
+            objectStream.close();
+            fileStream.close();
+        } catch (Exception e) {
+            Slog.i(TAG, "Could not save observers metadata to file: " + e);
+        }
+    }
+
     /**
      * Handles the thresholding logic for system server boots.
      */
@@ -1686,10 +1827,16 @@
 
         private final int mBootTriggerCount;
         private final long mTriggerWindow;
+        private final int mBootMitigationIncrement;
 
         BootThreshold(int bootTriggerCount, long triggerWindow) {
+            this(bootTriggerCount, triggerWindow, /*bootMitigationIncrement=*/ 1);
+        }
+
+        BootThreshold(int bootTriggerCount, long triggerWindow, int bootMitigationIncrement) {
             this.mBootTriggerCount = bootTriggerCount;
             this.mTriggerWindow = triggerWindow;
+            this.mBootMitigationIncrement = bootMitigationIncrement;
         }
 
         public void reset() {
@@ -1761,8 +1908,13 @@
 
 
         /** Increments the boot counter, and returns whether the device is bootlooping. */
+        @GuardedBy("mLock")
         public boolean incrementAndTest() {
-            readMitigationCountFromMetadataIfNecessary();
+            if (Flags.recoverabilityDetection()) {
+                readAllObserversBootMitigationCountIfNecessary(METADATA_FILE);
+            } else {
+                readMitigationCountFromMetadataIfNecessary();
+            }
             final long now = mSystemClock.uptimeMillis();
             if (now - getStart() < 0) {
                 Slog.e(TAG, "Window was less than zero. Resetting start to current time.");
@@ -1770,8 +1922,12 @@
                 setMitigationStart(now);
             }
             if (now - getMitigationStart() > DEFAULT_DEESCALATION_WINDOW_MS) {
-                setMitigationCount(0);
                 setMitigationStart(now);
+                if (Flags.recoverabilityDetection()) {
+                    resetAllObserversBootMitigationCount();
+                } else {
+                    setMitigationCount(0);
+                }
             }
             final long window = now - getStart();
             if (window >= mTriggerWindow) {
@@ -1782,9 +1938,48 @@
                 int count = getCount() + 1;
                 setCount(count);
                 EventLogTags.writeRescueNote(Process.ROOT_UID, count, window);
+                if (Flags.recoverabilityDetection()) {
+                    boolean mitigate = (count >= mBootTriggerCount)
+                            && (count - mBootTriggerCount) % mBootMitigationIncrement == 0;
+                    return mitigate;
+                }
                 return count >= mBootTriggerCount;
             }
         }
 
+        @GuardedBy("mLock")
+        private void resetAllObserversBootMitigationCount() {
+            for (int i = 0; i < mAllObservers.size(); i++) {
+                final ObserverInternal observer = mAllObservers.valueAt(i);
+                observer.setBootMitigationCount(0);
+            }
+        }
+
+        @GuardedBy("mLock")
+        @SuppressWarnings("GuardedBy")
+        void readAllObserversBootMitigationCountIfNecessary(String filePath) {
+            File metadataFile = new File(filePath);
+            if (metadataFile.exists()) {
+                try {
+                    FileInputStream fileStream = new FileInputStream(metadataFile);
+                    ObjectInputStream objectStream = new ObjectInputStream(fileStream);
+                    HashMap<String, Integer> bootMitigationCounts =
+                            (HashMap<String, Integer>) objectStream.readObject();
+                    objectStream.close();
+                    fileStream.close();
+
+                    for (int i = 0; i < mAllObservers.size(); i++) {
+                        final ObserverInternal observer = mAllObservers.valueAt(i);
+                        if (bootMitigationCounts.containsKey(observer.name)) {
+                            observer.setBootMitigationCount(
+                                    bootMitigationCounts.get(observer.name));
+                        }
+                    }
+                } catch (Exception e) {
+                    Slog.i(TAG, "Could not read observer metadata file: " + e);
+                }
+            }
+        }
+
     }
 }
diff --git a/packages/CrashRecovery/services/java/com/android/server/RescueParty.java b/packages/CrashRecovery/services/java/com/android/server/RescueParty.java
index 7bdc1a0..7093ba4 100644
--- a/packages/CrashRecovery/services/java/com/android/server/RescueParty.java
+++ b/packages/CrashRecovery/services/java/com/android/server/RescueParty.java
@@ -20,6 +20,7 @@
 
 import static com.android.server.pm.PackageManagerServiceUtils.logCriticalInfo;
 
+import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.ContentResolver;
@@ -27,6 +28,7 @@
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.VersionedPackage;
+import android.crashrecovery.flags.Flags;
 import android.os.Build;
 import android.os.Environment;
 import android.os.PowerManager;
@@ -53,6 +55,8 @@
 import com.android.server.crashrecovery.proto.CrashRecoveryStatsLog;
 
 import java.io.File;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -89,6 +93,40 @@
     @VisibleForTesting
     static final int LEVEL_FACTORY_RESET = 5;
     @VisibleForTesting
+    static final int RESCUE_LEVEL_NONE = 0;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET = 1;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_ALL_DEVICE_CONFIG_RESET = 2;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_WARM_REBOOT = 3;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS = 4;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES = 5;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS = 6;
+    @VisibleForTesting
+    static final int RESCUE_LEVEL_FACTORY_RESET = 7;
+
+    @IntDef(prefix = { "RESCUE_LEVEL_" }, value = {
+        RESCUE_LEVEL_NONE,
+        RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET,
+        RESCUE_LEVEL_ALL_DEVICE_CONFIG_RESET,
+        RESCUE_LEVEL_WARM_REBOOT,
+        RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS,
+        RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES,
+        RESCUE_LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS,
+        RESCUE_LEVEL_FACTORY_RESET
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    @interface RescueLevels {}
+
+    @VisibleForTesting
+    static final String RESCUE_NON_REBOOT_LEVEL_LIMIT = "persist.sys.rescue_non_reboot_level_limit";
+    @VisibleForTesting
+    static final int DEFAULT_RESCUE_NON_REBOOT_LEVEL_LIMIT = RESCUE_LEVEL_WARM_REBOOT - 1;
+    @VisibleForTesting
     static final String TAG = "RescueParty";
     @VisibleForTesting
     static final long DEFAULT_OBSERVING_DURATION_MS = TimeUnit.DAYS.toMillis(2);
@@ -347,11 +385,20 @@
     }
 
     private static int getMaxRescueLevel(boolean mayPerformReboot) {
-        if (!mayPerformReboot
-                || SystemProperties.getBoolean(PROP_DISABLE_FACTORY_RESET_FLAG, false)) {
-            return LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS;
+        if (Flags.recoverabilityDetection()) {
+            if (!mayPerformReboot
+                    || SystemProperties.getBoolean(PROP_DISABLE_FACTORY_RESET_FLAG, false)) {
+                return SystemProperties.getInt(RESCUE_NON_REBOOT_LEVEL_LIMIT,
+                        DEFAULT_RESCUE_NON_REBOOT_LEVEL_LIMIT);
+            }
+            return RESCUE_LEVEL_FACTORY_RESET;
+        } else {
+            if (!mayPerformReboot
+                    || SystemProperties.getBoolean(PROP_DISABLE_FACTORY_RESET_FLAG, false)) {
+                return LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS;
+            }
+            return LEVEL_FACTORY_RESET;
         }
-        return LEVEL_FACTORY_RESET;
     }
 
     /**
@@ -379,6 +426,46 @@
         }
     }
 
+    /**
+     * Get the rescue level to perform if this is the n-th attempt at mitigating failure.
+     * When failedPackage is null then 1st and 2nd mitigation counts are redundant (scoped and
+     * all device config reset). Behaves as if one mitigation attempt was already done.
+     *
+     * @param mitigationCount the mitigation attempt number (1 = first attempt etc.).
+     * @param mayPerformReboot whether or not a reboot and factory reset may be performed
+     * for the given failure.
+     * @param failedPackage in case of bootloop this is null.
+     * @return the rescue level for the n-th mitigation attempt.
+     */
+    private static @RescueLevels int getRescueLevel(int mitigationCount, boolean mayPerformReboot,
+            @Nullable VersionedPackage failedPackage) {
+        // Skipping RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET since it's not defined without a failed
+        // package.
+        if (failedPackage == null && mitigationCount > 0) {
+            mitigationCount += 1;
+        }
+        if (mitigationCount == 1) {
+            return RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET;
+        } else if (mitigationCount == 2) {
+            return RESCUE_LEVEL_ALL_DEVICE_CONFIG_RESET;
+        } else if (mitigationCount == 3) {
+            return Math.min(getMaxRescueLevel(mayPerformReboot), RESCUE_LEVEL_WARM_REBOOT);
+        } else if (mitigationCount == 4) {
+            return Math.min(getMaxRescueLevel(mayPerformReboot),
+                                RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS);
+        } else if (mitigationCount == 5) {
+            return Math.min(getMaxRescueLevel(mayPerformReboot),
+                                RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES);
+        } else if (mitigationCount == 6) {
+            return Math.min(getMaxRescueLevel(mayPerformReboot),
+                                RESCUE_LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS);
+        } else if (mitigationCount >= 7) {
+            return Math.min(getMaxRescueLevel(mayPerformReboot), RESCUE_LEVEL_FACTORY_RESET);
+        } else {
+            return RESCUE_LEVEL_NONE;
+        }
+    }
+
     private static void executeRescueLevel(Context context, @Nullable String failedPackage,
             int level) {
         Slog.w(TAG, "Attempting rescue level " + levelToString(level));
@@ -397,6 +484,15 @@
 
     private static void executeRescueLevelInternal(Context context, int level, @Nullable
             String failedPackage) throws Exception {
+        if (Flags.recoverabilityDetection()) {
+            executeRescueLevelInternalNew(context, level, failedPackage);
+        } else {
+            executeRescueLevelInternalOld(context, level, failedPackage);
+        }
+    }
+
+    private static void executeRescueLevelInternalOld(Context context, int level, @Nullable
+            String failedPackage) throws Exception {
 
         if (level <= LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS) {
             // Disabling flag resets on master branch for trunk stable launch.
@@ -410,8 +506,6 @@
         // Try our best to reset all settings possible, and once finished
         // rethrow any exception that we encountered
         Exception res = null;
-        Runnable runnable;
-        Thread thread;
         switch (level) {
             case LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
                 try {
@@ -453,21 +547,7 @@
                 }
                 break;
             case LEVEL_WARM_REBOOT:
-                // Request the reboot from a separate thread to avoid deadlock on PackageWatchdog
-                // when device shutting down.
-                setRebootProperty(true);
-                runnable = () -> {
-                    try {
-                        PowerManager pm = context.getSystemService(PowerManager.class);
-                        if (pm != null) {
-                            pm.reboot(TAG);
-                        }
-                    } catch (Throwable t) {
-                        logRescueException(level, failedPackage, t);
-                    }
-                };
-                thread = new Thread(runnable);
-                thread.start();
+                executeWarmReboot(context, level, failedPackage);
                 break;
             case LEVEL_FACTORY_RESET:
                 // Before the completion of Reboot, if any crash happens then PackageWatchdog
@@ -475,23 +555,9 @@
                 // Adding a check to prevent factory reset to execute before above reboot completes.
                 // Note: this reboot property is not persistent resets after reboot is completed.
                 if (isRebootPropertySet()) {
-                    break;
+                    return;
                 }
-                setFactoryResetProperty(true);
-                long now = System.currentTimeMillis();
-                setLastFactoryResetTimeMs(now);
-                runnable = new Runnable() {
-                    @Override
-                    public void run() {
-                        try {
-                            RecoverySystem.rebootPromptAndWipeUserData(context, TAG);
-                        } catch (Throwable t) {
-                            logRescueException(level, failedPackage, t);
-                        }
-                    }
-                };
-                thread = new Thread(runnable);
-                thread.start();
+                executeFactoryReset(context, level, failedPackage);
                 break;
         }
 
@@ -500,6 +566,83 @@
         }
     }
 
+    private static void executeRescueLevelInternalNew(Context context, @RescueLevels int level,
+            @Nullable String failedPackage) throws Exception {
+        CrashRecoveryStatsLog.write(CrashRecoveryStatsLog.RESCUE_PARTY_RESET_REPORTED,
+                level, levelToString(level));
+        switch (level) {
+            case RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET:
+                // Temporary disable deviceConfig reset
+                // resetDeviceConfig(context, /*isScoped=*/true, failedPackage);
+                break;
+            case RESCUE_LEVEL_ALL_DEVICE_CONFIG_RESET:
+                // Temporary disable deviceConfig reset
+                // resetDeviceConfig(context, /*isScoped=*/false, failedPackage);
+                break;
+            case RESCUE_LEVEL_WARM_REBOOT:
+                executeWarmReboot(context, level, failedPackage);
+                break;
+            case RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
+                resetAllSettingsIfNecessary(context, Settings.RESET_MODE_UNTRUSTED_DEFAULTS, level);
+                break;
+            case RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:
+                resetAllSettingsIfNecessary(context, Settings.RESET_MODE_UNTRUSTED_CHANGES, level);
+                break;
+            case RESCUE_LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:
+                resetAllSettingsIfNecessary(context, Settings.RESET_MODE_TRUSTED_DEFAULTS, level);
+                break;
+            case RESCUE_LEVEL_FACTORY_RESET:
+                // Before the completion of Reboot, if any crash happens then PackageWatchdog
+                // escalates to next level i.e. factory reset, as they happen in separate threads.
+                // Adding a check to prevent factory reset to execute before above reboot completes.
+                // Note: this reboot property is not persistent resets after reboot is completed.
+                if (isRebootPropertySet()) {
+                    return;
+                }
+                executeFactoryReset(context, level, failedPackage);
+                break;
+        }
+    }
+
+    private static void executeWarmReboot(Context context, int level,
+            @Nullable String failedPackage) {
+        // Request the reboot from a separate thread to avoid deadlock on PackageWatchdog
+        // when device shutting down.
+        setRebootProperty(true);
+        Runnable runnable = () -> {
+            try {
+                PowerManager pm = context.getSystemService(PowerManager.class);
+                if (pm != null) {
+                    pm.reboot(TAG);
+                }
+            } catch (Throwable t) {
+                logRescueException(level, failedPackage, t);
+            }
+        };
+        Thread thread = new Thread(runnable);
+        thread.start();
+    }
+
+    private static void executeFactoryReset(Context context, int level,
+            @Nullable String failedPackage) {
+        setFactoryResetProperty(true);
+        long now = System.currentTimeMillis();
+        setLastFactoryResetTimeMs(now);
+        Runnable runnable = new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    RecoverySystem.rebootPromptAndWipeUserData(context, TAG);
+                } catch (Throwable t) {
+                    logRescueException(level, failedPackage, t);
+                }
+            }
+        };
+        Thread thread = new Thread(runnable);
+        thread.start();
+    }
+
+
     private static String getCompleteMessage(Throwable t) {
         final StringBuilder builder = new StringBuilder();
         builder.append(t.getMessage());
@@ -521,17 +664,38 @@
     }
 
     private static int mapRescueLevelToUserImpact(int rescueLevel) {
-        switch(rescueLevel) {
-            case LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
-            case LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:
-                return PackageHealthObserverImpact.USER_IMPACT_LEVEL_10;
-            case LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:
-            case LEVEL_WARM_REBOOT:
-                return PackageHealthObserverImpact.USER_IMPACT_LEVEL_50;
-            case LEVEL_FACTORY_RESET:
-                return PackageHealthObserverImpact.USER_IMPACT_LEVEL_100;
-            default:
-                return PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
+        if (Flags.recoverabilityDetection()) {
+            switch (rescueLevel) {
+                case RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_10;
+                case RESCUE_LEVEL_ALL_DEVICE_CONFIG_RESET:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_20;
+                case RESCUE_LEVEL_WARM_REBOOT:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_50;
+                case RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_71;
+                case RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_75;
+                case RESCUE_LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_80;
+                case RESCUE_LEVEL_FACTORY_RESET:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_100;
+                default:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
+            }
+        } else {
+            switch (rescueLevel) {
+                case LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
+                case LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_10;
+                case LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:
+                case LEVEL_WARM_REBOOT:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_50;
+                case LEVEL_FACTORY_RESET:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_100;
+                default:
+                    return PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
+            }
         }
     }
 
@@ -548,7 +712,7 @@
         final ContentResolver resolver = context.getContentResolver();
         try {
             Settings.Global.resetToDefaultsAsUser(resolver, null, mode,
-                UserHandle.SYSTEM.getIdentifier());
+                    UserHandle.SYSTEM.getIdentifier());
         } catch (Exception e) {
             res = new RuntimeException("Failed to reset global settings", e);
         }
@@ -667,8 +831,13 @@
                 @FailureReasons int failureReason, int mitigationCount) {
             if (!isDisabled() && (failureReason == PackageWatchdog.FAILURE_REASON_APP_CRASH
                     || failureReason == PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING)) {
-                return mapRescueLevelToUserImpact(getRescueLevel(mitigationCount,
+                if (Flags.recoverabilityDetection()) {
+                    return mapRescueLevelToUserImpact(getRescueLevel(mitigationCount,
+                            mayPerformReboot(failedPackage), failedPackage));
+                } else {
+                    return mapRescueLevelToUserImpact(getRescueLevel(mitigationCount,
                         mayPerformReboot(failedPackage)));
+                }
             } else {
                 return PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
             }
@@ -682,8 +851,10 @@
             }
             if (failureReason == PackageWatchdog.FAILURE_REASON_APP_CRASH
                     || failureReason == PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING) {
-                final int level = getRescueLevel(mitigationCount,
-                        mayPerformReboot(failedPackage));
+                final int level = Flags.recoverabilityDetection() ? getRescueLevel(mitigationCount,
+                        mayPerformReboot(failedPackage), failedPackage)
+                        : getRescueLevel(mitigationCount,
+                                mayPerformReboot(failedPackage));
                 executeRescueLevel(mContext,
                         failedPackage == null ? null : failedPackage.getPackageName(), level);
                 return true;
@@ -716,7 +887,12 @@
             if (isDisabled()) {
                 return PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
             }
-            return mapRescueLevelToUserImpact(getRescueLevel(mitigationCount, true));
+            if (Flags.recoverabilityDetection()) {
+                return mapRescueLevelToUserImpact(getRescueLevel(mitigationCount,
+                        true, /*failedPackage=*/ null));
+            } else {
+                return mapRescueLevelToUserImpact(getRescueLevel(mitigationCount, true));
+            }
         }
 
         @Override
@@ -725,8 +901,10 @@
                 return false;
             }
             boolean mayPerformReboot = !shouldThrottleReboot();
-            executeRescueLevel(mContext, /*failedPackage=*/ null,
-                    getRescueLevel(mitigationCount, mayPerformReboot));
+            final int level = Flags.recoverabilityDetection() ? getRescueLevel(mitigationCount,
+                        mayPerformReboot, /*failedPackage=*/ null)
+                        : getRescueLevel(mitigationCount, mayPerformReboot);
+            executeRescueLevel(mContext, /*failedPackage=*/ null, level);
             return true;
         }
 
@@ -843,14 +1021,44 @@
     }
 
     private static String levelToString(int level) {
-        switch (level) {
-            case LEVEL_NONE: return "NONE";
-            case LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS: return "RESET_SETTINGS_UNTRUSTED_DEFAULTS";
-            case LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES: return "RESET_SETTINGS_UNTRUSTED_CHANGES";
-            case LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS: return "RESET_SETTINGS_TRUSTED_DEFAULTS";
-            case LEVEL_WARM_REBOOT: return "WARM_REBOOT";
-            case LEVEL_FACTORY_RESET: return "FACTORY_RESET";
-            default: return Integer.toString(level);
+        if (Flags.recoverabilityDetection()) {
+            switch (level) {
+                case RESCUE_LEVEL_NONE:
+                    return "NONE";
+                case RESCUE_LEVEL_SCOPED_DEVICE_CONFIG_RESET:
+                    return "SCOPED_DEVICE_CONFIG_RESET";
+                case RESCUE_LEVEL_ALL_DEVICE_CONFIG_RESET:
+                    return "ALL_DEVICE_CONFIG_RESET";
+                case RESCUE_LEVEL_WARM_REBOOT:
+                    return "WARM_REBOOT";
+                case RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
+                    return "RESET_SETTINGS_UNTRUSTED_DEFAULTS";
+                case RESCUE_LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:
+                    return "RESET_SETTINGS_UNTRUSTED_CHANGES";
+                case RESCUE_LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:
+                    return "RESET_SETTINGS_TRUSTED_DEFAULTS";
+                case RESCUE_LEVEL_FACTORY_RESET:
+                    return "FACTORY_RESET";
+                default:
+                    return Integer.toString(level);
+            }
+        } else {
+            switch (level) {
+                case LEVEL_NONE:
+                    return "NONE";
+                case LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:
+                    return "RESET_SETTINGS_UNTRUSTED_DEFAULTS";
+                case LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:
+                    return "RESET_SETTINGS_UNTRUSTED_CHANGES";
+                case LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:
+                    return "RESET_SETTINGS_TRUSTED_DEFAULTS";
+                case LEVEL_WARM_REBOOT:
+                    return "WARM_REBOOT";
+                case LEVEL_FACTORY_RESET:
+                    return "FACTORY_RESET";
+                default:
+                    return Integer.toString(level);
+            }
         }
     }
 }
diff --git a/packages/CrashRecovery/services/java/com/android/server/rollback/RollbackPackageHealthObserver.java b/packages/CrashRecovery/services/java/com/android/server/rollback/RollbackPackageHealthObserver.java
index 0fb9327..93f26ae 100644
--- a/packages/CrashRecovery/services/java/com/android/server/rollback/RollbackPackageHealthObserver.java
+++ b/packages/CrashRecovery/services/java/com/android/server/rollback/RollbackPackageHealthObserver.java
@@ -69,7 +69,7 @@
  *
  * @hide
  */
-final class RollbackPackageHealthObserver implements PackageHealthObserver {
+public final class RollbackPackageHealthObserver implements PackageHealthObserver {
     private static final String TAG = "RollbackPackageHealthObserver";
     private static final String NAME = "rollback-observer";
     private static final int PERSISTENT_MASK = ApplicationInfo.FLAG_PERSISTENT
@@ -89,7 +89,7 @@
     private boolean mTwoPhaseRollbackEnabled;
 
     @VisibleForTesting
-    RollbackPackageHealthObserver(Context context, ApexManager apexManager) {
+    public RollbackPackageHealthObserver(Context context, ApexManager apexManager) {
         mContext = context;
         HandlerThread handlerThread = new HandlerThread("RollbackPackageHealthObserver");
         handlerThread.start();
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/ui/RemoteViewsFactory.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/RemoteViewsFactory.kt
index a46e358..3fb91522 100644
--- a/packages/CredentialManager/src/com/android/credentialmanager/common/ui/RemoteViewsFactory.kt
+++ b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/RemoteViewsFactory.kt
@@ -17,7 +17,6 @@
 package com.android.credentialmanager.common.ui
 
 import android.content.Context
-import android.content.res.Configuration
 import android.widget.RemoteViews
 import androidx.core.content.ContextCompat
 import com.android.credentialmanager.model.get.CredentialEntryInfo
@@ -27,10 +26,12 @@
 class RemoteViewsFactory {
 
     companion object {
-        private const val setAdjustViewBoundsMethodName = "setAdjustViewBounds"
-        private const val setMaxHeightMethodName = "setMaxHeight"
-        private const val setBackgroundResourceMethodName = "setBackgroundResource"
-        private const val bulletPoint = "\u2022"
+        private const val SET_ADJUST_VIEW_BOUNDS_METHOD_NAME = "setAdjustViewBounds"
+        private const val SET_MAX_HEIGHT_METHOD_NAME = "setMaxHeight"
+        private const val SET_BACKGROUND_RESOURCE_METHOD_NAME = "setBackgroundResource"
+        private const val BULLET_POINT = "\u2022"
+        // TODO(jbabs): RemoteViews#setViewPadding renders this as 8dp on the display. Debug why.
+        private const val END_ITEMS_PADDING = 28
 
         fun createDropdownPresentation(
             context: Context,
@@ -50,18 +51,18 @@
             val secondaryText =
                 if (credentialEntryInfo.displayName != null
                     && (credentialEntryInfo.displayName != credentialEntryInfo.userName))
-                    (credentialEntryInfo.userName + " " + bulletPoint + " "
+                    (credentialEntryInfo.userName + " " + BULLET_POINT + " "
                             + credentialEntryInfo.credentialTypeDisplayName
-                            + " " + bulletPoint + " " + credentialEntryInfo.providerDisplayName)
-                else (credentialEntryInfo.credentialTypeDisplayName + " " + bulletPoint + " "
+                            + " " + BULLET_POINT + " " + credentialEntryInfo.providerDisplayName)
+                else (credentialEntryInfo.credentialTypeDisplayName + " " + BULLET_POINT + " "
                         + credentialEntryInfo.providerDisplayName)
             remoteViews.setTextViewText(android.R.id.text2, secondaryText)
             remoteViews.setImageViewIcon(android.R.id.icon1, icon);
             remoteViews.setBoolean(
-                android.R.id.icon1, setAdjustViewBoundsMethodName, true);
+                android.R.id.icon1, SET_ADJUST_VIEW_BOUNDS_METHOD_NAME, true);
             remoteViews.setInt(
                 android.R.id.icon1,
-                setMaxHeightMethodName,
+                SET_MAX_HEIGHT_METHOD_NAME,
                 context.resources.getDimensionPixelSize(
                     com.android.credentialmanager.R.dimen.autofill_icon_size));
             remoteViews.setContentDescription(android.R.id.icon1, credentialEntryInfo
@@ -71,11 +72,11 @@
                     com.android.credentialmanager.R.drawable.fill_dialog_dynamic_list_item_one else
                     com.android.credentialmanager.R.drawable.fill_dialog_dynamic_list_item_middle
             remoteViews.setInt(
-                android.R.id.content, setBackgroundResourceMethodName, drawableId);
+                android.R.id.content, SET_BACKGROUND_RESOURCE_METHOD_NAME, drawableId);
             if (isFirstEntry) remoteViews.setViewPadding(
                 com.android.credentialmanager.R.id.credential_card,
                 /* left=*/0,
-                /* top=*/8,
+                /* top=*/END_ITEMS_PADDING,
                 /* right=*/0,
                 /* bottom=*/0)
             if (isLastEntry) remoteViews.setViewPadding(
@@ -83,7 +84,7 @@
                 /*left=*/0,
                 /* top=*/0,
                 /* right=*/0,
-                /* bottom=*/8)
+                /* bottom=*/END_ITEMS_PADDING)
             return remoteViews
         }
 
@@ -95,16 +96,16 @@
                 com.android.credentialmanager
                         .R.string.dropdown_presentation_more_sign_in_options_text))
             remoteViews.setBoolean(
-                android.R.id.icon1, setAdjustViewBoundsMethodName, true);
+                android.R.id.icon1, SET_ADJUST_VIEW_BOUNDS_METHOD_NAME, true);
             remoteViews.setInt(
                 android.R.id.icon1,
-                setMaxHeightMethodName,
+                SET_MAX_HEIGHT_METHOD_NAME,
                 context.resources.getDimensionPixelSize(
                     com.android.credentialmanager.R.dimen.autofill_icon_size));
             val drawableId =
                 com.android.credentialmanager.R.drawable.more_options_list_item
             remoteViews.setInt(
-                android.R.id.content, setBackgroundResourceMethodName, drawableId);
+                android.R.id.content, SET_BACKGROUND_RESOURCE_METHOD_NAME, drawableId);
             return remoteViews
         }
     }
diff --git a/packages/PackageInstaller/src/com/android/packageinstaller/v2/model/InstallRepository.kt b/packages/PackageInstaller/src/com/android/packageinstaller/v2/model/InstallRepository.kt
index 32795e4..e48c0f4 100644
--- a/packages/PackageInstaller/src/com/android/packageinstaller/v2/model/InstallRepository.kt
+++ b/packages/PackageInstaller/src/com/android/packageinstaller/v2/model/InstallRepository.kt
@@ -96,6 +96,7 @@
     var stagedSessionId = SessionInfo.INVALID_ID
         private set
     private var callingUid = Process.INVALID_UID
+    private var originatingUid = Process.INVALID_UID
     private var callingPackage: String? = null
     private var sessionStager: SessionStager? = null
     private lateinit var intent: Intent
@@ -148,7 +149,7 @@
         }
         val sourceInfo: ApplicationInfo? = getSourceInfo(callingPackage)
         // Uid of the source package, with a preference to uid from ApplicationInfo
-        val originatingUid = sourceInfo?.uid ?: callingUid
+        originatingUid = sourceInfo?.uid ?: callingUid
         appOpRequestInfo = AppOpRequestInfo(
             getPackageNameForUid(context, originatingUid, callingPackage),
             originatingUid, callingAttributionTag
@@ -282,7 +283,7 @@
                     context.contentResolver.openAssetFileDescriptor(uri, "r").use { afd ->
                         val pfd: ParcelFileDescriptor? = afd?.parcelFileDescriptor
                         val params: SessionParams =
-                            createSessionParams(intent, pfd, uri.toString())
+                            createSessionParams(originatingUid, intent, pfd, uri.toString())
                         stagedSessionId = packageInstaller.createSession(params)
                     }
                 } catch (e: Exception) {
@@ -338,6 +339,7 @@
     }
 
     private fun createSessionParams(
+        originatingUid: Int,
         intent: Intent,
         pfd: ParcelFileDescriptor?,
         debugPathName: String,
@@ -354,9 +356,7 @@
         params.setOriginatingUri(
             intent.getParcelableExtra(Intent.EXTRA_ORIGINATING_URI, Uri::class.java)
         )
-        params.setOriginatingUid(
-            intent.getIntExtra(Intent.EXTRA_ORIGINATING_UID, Process.INVALID_UID)
-        )
+        params.setOriginatingUid(originatingUid)
         params.setInstallerPackageName(intent.getStringExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME))
         params.setInstallReason(PackageManager.INSTALL_REASON_USER)
         // Disable full screen intent usage by for sideloads.
diff --git a/packages/SettingsLib/ProfileSelector/Android.bp b/packages/SettingsLib/ProfileSelector/Android.bp
index 6dc07b2..4aa67c1 100644
--- a/packages/SettingsLib/ProfileSelector/Android.bp
+++ b/packages/SettingsLib/ProfileSelector/Android.bp
@@ -20,6 +20,7 @@
     static_libs: [
         "com.google.android.material_material",
         "SettingsLibSettingsTheme",
+        "android.os.flags-aconfig-java-export",
     ],
 
     sdk_version: "system_current",
diff --git a/packages/SettingsLib/ProfileSelector/AndroidManifest.xml b/packages/SettingsLib/ProfileSelector/AndroidManifest.xml
index 80f6b76..303e20c 100644
--- a/packages/SettingsLib/ProfileSelector/AndroidManifest.xml
+++ b/packages/SettingsLib/ProfileSelector/AndroidManifest.xml
@@ -18,5 +18,5 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
           package="com.android.settingslib.widget.profileselector">
 
-    <uses-sdk android:minSdkVersion="23" />
+    <uses-sdk android:minSdkVersion="29" />
 </manifest>
diff --git a/packages/SettingsLib/ProfileSelector/res/values/strings.xml b/packages/SettingsLib/ProfileSelector/res/values/strings.xml
index 68d4047..76ccb65 100644
--- a/packages/SettingsLib/ProfileSelector/res/values/strings.xml
+++ b/packages/SettingsLib/ProfileSelector/res/values/strings.xml
@@ -21,4 +21,6 @@
     <string name="settingslib_category_personal">Personal</string>
     <!-- Header for items under the work user [CHAR LIMIT=30] -->
     <string name="settingslib_category_work">Work</string>
+    <!-- Header for items under the private profile user [CHAR LIMIT=30] -->
+    <string name="settingslib_category_private">Private</string>
 </resources>
\ No newline at end of file
diff --git a/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileSelectFragment.java b/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileSelectFragment.java
index be5753be..c52386b 100644
--- a/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileSelectFragment.java
+++ b/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileSelectFragment.java
@@ -16,31 +16,77 @@
 
 package com.android.settingslib.widget;
 
+import android.annotation.TargetApi;
 import android.app.Activity;
+import android.content.Context;
+import android.content.pm.UserProperties;
+import android.os.Build;
 import android.os.Bundle;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.util.ArrayMap;
+import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 
+import androidx.core.os.BuildCompat;
 import androidx.fragment.app.Fragment;
 import androidx.viewpager2.widget.ViewPager2;
 
+import com.android.settingslib.widget.profileselector.R;
+
 import com.google.android.material.tabs.TabLayout;
 import com.google.android.material.tabs.TabLayoutMediator;
-import com.android.settingslib.widget.profileselector.R;
+
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * Base fragment class for profile settings.
  */
 public abstract class ProfileSelectFragment extends Fragment {
+    private static final String TAG = "ProfileSelectFragment";
+    // UserHandle#USER_NULL is a @TestApi so is not accessible.
+    private static final int USER_NULL = -10000;
+    private static final int DEFAULT_POSITION = 0;
 
     /**
-     * Personal or Work profile tab of {@link ProfileSelectFragment}
-     * <p>0: Personal tab.
-     * <p>1: Work profile tab.
+     * The type of profile tab of {@link ProfileSelectFragment} to show
+     * <ul>
+     *   <li>0: Personal tab.
+     *   <li>1: Work profile tab.
+     * </ul>
+     *
+     * <p> Please note that this is supported for legacy reasons. Please use
+     * {@link #EXTRA_SHOW_FRAGMENT_USER_ID} instead.
      */
-    public static final String EXTRA_SHOW_FRAGMENT_TAB =
-            ":settings:show_fragment_tab";
+    public static final String EXTRA_SHOW_FRAGMENT_TAB = ":settings:show_fragment_tab";
+
+    /**
+     * An {@link ArrayList} of users to show. The supported users are: System user, the managed
+     * profile user, and the private profile user. A client should pass all the user ids that need
+     * to be shown in this list. Note that if this list is not provided then, for legacy reasons
+     * see {@link #EXTRA_SHOW_FRAGMENT_TAB}, an attempt will be made to show two tabs: one for the
+     * System user and one for the managed profile user.
+     *
+     * <p>Please note that this MUST be used in conjunction with
+     * {@link #EXTRA_SHOW_FRAGMENT_USER_ID}
+     */
+    public static final String EXTRA_LIST_OF_USER_IDS = ":settings:list_user_ids";
+
+    /**
+     * The user id of the user to be show in {@link ProfileSelectFragment}. Only the below user
+     * types are supported:
+     * <ul>
+     *   <li> System user.
+     *   <li> Managed profile user.
+     *   <li> Private profile user.
+     * </ul>
+     *
+     * <p>Please note that this MUST be used in conjunction with {@link #EXTRA_LIST_OF_USER_IDS}.
+     */
+    public static final String EXTRA_SHOW_FRAGMENT_USER_ID = ":settings:show_fragment_user_id";
 
     /**
      * Used in fragment argument with Extra key EXTRA_SHOW_FRAGMENT_TAB
@@ -48,13 +94,23 @@
     public static final int PERSONAL_TAB = 0;
 
     /**
-     * Used in fragment argument with Extra key EXTRA_SHOW_FRAGMENT_TAB
+     * Used in fragment argument with Extra key EXTRA_SHOW_FRAGMENT_TAB for the managed profile
      */
     public static final int WORK_TAB = 1;
 
+    /**
+     * Please note that private profile is available from API LEVEL
+     * {@link Build.VERSION_CODES.VANILLA_ICE_CREAM} only, therefore PRIVATE_TAB MUST be
+     * passed in {@link #EXTRA_SHOW_FRAGMENT_TAB} and {@link #EXTRA_LIST_OF_PROFILE_TABS} for
+     * {@link Build.VERSION_CODES.VANILLA_ICE_CREAM} or higher API Levels only.
+     */
+    private static final int PRIVATE_TAB = 2;
+
     private ViewGroup mContentView;
 
     private ViewPager2 mViewPager;
+    private final ArrayMap<UserHandle, Integer> mProfileTabsByUsers = new ArrayMap<>();
+    private boolean mUsingUserIds = false;
 
     @Override
     public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -67,7 +123,7 @@
         if (titleResId > 0) {
             activity.setTitle(titleResId);
         }
-        final int selectedTab = getTabId(activity, getArguments());
+        initProfileTabsToShow();
 
         final View tabContainer = mContentView.findViewById(R.id.tab_container);
         mViewPager = tabContainer.findViewById(R.id.view_pager);
@@ -78,16 +134,14 @@
         ).attach();
 
         tabContainer.setVisibility(View.VISIBLE);
-        final TabLayout.Tab tab = tabs.getTabAt(selectedTab);
+        final TabLayout.Tab tab = tabs.getTabAt(getSelectedTabPosition(activity, getArguments()));
         tab.select();
 
         return mContentView;
     }
 
     /**
-     * create Personal or Work profile fragment
-     * <p>0: Personal profile.
-     * <p>1: Work profile.
+     * Create Personal or Work or Private profile fragment. See {@link #EXTRA_SHOW_FRAGMENT_USER_ID}
      */
     public abstract Fragment createFragment(int position);
 
@@ -99,21 +153,90 @@
         return 0;
     }
 
-    int getTabId(Activity activity, Bundle bundle) {
+    int getSelectedTabPosition(Activity activity, Bundle bundle) {
         if (bundle != null) {
+            final int extraUserId = bundle.getInt(EXTRA_SHOW_FRAGMENT_USER_ID, USER_NULL);
+            if (extraUserId != USER_NULL) {
+                return mProfileTabsByUsers.indexOfKey(UserHandle.of(extraUserId));
+            }
             final int extraTab = bundle.getInt(EXTRA_SHOW_FRAGMENT_TAB, -1);
             if (extraTab != -1) {
                 return extraTab;
             }
         }
-        return PERSONAL_TAB;
+        return DEFAULT_POSITION;
+    }
+
+    int getTabCount() {
+        return mUsingUserIds ? mProfileTabsByUsers.size() : 2;
+    }
+
+    void initProfileTabsToShow() {
+        Bundle bundle = getArguments();
+        if (bundle != null) {
+            ArrayList<Integer> userIdsToShow =
+                    bundle.getIntegerArrayList(EXTRA_LIST_OF_USER_IDS);
+            if (userIdsToShow != null && !userIdsToShow.isEmpty()) {
+                mUsingUserIds = true;
+                UserManager userManager = getContext().getSystemService(UserManager.class);
+                List<UserHandle> userHandles = userManager.getUserProfiles();
+                for (UserHandle userHandle : userHandles) {
+                    if (!userIdsToShow.contains(userHandle.getIdentifier())) {
+                        continue;
+                    }
+                    if (userHandle.isSystem()) {
+                        mProfileTabsByUsers.put(userHandle, PERSONAL_TAB);
+                    } else if (userManager.isManagedProfile(userHandle.getIdentifier())) {
+                        mProfileTabsByUsers.put(userHandle, WORK_TAB);
+                    } else if (shouldShowPrivateProfileIfItsOne(userHandle)) {
+                        mProfileTabsByUsers.put(userHandle, PRIVATE_TAB);
+                    }
+                }
+            }
+        }
+    }
+
+    private int getProfileTabForPosition(int position) {
+        return mUsingUserIds ? mProfileTabsByUsers.valueAt(position) : position;
+    }
+
+    int getUserIdForPosition(int position) {
+        return mUsingUserIds ? mProfileTabsByUsers.keyAt(position).getIdentifier() : position;
     }
 
     private CharSequence getPageTitle(int position) {
-        if (position == WORK_TAB) {
+        int tab = getProfileTabForPosition(position);
+        if (tab == WORK_TAB) {
             return getContext().getString(R.string.settingslib_category_work);
+        } else if (tab == PRIVATE_TAB) {
+            return getContext().getString(R.string.settingslib_category_private);
         }
 
         return getString(R.string.settingslib_category_personal);
     }
+
+    @TargetApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    private boolean shouldShowUserInQuietMode(UserHandle userHandle, UserManager userManager) {
+        UserProperties userProperties = userManager.getUserProperties(userHandle);
+        return !userManager.isQuietModeEnabled(userHandle)
+                || userProperties.getShowInQuietMode() != UserProperties.SHOW_IN_QUIET_MODE_HIDDEN;
+    }
+
+    // It's sufficient to have this method marked with the appropriate API level because we expect
+    // to be here only for this API level - when then private profile was introduced.
+    @TargetApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    private boolean shouldShowPrivateProfileIfItsOne(UserHandle userHandle) {
+        if (!BuildCompat.isAtLeastV() || !android.os.Flags.allowPrivateProfile()) {
+            return false;
+        }
+        try {
+            Context userContext = getContext().createContextAsUser(userHandle, /* flags= */ 0);
+            UserManager userManager = userContext.getSystemService(UserManager.class);
+            return userManager.isPrivateProfile()
+                    && shouldShowUserInQuietMode(userHandle, userManager);
+        } catch (IllegalStateException exception) {
+            Log.i(TAG, "Ignoring this user as the calling package not available in this user.");
+        }
+        return false;
+    }
 }
diff --git a/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileViewPagerAdapter.java b/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileViewPagerAdapter.java
index f5ab647..37f4f27 100644
--- a/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileViewPagerAdapter.java
+++ b/packages/SettingsLib/ProfileSelector/src/com/android/settingslib/widget/ProfileViewPagerAdapter.java
@@ -18,7 +18,6 @@
 
 import androidx.fragment.app.Fragment;
 import androidx.viewpager2.adapter.FragmentStateAdapter;
-import com.android.settingslib.widget.profileselector.R;
 
 /**
  * ViewPager Adapter to handle between TabLayout and ViewPager2
@@ -34,11 +33,11 @@
 
     @Override
     public Fragment createFragment(int position) {
-        return mParentFragments.createFragment(position);
+        return mParentFragments.createFragment(mParentFragments.getUserIdForPosition(position));
     }
 
     @Override
     public int getItemCount() {
-        return 2;
+        return mParentFragments.getTabCount();
     }
 }
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioRepository.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioRepository.kt
index 6730aad..e7fec69 100644
--- a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioRepository.kt
@@ -19,7 +19,6 @@
 import android.media.AudioDeviceInfo
 import android.media.AudioManager
 import android.media.AudioManager.OnCommunicationDeviceChangedListener
-import androidx.concurrent.futures.DirectExecutor
 import com.android.internal.util.ConcurrentUtils
 import com.android.settingslib.volume.shared.AudioManagerEventsReceiver
 import com.android.settingslib.volume.shared.model.AudioManagerEvent
@@ -109,8 +108,8 @@
             callbackFlow {
                     val listener = OnCommunicationDeviceChangedListener { trySend(Unit) }
                     audioManager.addOnCommunicationDeviceChangedListener(
-                        DirectExecutor.INSTANCE,
-                        listener
+                        ConcurrentUtils.DIRECT_EXECUTOR,
+                        listener,
                     )
 
                     awaitClose { audioManager.removeOnCommunicationDeviceChangedListener(listener) }
@@ -146,7 +145,7 @@
             maxVolume = audioManager.getStreamMaxVolume(audioStream.value),
             volume = audioManager.getStreamVolume(audioStream.value),
             isAffectedByRingerMode = audioManager.isStreamAffectedByRingerMode(audioStream.value),
-            isMuted = audioManager.isStreamMute(audioStream.value),
+            isMuted = audioManager.isStreamMute(audioStream.value)
         )
     }
 
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/AudioVolumeInteractor.kt b/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/AudioVolumeInteractor.kt
index c9ac97d..778653b 100644
--- a/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/AudioVolumeInteractor.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/domain/interactor/AudioVolumeInteractor.kt
@@ -66,6 +66,10 @@
         }
     }
 
+    fun isMutable(audioStream: AudioStream): Boolean =
+        // Alarm stream doesn't support muting
+        audioStream.value != AudioManager.STREAM_ALARM
+
     private suspend fun processVolume(
         audioStreamModel: AudioStreamModel,
         ringerMode: RingerMode,
diff --git a/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java b/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java
index eaec617..5629a7b 100644
--- a/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java
+++ b/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java
@@ -256,8 +256,7 @@
         Settings.Secure.HEARING_AID_MEDIA_ROUTING,
         Settings.Secure.HEARING_AID_NOTIFICATION_ROUTING,
         Settings.Secure.ACCESSIBILITY_FONT_SCALING_HAS_BEEN_CHANGED,
-        Settings.Secure.SEARCH_PRESS_HOLD_NAV_HANDLE_ENABLED,
-        Settings.Secure.SEARCH_LONG_PRESS_HOME_ENABLED,
+        Settings.Secure.SEARCH_ALL_ENTRYPOINTS_ENABLED,
         Settings.Secure.HUB_MODE_TUTORIAL_STATE,
         Settings.Secure.STYLUS_BUTTONS_ENABLED,
         Settings.Secure.STYLUS_HANDWRITING_ENABLED,
diff --git a/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java b/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java
index 046d6e2..b8d95eb 100644
--- a/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java
+++ b/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java
@@ -208,8 +208,7 @@
         VALIDATORS.put(Secure.ASSIST_TOUCH_GESTURE_ENABLED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.ASSIST_LONG_PRESS_HOME_ENABLED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.VISUAL_QUERY_ACCESSIBILITY_DETECTION_ENABLED, BOOLEAN_VALIDATOR);
-        VALIDATORS.put(Secure.SEARCH_PRESS_HOLD_NAV_HANDLE_ENABLED, BOOLEAN_VALIDATOR);
-        VALIDATORS.put(Secure.SEARCH_LONG_PRESS_HOME_ENABLED, BOOLEAN_VALIDATOR);
+        VALIDATORS.put(Secure.SEARCH_ALL_ENTRYPOINTS_ENABLED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.VR_DISPLAY_MODE, new DiscreteValueValidator(new String[] {"0", "1"}));
         VALIDATORS.put(Secure.NOTIFICATION_BADGING, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.NOTIFICATION_DISMISS_RTL, BOOLEAN_VALIDATOR);
diff --git a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java
index 02d212c..dba3bac 100644
--- a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java
+++ b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java
@@ -1950,11 +1950,8 @@
                 Settings.Secure.ASSIST_LONG_PRESS_HOME_ENABLED,
                 SecureSettingsProto.Assist.LONG_PRESS_HOME_ENABLED);
         dumpSetting(s, p,
-                Settings.Secure.SEARCH_PRESS_HOLD_NAV_HANDLE_ENABLED,
-                SecureSettingsProto.Assist.SEARCH_PRESS_HOLD_NAV_HANDLE_ENABLED);
-        dumpSetting(s, p,
-                Settings.Secure.SEARCH_LONG_PRESS_HOME_ENABLED,
-                SecureSettingsProto.Assist.SEARCH_LONG_PRESS_HOME_ENABLED);
+                Settings.Secure.SEARCH_ALL_ENTRYPOINTS_ENABLED,
+                SecureSettingsProto.Assist.SEARCH_ALL_ENTRYPOINTS_ENABLED);
         dumpSetting(s, p,
                 Settings.Secure.VISUAL_QUERY_ACCESSIBILITY_DETECTION_ENABLED,
                 SecureSettingsProto.Assist.VISUAL_QUERY_ACCESSIBILITY_DETECTION_ENABLED);
diff --git a/packages/Shell/AndroidManifest.xml b/packages/Shell/AndroidManifest.xml
index 02d19dc..5804071 100644
--- a/packages/Shell/AndroidManifest.xml
+++ b/packages/Shell/AndroidManifest.xml
@@ -932,6 +932,9 @@
     <uses-permission
         android:name="android.permission.OVERRIDE_SYSTEM_KEY_BEHAVIOR_IN_FOCUSED_WINDOW" />
 
+    <!-- Permission required for Cts test - CtsSettingsTestCases -->
+    <uses-permission android:name="android.permission.PREPARE_FACTORY_RESET" />
+
     <application
         android:label="@string/app_label"
         android:theme="@android:style/Theme.DeviceDefault.DayNight"
diff --git a/packages/SystemUI/accessibility/accessibilitymenu/tests/src/com/android/systemui/accessibility/accessibilitymenu/tests/AccessibilityMenuServiceTest.java b/packages/SystemUI/accessibility/accessibilitymenu/tests/src/com/android/systemui/accessibility/accessibilitymenu/tests/AccessibilityMenuServiceTest.java
index 6546b87..f70ad9e 100644
--- a/packages/SystemUI/accessibility/accessibilitymenu/tests/src/com/android/systemui/accessibility/accessibilitymenu/tests/AccessibilityMenuServiceTest.java
+++ b/packages/SystemUI/accessibility/accessibilitymenu/tests/src/com/android/systemui/accessibility/accessibilitymenu/tests/AccessibilityMenuServiceTest.java
@@ -23,10 +23,10 @@
 import static android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS;
 import static android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_TAKE_SCREENSHOT;
 
-import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.INTENT_OPEN_BLOCKED;
 import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.INTENT_GLOBAL_ACTION;
 import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.INTENT_GLOBAL_ACTION_EXTRA;
 import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.INTENT_HIDE_MENU;
+import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.INTENT_OPEN_BLOCKED;
 import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.INTENT_TOGGLE_MENU;
 import static com.android.systemui.accessibility.accessibilitymenu.AccessibilityMenuService.PACKAGE_NAME;
 
@@ -77,6 +77,8 @@
     private static final int TIMEOUT_SERVICE_STATUS_CHANGE_S = 5;
     private static final int TIMEOUT_UI_CHANGE_S = 5;
     private static final int NO_GLOBAL_ACTION = -1;
+    private static final Intent INTENT_OPEN_MENU = new Intent(INTENT_TOGGLE_MENU)
+            .setPackage(PACKAGE_NAME);
 
     private static Instrumentation sInstrumentation;
     private static UiAutomation sUiAutomation;
@@ -152,9 +154,6 @@
     @Before
     public void setup() throws Throwable {
         sOpenBlocked.set(false);
-        wakeUpScreen();
-        sUiAutomation.executeShellCommand("input keyevent KEYCODE_MENU");
-        openMenu();
     }
 
     @After
@@ -188,24 +187,17 @@
     }
 
     private static void openMenu() throws Throwable {
-        openMenu(false);
-    }
-
-    private static void openMenu(boolean abandonOnBlock) throws Throwable {
-        Intent intent = new Intent(INTENT_TOGGLE_MENU);
-        intent.setPackage(PACKAGE_NAME);
-        sInstrumentation.getContext().sendBroadcast(intent);
+        unlockSignal();
+        sInstrumentation.getContext().sendBroadcast(INTENT_OPEN_MENU);
 
         TestUtils.waitUntil("Timed out before menu could appear.",
                 TIMEOUT_UI_CHANGE_S,
                 () -> {
-                    if (sOpenBlocked.get() && abandonOnBlock) {
-                        throw new IllegalStateException();
-                    }
                     if (isMenuVisible()) {
                         return true;
                     } else {
-                        sInstrumentation.getContext().sendBroadcast(intent);
+                        unlockSignal();
+                        sInstrumentation.getContext().sendBroadcast(INTENT_OPEN_MENU);
                         return false;
                     }
                 });
@@ -249,6 +241,7 @@
 
     @Test
     public void testAdjustBrightness() throws Throwable {
+        openMenu();
         Context context = sInstrumentation.getTargetContext();
         DisplayManager displayManager = context.getSystemService(
                 DisplayManager.class);
@@ -264,22 +257,28 @@
                 context.getDisplayId()).getBrightnessInfo();
 
         try {
-            displayManager.setBrightness(context.getDisplayId(), brightnessInfo.brightnessMinimum);
             TestUtils.waitUntil("Could not change to minimum brightness",
                     TIMEOUT_UI_CHANGE_S,
-                    () -> displayManager.getBrightness(context.getDisplayId())
-                            == brightnessInfo.brightnessMinimum);
+                    () -> {
+                        displayManager.setBrightness(
+                                context.getDisplayId(), brightnessInfo.brightnessMinimum);
+                        return displayManager.getBrightness(context.getDisplayId())
+                                == brightnessInfo.brightnessMinimum;
+                    });
             brightnessUpButton.performAction(CLICK_ID);
             TestUtils.waitUntil("Did not detect an increase in brightness.",
                     TIMEOUT_UI_CHANGE_S,
                     () -> displayManager.getBrightness(context.getDisplayId())
                             > brightnessInfo.brightnessMinimum);
 
-            displayManager.setBrightness(context.getDisplayId(), brightnessInfo.brightnessMaximum);
             TestUtils.waitUntil("Could not change to maximum brightness",
                     TIMEOUT_UI_CHANGE_S,
-                    () -> displayManager.getBrightness(context.getDisplayId())
-                            == brightnessInfo.brightnessMaximum);
+                    () -> {
+                        displayManager.setBrightness(
+                                context.getDisplayId(), brightnessInfo.brightnessMaximum);
+                        return displayManager.getBrightness(context.getDisplayId())
+                                == brightnessInfo.brightnessMaximum;
+                    });
             brightnessDownButton.performAction(CLICK_ID);
             TestUtils.waitUntil("Did not detect a decrease in brightness.",
                     TIMEOUT_UI_CHANGE_S,
@@ -292,6 +291,7 @@
 
     @Test
     public void testAdjustVolume() throws Throwable {
+        openMenu();
         Context context = sInstrumentation.getTargetContext();
         AudioManager audioManager = context.getSystemService(AudioManager.class);
         int resetVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
@@ -332,6 +332,7 @@
 
     @Test
     public void testAssistantButton_opensVoiceAssistant() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo assistantButton = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_ASSISTANT_VALUE.ordinal()));
         Intent expectedIntent = new Intent(Intent.ACTION_VOICE_COMMAND);
@@ -349,6 +350,7 @@
 
     @Test
     public void testAccessibilitySettingsButton_opensAccessibilitySettings() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo settingsButton = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_A11YSETTING_VALUE.ordinal()));
         Intent expectedIntent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
@@ -364,6 +366,7 @@
 
     @Test
     public void testPowerButton_performsGlobalAction() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo button = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_POWER_VALUE.ordinal()));
 
@@ -376,6 +379,7 @@
 
     @Test
     public void testRecentButton_performsGlobalAction() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo button = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_RECENT_VALUE.ordinal()));
 
@@ -388,6 +392,7 @@
 
     @Test
     public void testLockButton_performsGlobalAction() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo button = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_LOCKSCREEN_VALUE.ordinal()));
 
@@ -400,6 +405,7 @@
 
     @Test
     public void testQuickSettingsButton_performsGlobalAction() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo button = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_QUICKSETTING_VALUE.ordinal()));
 
@@ -412,6 +418,7 @@
 
     @Test
     public void testNotificationsButton_performsGlobalAction() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo button = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_NOTIFICATION_VALUE.ordinal()));
 
@@ -424,6 +431,7 @@
 
     @Test
     public void testScreenshotButton_performsGlobalAction() throws Throwable {
+        openMenu();
         AccessibilityNodeInfo button = findGridButtonInfo(getGridButtonList(),
                 String.valueOf(ShortcutId.ID_SCREENSHOT_VALUE.ordinal()));
 
@@ -436,6 +444,7 @@
 
     @Test
     public void testOnScreenLock_closesMenu() throws Throwable {
+        openMenu();
         closeScreen();
         wakeUpScreen();
 
@@ -447,13 +456,18 @@
         closeScreen();
         wakeUpScreen();
 
-        boolean blocked = false;
-        try {
-            openMenu(true);
-        } catch (IllegalStateException e) {
-            // Expected
-            blocked = true;
-        }
-        assertThat(blocked).isTrue();
+        TestUtils.waitUntil("Did not receive signal that menu cannot open",
+                TIMEOUT_UI_CHANGE_S,
+                () -> {
+                    sInstrumentation.getContext().sendBroadcast(INTENT_OPEN_MENU);
+                    return sOpenBlocked.get();
+                });
+    }
+
+    private static void unlockSignal() {
+        // MENU unlocks screen,
+        // BACK closes any menu that may appear if the screen wasn't locked.
+        sUiAutomation.executeShellCommand("input keyevent KEYCODE_MENU");
+        sUiAutomation.executeShellCommand("input keyevent KEYCODE_BACK");
     }
 }
diff --git a/packages/SystemUI/aconfig/systemui.aconfig b/packages/SystemUI/aconfig/systemui.aconfig
index 8da5021..a155dc4 100644
--- a/packages/SystemUI/aconfig/systemui.aconfig
+++ b/packages/SystemUI/aconfig/systemui.aconfig
@@ -104,6 +104,13 @@
 }
 
 flag {
+    name: "notifications_heads_up_refactor"
+    namespace: "systemui"
+    description: "Use HeadsUpInteractor to feed HUN updates to the NSSL."
+    bug: "325936094"
+}
+
+flag {
    name: "pss_app_selector_abrupt_exit_fix"
    namespace: "systemui"
    description: "Fixes the app selector abruptly disappearing without an animation, when the"
@@ -424,6 +431,13 @@
 }
 
 flag {
+    name: "screenshot_shelf_ui"
+    namespace: "systemui"
+    description: "Use new shelf UI flow for screenshots"
+    bug: "329659738"
+}
+
+flag {
    name: "run_fingerprint_detect_on_dismissible_keyguard"
    namespace: "systemui"
    description: "Run fingerprint detect instead of authenticate if the keyguard is dismissible."
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/DefaultClockSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/DefaultClockSection.kt
index 82e19e7c..1d86b15 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/DefaultClockSection.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/DefaultClockSection.kt
@@ -58,7 +58,6 @@
         if (currentClock?.smallClock?.view == null) {
             return
         }
-        viewModel.clock = currentClock
 
         val context = LocalContext.current
         MovableElement(key = smallClockElementKey, modifier = modifier) {
@@ -89,7 +88,6 @@
     @Composable
     fun SceneScope.LargeClock(modifier: Modifier = Modifier) {
         val currentClock by viewModel.currentClock.collectAsState()
-        viewModel.clock = currentClock
         if (currentClock?.largeClock?.view == null) {
             return
         }
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 8ad2bb7..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,10 +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.shared.model.StackRounding
-import com.android.systemui.statusbar.notification.stack.ui.viewbinder.NotificationStackAppearanceViewBinder.SCRIM_CORNER_RADIUS
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel
 import kotlin.math.roundToInt
 
@@ -140,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)
@@ -157,7 +159,7 @@
 
     val contentHeight = viewModel.intrinsicContentHeight.collectAsState()
 
-    val stackRounding = viewModel.stackRounding.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
@@ -225,6 +227,7 @@
                 .graphicsLayer {
                     shape =
                         calculateCornerRadius(
+                                scrimCornerRadius,
                                 screenCornerRadius,
                                 { expansionFraction },
                                 layoutState.isTransitioningBetween(Scenes.Gone, Scenes.Shade)
@@ -357,6 +360,7 @@
 }
 
 private fun calculateCornerRadius(
+    scrimCornerRadius: Dp,
     screenCornerRadius: Dp,
     expansionFraction: () -> Float,
     transitioning: Boolean,
@@ -364,12 +368,12 @@
     return if (transitioning) {
         lerp(
                 start = screenCornerRadius.value,
-                stop = SCRIM_CORNER_RADIUS,
+                stop = scrimCornerRadius.value,
                 fraction = (expansionFraction() / EXPANSION_FOR_MAX_CORNER_RADIUS).coerceIn(0f, 1f),
             )
             .dp
     } else {
-        SCRIM_CORNER_RADIUS.dp
+        scrimCornerRadius
     }
 }
 
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 2c31f9b..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
@@ -370,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/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
index 2435170..248dfee 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
@@ -18,6 +18,7 @@
 
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.size
 import androidx.compose.material3.IconButton
@@ -27,6 +28,7 @@
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.semantics.ProgressBarRangeInfo
@@ -38,6 +40,7 @@
 import androidx.compose.ui.unit.dp
 import com.android.compose.PlatformSlider
 import com.android.compose.PlatformSliderColors
+import com.android.systemui.common.shared.model.Icon
 import com.android.systemui.common.ui.compose.Icon
 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderState
 
@@ -86,18 +89,11 @@
                 Text(text = state.valueText, color = LocalContentColor.current)
             } else {
                 state.icon?.let {
-                    IconButton(
-                        onClick = onIconTapped,
-                        colors =
-                            IconButtonColors(
-                                contentColor = LocalContentColor.current,
-                                containerColor = Color.Transparent,
-                                disabledContentColor = LocalContentColor.current,
-                                disabledContainerColor = Color.Transparent,
-                            )
-                    ) {
-                        Icon(modifier = Modifier.size(24.dp), icon = it)
-                    }
+                    SliderIcon(
+                        icon = it,
+                        onIconTapped = onIconTapped,
+                        isTappable = state.isMutable,
+                    )
                 }
             }
         },
@@ -127,3 +123,32 @@
         }
     )
 }
+
+@Composable
+private fun SliderIcon(
+    icon: Icon,
+    onIconTapped: () -> Unit,
+    isTappable: Boolean,
+    modifier: Modifier = Modifier
+) {
+    if (isTappable) {
+        IconButton(
+            modifier = modifier,
+            onClick = onIconTapped,
+            colors =
+                IconButtonColors(
+                    contentColor = LocalContentColor.current,
+                    containerColor = Color.Transparent,
+                    disabledContentColor = LocalContentColor.current,
+                    disabledContainerColor = Color.Transparent,
+                ),
+            content = { Icon(modifier = Modifier.size(24.dp), icon = icon) },
+        )
+    } else {
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+            content = { Icon(modifier = Modifier.size(24.dp), icon = icon) },
+        )
+    }
+}
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/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractorTest.kt
index 769caaa..36458ed 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractorTest.kt
@@ -270,12 +270,61 @@
         }
 
     @Test
+    fun transitionValue_canceled_toAnotherState() =
+        testScope.runTest {
+            val transitionValuesGone by collectValues(underTest.transitionValue(state = GONE))
+            val transitionValuesAod by collectValues(underTest.transitionValue(state = AOD))
+            val transitionValuesLs by collectValues(underTest.transitionValue(state = LOCKSCREEN))
+
+            listOf(
+                    TransitionStep(GONE, AOD, 0f, STARTED),
+                    TransitionStep(GONE, AOD, 0.5f, RUNNING),
+                    TransitionStep(GONE, AOD, 0.5f, CANCELED),
+                    TransitionStep(AOD, LOCKSCREEN, 0.5f, STARTED),
+                    TransitionStep(AOD, LOCKSCREEN, 0.7f, RUNNING),
+                    TransitionStep(AOD, LOCKSCREEN, 1f, FINISHED),
+                )
+                .forEach {
+                    repository.sendTransitionStep(it)
+                    runCurrent()
+                }
+
+            assertThat(transitionValuesGone).isEqualTo(listOf(1f, 0.5f, 0f))
+            assertThat(transitionValuesAod).isEqualTo(listOf(0f, 0.5f, 0.5f, 0.3f, 0f))
+            assertThat(transitionValuesLs).isEqualTo(listOf(0.5f, 0.7f, 1f))
+        }
+
+    @Test
+    fun transitionValue_canceled_backToOriginalState() =
+        testScope.runTest {
+            val transitionValuesGone by collectValues(underTest.transitionValue(state = GONE))
+            val transitionValuesAod by collectValues(underTest.transitionValue(state = AOD))
+
+            listOf(
+                    TransitionStep(GONE, AOD, 0f, STARTED),
+                    TransitionStep(GONE, AOD, 0.5f, RUNNING),
+                    TransitionStep(GONE, AOD, 1f, CANCELED),
+                    TransitionStep(AOD, GONE, 0.5f, STARTED),
+                    TransitionStep(AOD, GONE, 0.7f, RUNNING),
+                    TransitionStep(AOD, GONE, 1f, FINISHED),
+                )
+                .forEach {
+                    repository.sendTransitionStep(it)
+                    runCurrent()
+                }
+
+            assertThat(transitionValuesGone).isEqualTo(listOf(1f, 0.5f, 0.5f, 0.7f, 1f))
+            assertThat(transitionValuesAod).isEqualTo(listOf(0f, 0.5f, 0.5f, 0.3f, 0f))
+        }
+
+    @Test
     fun isInTransitionToAnyState() =
         testScope.runTest {
             val inTransition by collectValues(underTest.isInTransitionToAnyState)
 
             assertEquals(
                 listOf(
+                    false,
                     true, // The repo is seeded with a transition from OFF to LOCKSCREEN.
                     false,
                 ),
@@ -288,6 +337,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
@@ -301,6 +351,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
@@ -314,6 +365,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
@@ -330,6 +382,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                 ),
@@ -345,6 +398,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
@@ -359,6 +413,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
@@ -379,6 +434,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
@@ -398,6 +454,7 @@
 
             assertEquals(
                 listOf(
+                    false,
                     true,
                     false,
                     true,
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModelTest.kt
index d443851..0cc0c2f 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModelTest.kt
@@ -19,6 +19,7 @@
 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.coroutines.collectValues
 import com.android.systemui.flags.Flags
 import com.android.systemui.flags.fakeFeatureFlagsClassic
@@ -32,6 +33,7 @@
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -49,9 +51,7 @@
         }
     private val testScope = kosmos.testScope
     private val keyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository
-    private val underTest by lazy {
-        kosmos.alternateBouncerToGoneTransitionViewModel
-    }
+    private val underTest by lazy { kosmos.alternateBouncerToGoneTransitionViewModel }
 
     @Test
     fun deviceEntryParentViewDisappear() =
@@ -73,6 +73,61 @@
             values.forEach { assertThat(it).isEqualTo(0f) }
         }
 
+    @Test
+    fun lockscreenAlpha() =
+        testScope.runTest {
+            val startAlpha = 0.6f
+            val viewState = ViewStateAccessor(alpha = { startAlpha })
+            val alpha by collectLastValue(underTest.lockscreenAlpha(viewState))
+            runCurrent()
+
+            keyguardTransitionRepository.sendTransitionSteps(
+                listOf(
+                    step(0f, TransitionState.STARTED),
+                    step(0.25f),
+                    step(0.5f),
+                    step(0.75f),
+                    step(1f),
+                ),
+                testScope,
+            )
+
+            // Alpha starts at the starting value from ViewStateAccessor.
+            keyguardTransitionRepository.sendTransitionStep(
+                step(0f, state = TransitionState.STARTED)
+            )
+            runCurrent()
+            assertThat(alpha).isEqualTo(startAlpha)
+
+            // Alpha finishes in 200ms out of 500ms, check the alpha at the halfway point.
+            val progress = 0.2f
+            keyguardTransitionRepository.sendTransitionStep(step(progress))
+            runCurrent()
+            assertThat(alpha).isEqualTo(0.3f)
+
+            // Alpha ends at 0.
+            keyguardTransitionRepository.sendTransitionStep(step(1f))
+            runCurrent()
+            assertThat(alpha).isEqualTo(0f)
+        }
+
+    @Test
+    fun lockscreenAlpha_zeroInitialAlpha() =
+        testScope.runTest {
+            // ViewState starts at 0 alpha.
+            val viewState = ViewStateAccessor(alpha = { 0f })
+            val alpha by collectValues(underTest.lockscreenAlpha(viewState))
+
+            keyguardTransitionRepository.sendTransitionSteps(
+                from = KeyguardState.ALTERNATE_BOUNCER,
+                to = GONE,
+                testScope
+            )
+
+            // Alpha starts and ends at 0.
+            alpha.forEach { assertThat(it).isEqualTo(0f) }
+        }
+
     private fun step(value: Float, state: TransitionState = RUNNING): TransitionStep {
         return TransitionStep(
             from = KeyguardState.ALTERNATE_BOUNCER,
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelTest.kt
index 0796af0..409c551 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelTest.kt
@@ -91,27 +91,6 @@
             assertThat(bgViewAlpha).isEqualTo(1f)
         }
 
-    @Test
-    fun deviceEntryBackgroundViewAlpha_rearFpEnrolled_noUpdates() =
-        testScope.runTest {
-            fingerprintPropertyRepository.supportsRearFps()
-            val bgViewAlpha by collectLastValue(underTest.deviceEntryBackgroundViewAlpha)
-            keyguardTransitionRepository.sendTransitionStep(step(0f, TransitionState.STARTED))
-            assertThat(bgViewAlpha).isNull()
-
-            keyguardTransitionRepository.sendTransitionStep(step(0.5f))
-            assertThat(bgViewAlpha).isNull()
-
-            keyguardTransitionRepository.sendTransitionStep(step(.75f))
-            assertThat(bgViewAlpha).isNull()
-
-            keyguardTransitionRepository.sendTransitionStep(step(1f))
-            assertThat(bgViewAlpha).isNull()
-
-            keyguardTransitionRepository.sendTransitionStep(step(1f, TransitionState.FINISHED))
-            assertThat(bgViewAlpha).isNull()
-        }
-
     private fun step(
         value: Float,
         state: TransitionState = TransitionState.RUNNING
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/MediaTestHelper.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/MediaTestHelper.kt
new file mode 100644
index 0000000..8e44932
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/MediaTestHelper.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.media.controls
+
+import android.R
+import android.app.smartspace.SmartspaceAction
+import android.content.Context
+import android.graphics.drawable.Icon
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+
+class MediaTestHelper {
+    companion object {
+        /** Returns a list of three mocked recommendations */
+        fun getValidRecommendationList(context: Context): List<SmartspaceAction> {
+            val mediaRecommendationItem =
+                mock<SmartspaceAction> {
+                    whenever(icon)
+                        .thenReturn(
+                            Icon.createWithResource(
+                                context,
+                                R.drawable.ic_media_play,
+                            )
+                        )
+                }
+            return listOf(mediaRecommendationItem, mediaRecommendationItem, mediaRecommendationItem)
+        }
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaDataRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaDataRepositoryTest.kt
new file mode 100644
index 0000000..6c41bc3
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaDataRepositoryTest.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.media.controls.data.repository
+
+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.flags.Flags
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.media.controls.MediaTestHelper
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MediaDataRepositoryTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+
+    private val underTest: MediaDataRepository = kosmos.mediaDataRepository
+
+    @Test
+    fun setRecommendation() =
+        testScope.runTest {
+            val smartspaceData by collectLastValue(underTest.smartspaceMediaData)
+            val recommendation = SmartspaceMediaData(isActive = true)
+
+            underTest.setRecommendation(recommendation)
+
+            assertThat(smartspaceData).isEqualTo(recommendation)
+        }
+
+    @Test
+    fun addAndRemoveMediaData() =
+        testScope.runTest {
+            val entries by collectLastValue(underTest.mediaEntries)
+
+            val firstKey = "key1"
+            val firstData = MediaData().copy(isPlaying = true)
+
+            val secondKey = "key2"
+            val secondData = MediaData().copy(resumption = true)
+
+            underTest.addMediaEntry(firstKey, firstData)
+            underTest.addMediaEntry(secondKey, secondData)
+            underTest.addMediaEntry(firstKey, firstData.copy(isPlaying = false))
+
+            assertThat(entries!!.size).isEqualTo(2)
+            assertThat(entries!![firstKey]).isNotEqualTo(firstData)
+
+            underTest.removeMediaEntry(firstKey)
+
+            assertThat(entries!!.size).isEqualTo(1)
+            assertThat(entries!![secondKey]).isEqualTo(secondData)
+        }
+
+    @Test
+    fun setRecommendationInactive() =
+        testScope.runTest {
+            kosmos.fakeFeatureFlagsClassic.set(Flags.MEDIA_RETAIN_RECOMMENDATIONS, true)
+            val smartspaceData by collectLastValue(underTest.smartspaceMediaData)
+            val recommendation =
+                SmartspaceMediaData(
+                    targetId = KEY_MEDIA_SMARTSPACE,
+                    isActive = true,
+                    recommendations = MediaTestHelper.getValidRecommendationList(context),
+                )
+
+            underTest.setRecommendation(recommendation)
+
+            assertThat(smartspaceData).isEqualTo(recommendation)
+
+            underTest.setRecommendationInactive(KEY_MEDIA_SMARTSPACE)
+
+            assertThat(smartspaceData).isNotEqualTo(recommendation)
+            assertThat(smartspaceData!!.isActive).isFalse()
+        }
+
+    @Test
+    fun dismissRecommendation() =
+        testScope.runTest {
+            val smartspaceData by collectLastValue(underTest.smartspaceMediaData)
+            val recommendation =
+                SmartspaceMediaData(
+                    targetId = KEY_MEDIA_SMARTSPACE,
+                    isActive = true,
+                    recommendations = MediaTestHelper.getValidRecommendationList(context),
+                )
+
+            underTest.setRecommendation(recommendation)
+
+            assertThat(smartspaceData).isEqualTo(recommendation)
+
+            underTest.dismissSmartspaceRecommendation(KEY_MEDIA_SMARTSPACE)
+
+            assertThat(smartspaceData!!.isActive).isFalse()
+        }
+
+    companion object {
+        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryTest.kt
new file mode 100644
index 0000000..d39e77d
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryTest.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.media.controls.data.repository
+
+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.testScope
+import com.android.systemui.media.controls.MediaTestHelper
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MediaFilterRepositoryTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+
+    private val underTest: MediaFilterRepository = kosmos.mediaFilterRepository
+
+    @Test
+    fun addSelectedUserMediaEntry_activeThenInactivate() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(underTest.selectedUserEntries)
+
+            val userMedia = MediaData().copy(active = true)
+
+            underTest.addSelectedUserMediaEntry(KEY, userMedia)
+
+            assertThat(selectedUserEntries?.get(KEY)).isEqualTo(userMedia)
+
+            underTest.addSelectedUserMediaEntry(KEY, userMedia.copy(active = false))
+
+            assertThat(selectedUserEntries?.get(KEY)).isNotEqualTo(userMedia)
+            assertThat(selectedUserEntries?.get(KEY)?.active).isFalse()
+        }
+
+    @Test
+    fun addSelectedUserMediaEntry_thenRemove_returnsBoolean() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(underTest.selectedUserEntries)
+
+            val userMedia = MediaData()
+
+            underTest.addSelectedUserMediaEntry(KEY, userMedia)
+
+            assertThat(selectedUserEntries?.get(KEY)).isEqualTo(userMedia)
+
+            assertThat(underTest.removeSelectedUserMediaEntry(KEY, userMedia)).isTrue()
+        }
+
+    @Test
+    fun addSelectedUserMediaEntry_thenRemove_returnsValue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(underTest.selectedUserEntries)
+
+            val userMedia = MediaData()
+
+            underTest.addSelectedUserMediaEntry(KEY, userMedia)
+
+            assertThat(selectedUserEntries?.get(KEY)).isEqualTo(userMedia)
+
+            assertThat(underTest.removeSelectedUserMediaEntry(KEY)).isEqualTo(userMedia)
+        }
+
+    @Test
+    fun addAllUserMediaEntry_activeThenInactivate() =
+        testScope.runTest {
+            val allUserEntries by collectLastValue(underTest.allUserEntries)
+
+            val userMedia = MediaData().copy(active = true)
+
+            underTest.addMediaEntry(KEY, userMedia)
+
+            assertThat(allUserEntries?.get(KEY)).isEqualTo(userMedia)
+
+            underTest.addMediaEntry(KEY, userMedia.copy(active = false))
+
+            assertThat(allUserEntries?.get(KEY)).isNotEqualTo(userMedia)
+            assertThat(allUserEntries?.get(KEY)?.active).isFalse()
+        }
+
+    @Test
+    fun addAllUserMediaEntry_thenRemove_returnsValue() =
+        testScope.runTest {
+            val allUserEntries by collectLastValue(underTest.allUserEntries)
+
+            val userMedia = MediaData()
+
+            underTest.addMediaEntry(KEY, userMedia)
+
+            assertThat(allUserEntries?.get(KEY)).isEqualTo(userMedia)
+
+            assertThat(underTest.removeMediaEntry(KEY)).isEqualTo(userMedia)
+        }
+
+    @Test
+    fun addActiveRecommendation_thenInactive() =
+        testScope.runTest {
+            val smartspaceMediaData by collectLastValue(underTest.smartspaceMediaData)
+
+            val mediaRecommendation =
+                SmartspaceMediaData(
+                    targetId = KEY_MEDIA_SMARTSPACE,
+                    isActive = true,
+                    recommendations = MediaTestHelper.getValidRecommendationList(context),
+                )
+
+            underTest.setRecommendation(mediaRecommendation)
+
+            assertThat(smartspaceMediaData).isEqualTo(mediaRecommendation)
+
+            underTest.setRecommendation(mediaRecommendation.copy(isActive = false))
+
+            assertThat(smartspaceMediaData).isNotEqualTo(mediaRecommendation)
+            assertThat(smartspaceMediaData?.isActive).isFalse()
+        }
+
+    companion object {
+        private const val KEY = "KEY"
+        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt
new file mode 100644
index 0000000..6e67000
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt
@@ -0,0 +1,199 @@
+/*
+ * 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.media.controls.domain.interactor
+
+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.flags.Flags
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.media.controls.MediaTestHelper
+import com.android.systemui.media.controls.data.repository.MediaFilterRepository
+import com.android.systemui.media.controls.data.repository.mediaFilterRepository
+import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor
+import com.android.systemui.media.controls.domain.pipeline.interactor.mediaCarouselInteractor
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MediaCarouselInteractorTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+
+    private val mediaFilterRepository: MediaFilterRepository = kosmos.mediaFilterRepository
+    private val underTest: MediaCarouselInteractor = kosmos.mediaCarouselInteractor
+
+    @Test
+    fun addUserMediaEntry_activeThenInactivate() =
+        testScope.runTest {
+            val hasActiveMediaOrRecommendation by
+                collectLastValue(underTest.hasActiveMediaOrRecommendation)
+            val hasActiveMedia by collectLastValue(underTest.hasActiveMedia)
+            val hasAnyMedia by collectLastValue(underTest.hasAnyMedia)
+
+            val userMedia = MediaData().copy(active = true)
+
+            mediaFilterRepository.addSelectedUserMediaEntry(KEY, userMedia)
+
+            assertThat(hasActiveMediaOrRecommendation).isTrue()
+            assertThat(hasActiveMedia).isTrue()
+            assertThat(hasAnyMedia).isTrue()
+
+            mediaFilterRepository.addSelectedUserMediaEntry(KEY, userMedia.copy(active = false))
+
+            assertThat(hasActiveMediaOrRecommendation).isFalse()
+            assertThat(hasActiveMedia).isFalse()
+            assertThat(hasAnyMedia).isTrue()
+        }
+
+    @Test
+    fun addInactiveUserMediaEntry_thenRemove() =
+        testScope.runTest {
+            val hasActiveMediaOrRecommendation by
+                collectLastValue(underTest.hasActiveMediaOrRecommendation)
+            val hasActiveMedia by collectLastValue(underTest.hasActiveMedia)
+            val hasAnyMedia by collectLastValue(underTest.hasAnyMedia)
+
+            val userMedia = MediaData().copy(active = false)
+
+            mediaFilterRepository.addSelectedUserMediaEntry(KEY, userMedia)
+
+            assertThat(hasActiveMediaOrRecommendation).isFalse()
+            assertThat(hasActiveMedia).isFalse()
+            assertThat(hasAnyMedia).isTrue()
+
+            assertThat(mediaFilterRepository.removeSelectedUserMediaEntry(KEY, userMedia)).isTrue()
+
+            assertThat(hasActiveMediaOrRecommendation).isFalse()
+            assertThat(hasActiveMedia).isFalse()
+            assertThat(hasAnyMedia).isFalse()
+        }
+
+    @Test
+    fun addActiveRecommendation_inactiveMedia() =
+        testScope.runTest {
+            val hasActiveMediaOrRecommendation by
+                collectLastValue(underTest.hasActiveMediaOrRecommendation)
+            val hasAnyMediaOrRecommendation by
+                collectLastValue(underTest.hasAnyMediaOrRecommendation)
+            kosmos.fakeFeatureFlagsClassic.set(Flags.MEDIA_RETAIN_RECOMMENDATIONS, false)
+
+            val userMediaRecommendation =
+                SmartspaceMediaData(
+                    targetId = KEY_MEDIA_SMARTSPACE,
+                    isActive = true,
+                    recommendations = MediaTestHelper.getValidRecommendationList(context),
+                )
+            val userMedia = MediaData().copy(active = false)
+
+            mediaFilterRepository.setRecommendation(userMediaRecommendation)
+
+            assertThat(hasActiveMediaOrRecommendation).isTrue()
+            assertThat(hasAnyMediaOrRecommendation).isTrue()
+
+            mediaFilterRepository.addSelectedUserMediaEntry(KEY, userMedia)
+
+            assertThat(hasActiveMediaOrRecommendation).isTrue()
+            assertThat(hasAnyMediaOrRecommendation).isTrue()
+        }
+
+    @Test
+    fun addActiveRecommendation_thenInactive() =
+        testScope.runTest {
+            val hasActiveMediaOrRecommendation by
+                collectLastValue(underTest.hasActiveMediaOrRecommendation)
+            val hasAnyMediaOrRecommendation by
+                collectLastValue(underTest.hasAnyMediaOrRecommendation)
+            kosmos.fakeFeatureFlagsClassic.set(Flags.MEDIA_RETAIN_RECOMMENDATIONS, false)
+
+            val mediaRecommendation =
+                SmartspaceMediaData(
+                    targetId = KEY_MEDIA_SMARTSPACE,
+                    isActive = true,
+                    recommendations = MediaTestHelper.getValidRecommendationList(context),
+                )
+
+            mediaFilterRepository.setRecommendation(mediaRecommendation)
+
+            assertThat(hasActiveMediaOrRecommendation).isTrue()
+            assertThat(hasAnyMediaOrRecommendation).isTrue()
+
+            mediaFilterRepository.setRecommendation(mediaRecommendation.copy(isActive = false))
+
+            assertThat(hasActiveMediaOrRecommendation).isFalse()
+            assertThat(hasAnyMediaOrRecommendation).isFalse()
+        }
+
+    @Test
+    fun addActiveRecommendation_thenInvalid() =
+        testScope.runTest {
+            val hasActiveMediaOrRecommendation by
+                collectLastValue(underTest.hasActiveMediaOrRecommendation)
+            val hasAnyMediaOrRecommendation by
+                collectLastValue(underTest.hasAnyMediaOrRecommendation)
+            kosmos.fakeFeatureFlagsClassic.set(Flags.MEDIA_RETAIN_RECOMMENDATIONS, false)
+
+            val mediaRecommendation =
+                SmartspaceMediaData(
+                    targetId = KEY_MEDIA_SMARTSPACE,
+                    isActive = true,
+                    recommendations = MediaTestHelper.getValidRecommendationList(context),
+                )
+
+            mediaFilterRepository.setRecommendation(mediaRecommendation)
+
+            assertThat(hasActiveMediaOrRecommendation).isTrue()
+            assertThat(hasAnyMediaOrRecommendation).isTrue()
+
+            mediaFilterRepository.setRecommendation(
+                mediaRecommendation.copy(recommendations = listOf())
+            )
+
+            assertThat(hasActiveMediaOrRecommendation).isFalse()
+            assertThat(hasAnyMediaOrRecommendation).isFalse()
+        }
+
+    @Test
+    fun hasAnyMedia_noMediaSet_returnsFalse() =
+        testScope.runTest { assertThat(underTest.hasAnyMedia.value).isFalse() }
+
+    @Test
+    fun hasAnyMediaOrRecommendation_noMediaSet_returnsFalse() =
+        testScope.runTest { assertThat(underTest.hasAnyMediaOrRecommendation.value).isFalse() }
+
+    @Test
+    fun hasActiveMedia_noMediaSet_returnsFalse() =
+        testScope.runTest { assertThat(underTest.hasActiveMedia.value).isFalse() }
+
+    @Test
+    fun hasActiveMediaOrRecommendation_nothingSet_returnsFalse() =
+        testScope.runTest { assertThat(underTest.hasActiveMediaOrRecommendation.value).isFalse() }
+
+    companion object {
+        private const val KEY = "KEY"
+        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapperTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapperTest.kt
index c2ce392..f1cd0c8 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapperTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapperTest.kt
@@ -185,7 +185,7 @@
             setOf(QSTileState.UserAction.CLICK),
             label,
             null,
-            QSTileState.SideViewIcon.None,
+            QSTileState.SideViewIcon.Chevron,
             QSTileState.EnabledState.ENABLED,
             Switch::class.qualifiedName
         )
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 6dd425c..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
@@ -20,9 +20,12 @@
 import androidx.test.filters.SmallTest
 import com.android.systemui.SysuiTestCase
 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
 
@@ -59,6 +61,18 @@
             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 {
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/AvalancheControllerTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/AvalancheControllerTest.kt
index be63301..30564bb 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/AvalancheControllerTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/AvalancheControllerTest.kt
@@ -60,7 +60,7 @@
     private val mGlobalSettings = FakeGlobalSettings()
     private val mSystemClock = FakeSystemClock()
     private val mExecutor = FakeExecutor(mSystemClock)
-    private var testableHeadsUpManager: BaseHeadsUpManager? = null
+    private lateinit var testableHeadsUpManager: BaseHeadsUpManager
 
     @Before
     fun setUp() {
@@ -88,20 +88,15 @@
     }
 
     private fun createHeadsUpEntry(id: Int): BaseHeadsUpManager.HeadsUpEntry {
-        val entry = testableHeadsUpManager!!.createHeadsUpEntry()
-
-        entry.setEntry(
+        return testableHeadsUpManager.createHeadsUpEntry(
             NotificationEntryBuilder()
                 .setSbn(HeadsUpManagerTestUtil.createSbn(id, Notification.Builder(mContext, "")))
                 .build()
         )
-        return entry
     }
 
     private fun createFsiHeadsUpEntry(id: Int): BaseHeadsUpManager.HeadsUpEntry {
-        val entry = testableHeadsUpManager!!.createHeadsUpEntry()
-        entry.setEntry(createFullScreenIntentEntry(id, mContext))
-        return entry
+        return testableHeadsUpManager.createHeadsUpEntry(createFullScreenIntentEntry(id, mContext))
     }
 
     @Test
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/BaseHeadsUpManagerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/BaseHeadsUpManagerTest.java
index ed0d272..3dc4495 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/BaseHeadsUpManagerTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/BaseHeadsUpManagerTest.java
@@ -38,7 +38,6 @@
 import android.app.Notification;
 import android.app.PendingIntent;
 import android.app.Person;
-import android.content.Intent;
 import android.testing.TestableLooper;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -498,16 +497,16 @@
     public void testAlertEntryCompareTo_ongoingCallLessThanActiveRemoteInput() {
         final BaseHeadsUpManager hum = createHeadsUpManager();
 
-        final BaseHeadsUpManager.HeadsUpEntry ongoingCall = hum.new HeadsUpEntry();
-        ongoingCall.setEntry(new NotificationEntryBuilder()
-                .setSbn(HeadsUpManagerTestUtil.createSbn(/* id = */ 0,
-                        new Notification.Builder(mContext, "")
-                                .setCategory(Notification.CATEGORY_CALL)
-                                .setOngoing(true)))
-                .build());
+        final BaseHeadsUpManager.HeadsUpEntry ongoingCall = hum.new HeadsUpEntry(
+                new NotificationEntryBuilder()
+                        .setSbn(HeadsUpManagerTestUtil.createSbn(/* id = */ 0,
+                                new Notification.Builder(mContext, "")
+                                        .setCategory(Notification.CATEGORY_CALL)
+                                        .setOngoing(true)))
+                        .build());
 
-        final BaseHeadsUpManager.HeadsUpEntry activeRemoteInput = hum.new HeadsUpEntry();
-        activeRemoteInput.setEntry(HeadsUpManagerTestUtil.createEntry(/* id = */ 1, mContext));
+        final BaseHeadsUpManager.HeadsUpEntry activeRemoteInput = hum.new HeadsUpEntry(
+                HeadsUpManagerTestUtil.createEntry(/* id = */ 1, mContext));
         activeRemoteInput.mRemoteInputActive = true;
 
         assertThat(ongoingCall.compareTo(activeRemoteInput)).isLessThan(0);
@@ -518,18 +517,18 @@
     public void testAlertEntryCompareTo_incomingCallLessThanActiveRemoteInput() {
         final BaseHeadsUpManager hum = createHeadsUpManager();
 
-        final BaseHeadsUpManager.HeadsUpEntry incomingCall = hum.new HeadsUpEntry();
         final Person person = new Person.Builder().setName("person").build();
         final PendingIntent intent = mock(PendingIntent.class);
-        incomingCall.setEntry(new NotificationEntryBuilder()
-                .setSbn(HeadsUpManagerTestUtil.createSbn(/* id = */ 0,
-                        new Notification.Builder(mContext, "")
-                                .setStyle(Notification.CallStyle
-                                        .forIncomingCall(person, intent, intent))))
-                .build());
+        final BaseHeadsUpManager.HeadsUpEntry incomingCall = hum.new HeadsUpEntry(
+                new NotificationEntryBuilder()
+                        .setSbn(HeadsUpManagerTestUtil.createSbn(/* id = */ 0,
+                                new Notification.Builder(mContext, "")
+                                        .setStyle(Notification.CallStyle
+                                                .forIncomingCall(person, intent, intent))))
+                        .build());
 
-        final BaseHeadsUpManager.HeadsUpEntry activeRemoteInput = hum.new HeadsUpEntry();
-        activeRemoteInput.setEntry(HeadsUpManagerTestUtil.createEntry(/* id = */ 1, mContext));
+        final BaseHeadsUpManager.HeadsUpEntry activeRemoteInput = hum.new HeadsUpEntry(
+                HeadsUpManagerTestUtil.createEntry(/* id = */ 1, mContext));
         activeRemoteInput.mRemoteInputActive = true;
 
         assertThat(incomingCall.compareTo(activeRemoteInput)).isLessThan(0);
@@ -541,8 +540,7 @@
         final BaseHeadsUpManager hum = createHeadsUpManager();
 
         // Needs full screen intent in order to be pinned
-        final BaseHeadsUpManager.HeadsUpEntry entryToPin = hum.new HeadsUpEntry();
-        entryToPin.setEntry(
+        final BaseHeadsUpManager.HeadsUpEntry entryToPin = hum.new HeadsUpEntry(
                 HeadsUpManagerTestUtil.createFullScreenIntentEntry(/* id = */ 0, mContext));
 
         // Note: the standard way to show a notification would be calling showNotification rather
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/TestableHeadsUpManager.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/TestableHeadsUpManager.java
index d8f77f0..3c9dc63 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/TestableHeadsUpManager.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/TestableHeadsUpManager.java
@@ -54,9 +54,10 @@
         mStickyForSomeTimeAutoDismissTime = BaseHeadsUpManagerTest.TEST_STICKY_AUTO_DISMISS_TIME;
     }
 
+    @NonNull
     @Override
-    protected HeadsUpEntry createHeadsUpEntry() {
-        mLastCreatedEntry = spy(super.createHeadsUpEntry());
+    protected HeadsUpEntry createHeadsUpEntry(NotificationEntry entry) {
+        mLastCreatedEntry = spy(super.createHeadsUpEntry(entry));
         return mLastCreatedEntry;
     }
 
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/domain/interactor/AudioVolumeInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioVolumeInteractorTest.kt
index 3d93654..5358a6d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioVolumeInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioVolumeInteractorTest.kt
@@ -200,6 +200,15 @@
         }
     }
 
+    @Test
+    fun alarmStream_isNotMutable() {
+        with(kosmos) {
+            val isMutable = underTest.isMutable(AudioStream(AudioManager.STREAM_ALARM))
+
+            assertThat(isMutable).isFalse()
+        }
+    }
+
     private companion object {
         val audioStream = AudioStream(AudioManager.STREAM_SYSTEM)
     }
diff --git a/packages/SystemUI/res/layout/screenshot_shelf.xml b/packages/SystemUI/res/layout/screenshot_shelf.xml
new file mode 100644
index 0000000..ef1a21f
--- /dev/null
+++ b/packages/SystemUI/res/layout/screenshot_shelf.xml
@@ -0,0 +1,160 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+<com.android.systemui.screenshot.ui.ScreenshotShelfView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+    <ImageView
+        android:id="@+id/actions_container_background"
+        android:visibility="gone"
+        android:layout_height="0dp"
+        android:layout_width="0dp"
+        android:elevation="4dp"
+        android:background="@drawable/action_chip_container_background"
+        android:layout_marginStart="@dimen/overlay_action_container_margin_horizontal"
+        android:layout_marginBottom="@dimen/overlay_action_container_margin_bottom"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="@+id/actions_container"
+        app:layout_constraintEnd_toEndOf="@+id/actions_container"
+        app:layout_constraintBottom_toTopOf="@id/guideline"/>
+    <HorizontalScrollView
+        android:id="@+id/actions_container"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="@dimen/overlay_action_container_margin_horizontal"
+        android:paddingEnd="@dimen/overlay_action_container_padding_end"
+        android:paddingVertical="@dimen/overlay_action_container_padding_vertical"
+        android:elevation="4dp"
+        android:scrollbars="none"
+        app:layout_constraintHorizontal_bias="0"
+        app:layout_constraintWidth_percent="1.0"
+        app:layout_constraintWidth_max="wrap"
+        app:layout_constraintStart_toEndOf="@+id/screenshot_preview_border"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintBottom_toBottomOf="@id/actions_container_background">
+        <LinearLayout
+            android:id="@+id/screenshot_actions"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content">
+            <include layout="@layout/overlay_action_chip"
+                     android:id="@+id/screenshot_share_chip"/>
+            <include layout="@layout/overlay_action_chip"
+                     android:id="@+id/screenshot_edit_chip"/>
+            <include layout="@layout/overlay_action_chip"
+                     android:id="@+id/screenshot_scroll_chip"
+                     android:visibility="gone" />
+        </LinearLayout>
+    </HorizontalScrollView>
+    <View
+        android:id="@+id/screenshot_preview_border"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginStart="16dp"
+        android:layout_marginTop="@dimen/overlay_border_width_neg"
+        android:layout_marginEnd="@dimen/overlay_border_width_neg"
+        android:layout_marginBottom="14dp"
+        android:elevation="8dp"
+        android:background="@drawable/overlay_border"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="@id/screenshot_preview"
+        app:layout_constraintEnd_toEndOf="@id/screenshot_preview"
+        app:layout_constraintBottom_toBottomOf="parent"/>
+    <ImageView
+        android:id="@+id/screenshot_preview"
+        android:layout_width="@dimen/overlay_x_scale"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="@dimen/overlay_border_width"
+        android:layout_marginBottom="@dimen/overlay_border_width"
+        android:layout_gravity="center"
+        android:elevation="8dp"
+        android:contentDescription="@string/screenshot_edit_description"
+        android:scaleType="fitEnd"
+        android:background="@drawable/overlay_preview_background"
+        android:adjustViewBounds="true"
+        android:clickable="true"
+        app:layout_constraintStart_toStartOf="@id/screenshot_preview_border"
+        app:layout_constraintBottom_toBottomOf="@id/screenshot_preview_border"/>
+    <ImageView
+        android:id="@+id/screenshot_badge"
+        android:layout_width="56dp"
+        android:layout_height="56dp"
+        android:visibility="gone"
+        android:elevation="9dp"
+        app:layout_constraintBottom_toBottomOf="@id/screenshot_preview_border"
+        app:layout_constraintEnd_toEndOf="@id/screenshot_preview_border"/>
+    <FrameLayout
+        android:id="@+id/screenshot_dismiss_button"
+        android:layout_width="@dimen/overlay_dismiss_button_tappable_size"
+        android:layout_height="@dimen/overlay_dismiss_button_tappable_size"
+        android:elevation="11dp"
+        android:visibility="gone"
+        app:layout_constraintStart_toEndOf="@id/screenshot_preview"
+        app:layout_constraintEnd_toEndOf="@id/screenshot_preview"
+        app:layout_constraintTop_toTopOf="@id/screenshot_preview"
+        app:layout_constraintBottom_toTopOf="@id/screenshot_preview"
+        android:contentDescription="@string/screenshot_dismiss_description">
+        <ImageView
+            android:id="@+id/screenshot_dismiss_image"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:layout_margin="@dimen/overlay_dismiss_button_margin"
+            android:background="@drawable/circular_background"
+            android:backgroundTint="?androidprv:attr/materialColorPrimary"
+            android:tint="?androidprv:attr/materialColorOnPrimary"
+            android:padding="4dp"
+            android:src="@drawable/ic_close"/>
+    </FrameLayout>
+    <ImageView
+        android:id="@+id/screenshot_scrollable_preview"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:scaleType="matrix"
+        android:visibility="gone"
+        app:layout_constraintStart_toStartOf="@id/screenshot_preview"
+        app:layout_constraintTop_toTopOf="@id/screenshot_preview"
+        android:elevation="7dp"/>
+
+    <androidx.constraintlayout.widget.Guideline
+        android:id="@+id/guideline"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        app:layout_constraintGuide_end="0dp" />
+
+    <FrameLayout
+        android:id="@+id/screenshot_message_container"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/overlay_action_container_margin_horizontal"
+        android:layout_marginTop="4dp"
+        android:layout_marginBottom="@dimen/overlay_action_container_margin_bottom"
+        android:paddingHorizontal="@dimen/overlay_action_container_padding_end"
+        android:paddingVertical="@dimen/overlay_action_container_padding_vertical"
+        android:elevation="4dp"
+        android:background="@drawable/action_chip_container_background"
+        android:visibility="gone"
+        app:layout_constraintTop_toBottomOf="@id/guideline"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintWidth_max="450dp"
+        app:layout_constraintHorizontal_bias="0">
+        <include layout="@layout/screenshot_work_profile_first_run" />
+        <include layout="@layout/screenshot_detection_notice" />
+    </FrameLayout>
+</com.android.systemui.screenshot.ui.ScreenshotShelfView>
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index bf5eeb9..3029888 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -235,6 +235,8 @@
     <string name="screenshot_edit_label">Edit</string>
     <!-- Content description indicating that tapping the element will allow editing the screenshot [CHAR LIMIT=NONE] -->
     <string name="screenshot_edit_description">Edit screenshot</string>
+    <!-- Label for UI element which allows sharing the screenshot [CHAR LIMIT=30] -->
+    <string name="screenshot_share_label">Share</string>
     <!-- Content description indicating that tapping the element will allow sharing the screenshot [CHAR LIMIT=NONE] -->
     <string name="screenshot_share_description">Share screenshot</string>
     <!-- Label for UI element which allows the user to capture additional off-screen content in a screenshot. [CHAR LIMIT=30] -->
@@ -2011,6 +2013,10 @@
     <string name="system_multitasking_lhs">Enter split screen with current app to LHS</string>
     <!-- User visible title for the keyboard shortcut that switches from split screen to full screen [CHAR LIMIT=70] -->
     <string name="system_multitasking_full_screen">Switch from split screen to full screen</string>
+    <!-- User visible title for the keyboard shortcut that switches to app on right or below while using split screen [CHAR LIMIT=70] -->
+    <string name="system_multitasking_splitscreen_focus_rhs">Switch to app on right or below while using split screen</string>
+    <!-- User visible title for the keyboard shortcut that switches to app on left or above while using split screen [CHAR LIMIT=70] -->
+    <string name="system_multitasking_splitscreen_focus_lhs">Switch to app on left or above while using split screen</string>
     <!-- User visible title for the keyboard shortcut that replaces an app from one to another during split screen [CHAR LIMIT=70] -->
     <string name="system_multitasking_replace">During split screen: replace an app from one to another</string>
 
diff --git a/packages/SystemUI/src/com/android/keyguard/KeyguardClockSwitchController.java b/packages/SystemUI/src/com/android/keyguard/KeyguardClockSwitchController.java
index e621ffe..86f64f8 100644
--- a/packages/SystemUI/src/com/android/keyguard/KeyguardClockSwitchController.java
+++ b/packages/SystemUI/src/com/android/keyguard/KeyguardClockSwitchController.java
@@ -631,7 +631,7 @@
     @Nullable
     public ClockController getClock() {
         if (migrateClocksToBlueprint()) {
-            return mKeyguardClockInteractor.getClock();
+            return mKeyguardClockInteractor.getCurrentClock().getValue();
         } else {
             return mClockEventController.getClock();
         }
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/keyguard/data/repository/KeyguardClockRepository.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepository.kt
index 7ad5aac..7a56554 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepository.kt
@@ -113,7 +113,10 @@
 
     override val currentClock: StateFlow<ClockController?> =
         currentClockId
-            .map { clockRegistry.createCurrentClock() }
+            .map {
+                clockEventController.clock = clockRegistry.createCurrentClock()
+                clockEventController.clock
+            }
             .stateIn(
                 scope = applicationScope,
                 started = SharingStarted.WhileSubscribed(),
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt
index 9c68c45..a36bf8b 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardTransitionRepository.kt
@@ -119,24 +119,7 @@
     init {
         // Seed with transitions signaling a boot into lockscreen state. If updating this, please
         // also update FakeKeyguardTransitionRepository.
-        emitTransition(
-            TransitionStep(
-                KeyguardState.OFF,
-                KeyguardState.LOCKSCREEN,
-                0f,
-                TransitionState.STARTED,
-                KeyguardTransitionRepositoryImpl::class.simpleName!!,
-            )
-        )
-        emitTransition(
-            TransitionStep(
-                KeyguardState.OFF,
-                KeyguardState.LOCKSCREEN,
-                1f,
-                TransitionState.FINISHED,
-                KeyguardTransitionRepositoryImpl::class.simpleName!!,
-            )
-        )
+        initialTransitionSteps.forEach(::emitTransition)
     }
 
     override fun startTransition(info: TransitionInfo): UUID? {
@@ -256,5 +239,31 @@
 
     companion object {
         private const val TAG = "KeyguardTransitionRepository"
+
+        /**
+         * Transition steps to seed the repository with, so that all of the transition interactor
+         * flows emit reasonable initial values.
+         */
+        val initialTransitionSteps: List<TransitionStep> =
+            listOf(
+                TransitionStep(
+                    KeyguardState.OFF,
+                    KeyguardState.OFF,
+                    1f,
+                    TransitionState.FINISHED,
+                ),
+                TransitionStep(
+                    KeyguardState.OFF,
+                    KeyguardState.LOCKSCREEN,
+                    0f,
+                    TransitionState.STARTED,
+                ),
+                TransitionStep(
+                    KeyguardState.OFF,
+                    KeyguardState.LOCKSCREEN,
+                    1f,
+                    TransitionState.FINISHED,
+                ),
+            )
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromAodTransitionInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromAodTransitionInteractor.kt
index 9040e03..d09ee54 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromAodTransitionInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/FromAodTransitionInteractor.kt
@@ -252,5 +252,6 @@
         val TO_LOCKSCREEN_DURATION = 500.milliseconds
         val TO_GONE_DURATION = DEFAULT_DURATION
         val TO_OCCLUDED_DURATION = DEFAULT_DURATION
+        val TO_PRIMARY_BOUNCER_DURATION = DEFAULT_DURATION
     }
 }
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 7f752b4..1f24fc2 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,6 +231,7 @@
         private val DEFAULT_DURATION = 500.milliseconds
         val TO_GLANCEABLE_HUB_DURATION = 1.seconds
         val TO_LOCKSCREEN_DURATION = 1167.milliseconds
+        val TO_AOD_DURATION = 300.milliseconds
         val TO_GONE_DURATION = DEFAULT_DURATION
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractor.kt
index e6655ee..0cd7d18 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardTransitionInteractor.kt
@@ -91,11 +91,46 @@
         }
     }
 
+    val transitions = repository.transitions
+
+    /**
+     * A pair of the most recent STARTED step, and the transition step immediately preceding it. The
+     * transition framework enforces that the previous step is either a CANCELED or FINISHED step,
+     * and that the previous step was *to* the state the STARTED step is *from*.
+     *
+     * This flow can be used to access the previous step to determine whether it was CANCELED or
+     * FINISHED. In the case of a CANCELED step, we can also figure out which state we were coming
+     * from when we were canceled.
+     */
+    val startedStepWithPrecedingStep =
+        transitions
+            .pairwise()
+            .filter { it.newValue.transitionState == TransitionState.STARTED }
+            .shareIn(scope, SharingStarted.Eagerly)
+
     init {
+        // Collect non-canceled steps and emit transition values.
         scope.launch(mainDispatcher) {
-            repository.transitions.collect { step ->
-                getTransitionValueFlow(step.from).emit(1f - step.value)
-                getTransitionValueFlow(step.to).emit(step.value)
+            repository.transitions
+                .filter { it.transitionState != TransitionState.CANCELED }
+                .collect { step ->
+                    getTransitionValueFlow(step.from).emit(1f - step.value)
+                    getTransitionValueFlow(step.to).emit(step.value)
+                }
+        }
+
+        // If a transition from state A -> B is canceled in favor of a transition from B -> C, we
+        // need to ensure we emit transitionValue(A) = 0f, since no further steps will be emitted
+        // where the from or to states are A. This would leave transitionValue(A) stuck at an
+        // arbitrary non-zero value.
+        scope.launch(mainDispatcher) {
+            startedStepWithPrecedingStep.collect { (prevStep, startedStep) ->
+                if (
+                    prevStep.transitionState == TransitionState.CANCELED &&
+                        startedStep.to != prevStep.from
+                ) {
+                    getTransitionValueFlow(prevStep.from).emit(0f)
+                }
             }
         }
     }
@@ -202,8 +237,6 @@
     val dozingToLockscreenTransition: Flow<TransitionStep> =
         repository.transition(DOZING, LOCKSCREEN)
 
-    val transitions = repository.transitions
-
     /** Receive all [TransitionStep] matching a filter of [from]->[to] */
     fun transition(from: KeyguardState, to: KeyguardState): Flow<TransitionStep> {
         return repository.transition(from, to)
@@ -250,21 +283,6 @@
             .stateIn(scope, SharingStarted.Eagerly, DOZING)
 
     /**
-     * A pair of the most recent STARTED step, and the transition step immediately preceding it. The
-     * transition framework enforces that the previous step is either a CANCELED or FINISHED step,
-     * and that the previous step was *to* the state the STARTED step is *from*.
-     *
-     * This flow can be used to access the previous step to determine whether it was CANCELED or
-     * FINISHED. In the case of a CANCELED step, we can also figure out which state we were coming
-     * from when we were canceled.
-     */
-    val startedStepWithPrecedingStep =
-        transitions
-            .pairwise()
-            .filter { it.newValue.transitionState == TransitionState.STARTED }
-            .stateIn(scope, SharingStarted.Eagerly, null)
-
-    /**
      * The last [KeyguardState] to which we [TransitionState.FINISHED] a transition.
      *
      * WARNING: This will NOT emit a value if a transition is CANCELED, and will also not emit a
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardBlueprintViewBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardBlueprintViewBinder.kt
index 4812e03..89148b0 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardBlueprintViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardBlueprintViewBinder.kt
@@ -213,9 +213,10 @@
         cs: ConstraintSet,
         viewModel: KeyguardClockViewModel
     ) {
-        if (!DEBUG || viewModel.clock == null) return
+        val currentClock = viewModel.currentClock.value
+        if (!DEBUG || currentClock == null) return
         val smallClockViewId = R.id.lockscreen_clock_view
-        val largeClockViewId = viewModel.clock!!.largeClock.layout.views[0].id
+        val largeClockViewId = currentClock.largeClock.layout.views[0].id
         Log.i(
             TAG,
             "applyCsToSmallClock: vis=${cs.getVisibility(smallClockViewId)} " +
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinder.kt
index 01596ed..fa1fe5e 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinder.kt
@@ -19,6 +19,7 @@
 import android.transition.TransitionManager
 import android.transition.TransitionSet
 import android.view.View.INVISIBLE
+import android.view.ViewGroup
 import androidx.annotation.VisibleForTesting
 import androidx.constraintlayout.helper.widget.Layer
 import androidx.constraintlayout.widget.ConstraintLayout
@@ -40,7 +41,8 @@
 
 object KeyguardClockViewBinder {
     private val TAG = KeyguardClockViewBinder::class.simpleName!!
-
+    // When changing to new clock, we need to remove old clock views from burnInLayer
+    private var lastClock: ClockController? = null
     @JvmStatic
     fun bind(
         clockSection: ClockSection,
@@ -55,12 +57,11 @@
             }
         }
         keyguardRootView.repeatWhenAttached {
-            repeatOnLifecycle(Lifecycle.State.STARTED) {
+            repeatOnLifecycle(Lifecycle.State.CREATED) {
                 launch {
                     if (!migrateClocksToBlueprint()) return@launch
                     viewModel.currentClock.collect { currentClock ->
-                        cleanupClockViews(viewModel.clock, keyguardRootView, viewModel.burnInLayer)
-                        viewModel.clock = currentClock
+                        cleanupClockViews(currentClock, keyguardRootView, viewModel.burnInLayer)
                         addClockViews(currentClock, keyguardRootView)
                         updateBurnInLayer(keyguardRootView, viewModel)
                         applyConstraints(clockSection, keyguardRootView, true)
@@ -76,7 +77,7 @@
                 launch {
                     if (!migrateClocksToBlueprint()) return@launch
                     viewModel.clockShouldBeCentered.collect { clockShouldBeCentered ->
-                        viewModel.clock?.let {
+                        viewModel.currentClock.value?.let {
                             // Weather clock also has hasCustomPositionUpdatedAnimation as true
                             // TODO(b/323020908): remove ID check
                             if (
@@ -93,7 +94,7 @@
                 launch {
                     if (!migrateClocksToBlueprint()) return@launch
                     viewModel.isAodIconsVisible.collect { isAodIconsVisible ->
-                        viewModel.clock?.let {
+                        viewModel.currentClock.value?.let {
                             // Weather clock also has hasCustomPositionUpdatedAnimation as true
                             if (
                                 viewModel.useLargeClock && it.config.id == "DIGITAL_CLOCK_WEATHER"
@@ -132,11 +133,14 @@
     }
 
     private fun cleanupClockViews(
-        clockController: ClockController?,
+        currentClock: ClockController?,
         rootView: ConstraintLayout,
         burnInLayer: Layer?
     ) {
-        clockController?.let { clock ->
+        if (lastClock == currentClock) {
+            return
+        }
+        lastClock?.let { clock ->
             clock.smallClock.layout.views.forEach {
                 burnInLayer?.removeView(it)
                 rootView.removeView(it)
@@ -150,6 +154,7 @@
             }
             clock.largeClock.layout.views.forEach { rootView.removeView(it) }
         }
+        lastClock = currentClock
     }
 
     @VisibleForTesting
@@ -157,11 +162,19 @@
         clockController: ClockController?,
         rootView: ConstraintLayout,
     ) {
+        // We'll collect the same clock when exiting wallpaper picker without changing clock
+        // so we need to remove clock views from parent before addView again
         clockController?.let { clock ->
             clock.smallClock.layout.views.forEach {
+                if (it.parent != null) {
+                    (it.parent as ViewGroup).removeView(it)
+                }
                 rootView.addView(it).apply { it.visibility = INVISIBLE }
             }
             clock.largeClock.layout.views.forEach {
+                if (it.parent != null) {
+                    (it.parent as ViewGroup).removeView(it)
+                }
                 rootView.addView(it).apply { it.visibility = INVISIBLE }
             }
         }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardSmartspaceViewBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardSmartspaceViewBinder.kt
index b77f0c5..4d0a25f 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardSmartspaceViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardSmartspaceViewBinder.kt
@@ -41,7 +41,7 @@
         blueprintInteractor: KeyguardBlueprintInteractor,
     ) {
         keyguardRootView.repeatWhenAttached {
-            repeatOnLifecycle(Lifecycle.State.STARTED) {
+            repeatOnLifecycle(Lifecycle.State.CREATED) {
                 launch {
                     if (!migrateClocksToBlueprint()) return@launch
                     clockViewModel.hasCustomWeatherDataDisplay.collect { hasCustomWeatherDataDisplay
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/transitions/DeviceEntryIconTransitionModule.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/transitions/DeviceEntryIconTransitionModule.kt
index f20c4ac..3b21141 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/transitions/DeviceEntryIconTransitionModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/transitions/DeviceEntryIconTransitionModule.kt
@@ -22,10 +22,12 @@
 import com.android.systemui.keyguard.ui.viewmodel.AodToGoneTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.AodToLockscreenTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.AodToOccludedTransitionViewModel
+import com.android.systemui.keyguard.ui.viewmodel.AodToPrimaryBouncerTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.DozingToGoneTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.DozingToLockscreenTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.DozingToOccludedTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.DozingToPrimaryBouncerTransitionViewModel
+import com.android.systemui.keyguard.ui.viewmodel.DreamingToAodTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.GoneToAodTransitionViewModel
 import com.android.systemui.keyguard.ui.viewmodel.GoneToDozingTransitionViewModel
@@ -89,6 +91,12 @@
 
     @Binds
     @IntoSet
+    abstract fun aodToPrimaryBouncer(
+        impl: AodToPrimaryBouncerTransitionViewModel
+    ): DeviceEntryIconTransition
+
+    @Binds
+    @IntoSet
     abstract fun dozingToGone(impl: DozingToGoneTransitionViewModel): DeviceEntryIconTransition
 
     @Binds
@@ -111,6 +119,10 @@
 
     @Binds
     @IntoSet
+    abstract fun dreamingToAod(impl: DreamingToAodTransitionViewModel): DeviceEntryIconTransition
+
+    @Binds
+    @IntoSet
     abstract fun dreamingToLockscreen(
         impl: DreamingToLockscreenTransitionViewModel
     ): DeviceEntryIconTransition
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/BaseBlueprintTransition.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/BaseBlueprintTransition.kt
index 9c9df80..a215efa 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/BaseBlueprintTransition.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/BaseBlueprintTransition.kt
@@ -41,7 +41,7 @@
 
     private fun excludeClockAndSmartspaceViews(transition: Transition) {
         transition.excludeTarget(SmartspaceView::class.java, true)
-        clockViewModel.clock?.let { clock ->
+        clockViewModel.currentClock.value?.let { clock ->
             clock.largeClock.layout.views.forEach { view -> transition.excludeTarget(view, true) }
             clock.smallClock.layout.views.forEach { view -> transition.excludeTarget(view, true) }
         }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/IntraBlueprintTransition.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/IntraBlueprintTransition.kt
index 3adeb2a..c69d868 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/IntraBlueprintTransition.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/blueprints/transitions/IntraBlueprintTransition.kt
@@ -57,7 +57,9 @@
         when (config.type) {
             Type.NoTransition -> {}
             Type.DefaultClockStepping ->
-                addTransition(clockViewModel.clock?.let { DefaultClockSteppingTransition(it) })
+                addTransition(
+                    clockViewModel.currentClock.value?.let { DefaultClockSteppingTransition(it) }
+                )
             else -> addTransition(ClockSizeTransition(config, clockViewModel, smartspaceViewModel))
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/ClockSection.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/ClockSection.kt
index a183b72..7847c1c 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/ClockSection.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/ClockSection.kt
@@ -86,7 +86,7 @@
         if (!Flags.migrateClocksToBlueprint()) {
             return
         }
-        clockInteractor.clock?.let { clock ->
+        keyguardClockViewModel.currentClock.value?.let { clock ->
             constraintSet.applyDeltaFrom(buildConstraints(clock, constraintSet))
         }
     }
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/view/layout/sections/transitions/ClockSizeTransition.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/transitions/ClockSizeTransition.kt
index 6184c82..4d3a78d 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/transitions/ClockSizeTransition.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/view/layout/sections/transitions/ClockSizeTransition.kt
@@ -216,7 +216,9 @@
             captureSmartspace = !viewModel.useLargeClock && smartspaceViewModel.isSmartspaceEnabled
 
             if (viewModel.useLargeClock) {
-                viewModel.clock?.let { it.largeClock.layout.views.forEach { addTarget(it) } }
+                viewModel.currentClock.value?.let {
+                    it.largeClock.layout.views.forEach { addTarget(it) }
+                }
             } else {
                 addTarget(R.id.lockscreen_clock_view)
             }
@@ -276,7 +278,9 @@
             if (viewModel.useLargeClock) {
                 addTarget(R.id.lockscreen_clock_view)
             } else {
-                viewModel.clock?.let { it.largeClock.layout.views.forEach { addTarget(it) } }
+                viewModel.currentClock.value?.let {
+                    it.largeClock.layout.views.forEach { addTarget(it) }
+                }
             }
         }
 
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModel.kt
index d26356e..ac2713d 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AlternateBouncerToGoneTransitionViewModel.kt
@@ -16,6 +16,7 @@
 
 package com.android.systemui.keyguard.ui.viewmodel
 
+import android.util.MathUtils
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.keyguard.domain.interactor.FromAlternateBouncerTransitionInteractor.Companion.TO_GONE_DURATION
 import com.android.systemui.keyguard.shared.model.KeyguardState
@@ -47,13 +48,16 @@
             to = KeyguardState.GONE,
         )
 
-    val lockscreenAlpha: Flow<Float> =
-        transitionAnimation.sharedFlow(
+    fun lockscreenAlpha(viewState: ViewStateAccessor): Flow<Float> {
+        var startAlpha = 1f
+        return transitionAnimation.sharedFlow(
             duration = 200.milliseconds,
-            onStep = { 1 - it },
+            onStart = { startAlpha = viewState.alpha() },
+            onStep = { MathUtils.lerp(startAlpha, 0f, it) },
             onFinish = { 0f },
-            onCancel = { 1f },
+            onCancel = { startAlpha },
         )
+    }
 
     /** Scrim alpha values */
     val scrimAlpha: Flow<ScrimAlpha> =
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodBurnInViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodBurnInViewModel.kt
index f961e08..9c1f077 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodBurnInViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodBurnInViewModel.kt
@@ -169,7 +169,7 @@
         provider: Provider<ClockController>?,
     ): Provider<ClockController>? {
         return if (Flags.migrateClocksToBlueprint()) {
-            Provider { keyguardClockViewModel.clock }
+            Provider { keyguardClockViewModel.currentClock.value }
         } else {
             provider
         }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodToPrimaryBouncerTransitionViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodToPrimaryBouncerTransitionViewModel.kt
new file mode 100644
index 0000000..9a23007
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/AodToPrimaryBouncerTransitionViewModel.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.FromAodTransitionInteractor
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.keyguard.ui.KeyguardTransitionAnimationFlow
+import com.android.systemui.keyguard.ui.transitions.DeviceEntryIconTransition
+import javax.inject.Inject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Breaks down AOD->PRIMARY BOUNCER transition into discrete steps for corresponding views to
+ * consume.
+ */
+@ExperimentalCoroutinesApi
+@SysUISingleton
+class AodToPrimaryBouncerTransitionViewModel
+@Inject
+constructor(
+    animationFlow: KeyguardTransitionAnimationFlow,
+) : DeviceEntryIconTransition {
+    private val transitionAnimation =
+        animationFlow.setup(
+            duration = FromAodTransitionInteractor.TO_PRIMARY_BOUNCER_DURATION,
+            from = KeyguardState.AOD,
+            to = KeyguardState.PRIMARY_BOUNCER,
+        )
+
+    override val deviceEntryParentViewAlpha: Flow<Float> =
+        transitionAnimation.immediatelyTransitionTo(0f)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryBackgroundViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryBackgroundViewModel.kt
index 4c0a949..1b91c49 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryBackgroundViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryBackgroundViewModel.kt
@@ -55,6 +55,8 @@
     lockscreenToDozingTransitionViewModel: LockscreenToDozingTransitionViewModel,
     dozingToLockscreenTransitionViewModel: DozingToLockscreenTransitionViewModel,
     alternateBouncerToDozingTransitionViewModel: AlternateBouncerToDozingTransitionViewModel,
+    dreamingToAodTransitionViewModel: DreamingToAodTransitionViewModel,
+    primaryBouncerToLockscreenTransitionViewModel: PrimaryBouncerToLockscreenTransitionViewModel,
 ) {
     val color: Flow<Int> =
         deviceEntryIconViewModel.useBackgroundProtection.flatMapLatest { useBackground ->
@@ -96,6 +98,9 @@
                         lockscreenToDozingTransitionViewModel.deviceEntryBackgroundViewAlpha,
                         dozingToLockscreenTransitionViewModel.deviceEntryBackgroundViewAlpha,
                         alternateBouncerToDozingTransitionViewModel.deviceEntryBackgroundViewAlpha,
+                        dreamingToAodTransitionViewModel.deviceEntryBackgroundViewAlpha,
+                        primaryBouncerToLockscreenTransitionViewModel
+                            .deviceEntryBackgroundViewAlpha,
                     )
                     .merge()
                     .onStart {
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModel.kt
index 1a01897..bc4fd1c 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModel.kt
@@ -19,6 +19,7 @@
 import android.animation.FloatEvaluator
 import android.animation.IntEvaluator
 import com.android.keyguard.KeyguardViewController
+import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
 import com.android.systemui.deviceentry.domain.interactor.DeviceEntrySourceInteractor
 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryUdfpsInteractor
@@ -33,9 +34,11 @@
 import com.android.systemui.util.kotlin.sample
 import dagger.Lazy
 import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
@@ -45,6 +48,7 @@
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.shareIn
 
 /** Models the UI state for the containing device entry icon & long-press handling view. */
 @ExperimentalCoroutinesApi
@@ -62,6 +66,7 @@
     private val keyguardViewController: Lazy<KeyguardViewController>,
     private val deviceEntryInteractor: DeviceEntryInteractor,
     private val deviceEntrySourceInteractor: DeviceEntrySourceInteractor,
+    @Application private val scope: CoroutineScope,
 ) {
     val isUdfpsSupported: StateFlow<Boolean> = deviceEntryUdfpsInteractor.isUdfpsSupported
     private val intEvaluator = IntEvaluator()
@@ -73,7 +78,10 @@
     private val qsProgress: Flow<Float> = shadeInteractor.qsExpansion.onStart { emit(0f) }
     private val shadeExpansion: Flow<Float> = shadeInteractor.shadeExpansion.onStart { emit(0f) }
     private val transitionAlpha: Flow<Float> =
-        transitions.map { it.deviceEntryParentViewAlpha }.merge()
+        transitions
+            .map { it.deviceEntryParentViewAlpha }
+            .merge()
+            .shareIn(scope, SharingStarted.WhileSubscribed())
     private val alphaMultiplierFromShadeExpansion: Flow<Float> =
         combine(
             showingAlternateBouncer,
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToAodTransitionViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToAodTransitionViewModel.kt
new file mode 100644
index 0000000..0fa7475
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/DreamingToAodTransitionViewModel.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.deviceentry.domain.interactor.DeviceEntryUdfpsInteractor
+import com.android.systemui.keyguard.domain.interactor.FromDreamingTransitionInteractor
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.keyguard.ui.KeyguardTransitionAnimationFlow
+import com.android.systemui.keyguard.ui.transitions.DeviceEntryIconTransition
+import javax.inject.Inject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+
+/** Breaks down DREAMING->AOD transition into discrete steps for corresponding views to consume. */
+@ExperimentalCoroutinesApi
+@SysUISingleton
+class DreamingToAodTransitionViewModel
+@Inject
+constructor(
+    deviceEntryUdfpsInteractor: DeviceEntryUdfpsInteractor,
+    animationFlow: KeyguardTransitionAnimationFlow,
+) : DeviceEntryIconTransition {
+    private val transitionAnimation =
+        animationFlow.setup(
+            duration = FromDreamingTransitionInteractor.TO_AOD_DURATION,
+            from = KeyguardState.DREAMING,
+            to = KeyguardState.AOD,
+        )
+
+    val deviceEntryBackgroundViewAlpha: Flow<Float> =
+        transitionAnimation.immediatelyTransitionTo(0f)
+    override val deviceEntryParentViewAlpha: Flow<Float> =
+        deviceEntryUdfpsInteractor.isUdfpsEnrolledAndEnabled.flatMapLatest { udfpsEnrolledAndEnabled
+            ->
+            if (udfpsEnrolledAndEnabled) {
+                transitionAnimation.sharedFlow(
+                    duration = FromDreamingTransitionInteractor.TO_AOD_DURATION,
+                    onStep = { it },
+                    onFinish = { 1f },
+                )
+            } else {
+                emptyFlow()
+            }
+        }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardClockViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardClockViewModel.kt
index b6622e5..1c1c33a 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardClockViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardClockViewModel.kt
@@ -26,7 +26,6 @@
 import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
 import com.android.systemui.keyguard.shared.ComposeLockscreen
 import com.android.systemui.keyguard.shared.model.SettingsClockSize
-import com.android.systemui.plugins.clocks.ClockController
 import com.android.systemui.res.R
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.shade.shared.model.ShadeMode
@@ -54,8 +53,6 @@
     val useLargeClock: Boolean
         get() = clockSize.value == LARGE
 
-    var clock: ClockController? by keyguardClockInteractor::clock
-
     val clockSize =
         combine(keyguardClockInteractor.selectedClockSize, keyguardClockInteractor.clockSize) {
                 selectedSize,
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 301f00e..b662109 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
@@ -205,7 +205,7 @@
                 merge(
                         alphaOnShadeExpansion,
                         keyguardInteractor.dismissAlpha.filterNotNull(),
-                        alternateBouncerToGoneTransitionViewModel.lockscreenAlpha,
+                        alternateBouncerToGoneTransitionViewModel.lockscreenAlpha(viewState),
                         aodToGoneTransitionViewModel.lockscreenAlpha(viewState),
                         aodToLockscreenTransitionViewModel.lockscreenAlpha(viewState),
                         aodToOccludedTransitionViewModel.lockscreenAlpha(viewState),
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModel.kt
index 34c9ac9..2575041 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModel.kt
@@ -18,7 +18,6 @@
 
 import com.android.app.animation.Interpolators.EMPHASIZED_ACCELERATE
 import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.deviceentry.domain.interactor.DeviceEntryUdfpsInteractor
 import com.android.systemui.keyguard.domain.interactor.FromPrimaryBouncerTransitionInteractor
 import com.android.systemui.keyguard.shared.model.KeyguardState
 import com.android.systemui.keyguard.ui.KeyguardTransitionAnimationFlow
@@ -27,8 +26,6 @@
 import kotlin.time.Duration.Companion.milliseconds
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flatMapLatest
 
 /**
  * Breaks down PRIMARY BOUNCER->LOCKSCREEN transition into discrete steps for corresponding views to
@@ -39,7 +36,6 @@
 class PrimaryBouncerToLockscreenTransitionViewModel
 @Inject
 constructor(
-    deviceEntryUdfpsInteractor: DeviceEntryUdfpsInteractor,
     animationFlow: KeyguardTransitionAnimationFlow,
 ) : DeviceEntryIconTransition {
     private val transitionAnimation =
@@ -49,15 +45,6 @@
             to = KeyguardState.LOCKSCREEN,
         )
 
-    val deviceEntryBackgroundViewAlpha: Flow<Float> =
-        deviceEntryUdfpsInteractor.isUdfpsSupported.flatMapLatest { isUdfps ->
-            if (isUdfps) {
-                transitionAnimation.immediatelyTransitionTo(1f)
-            } else {
-                emptyFlow()
-            }
-        }
-
     val shortcutsAlpha: Flow<Float> =
         transitionAnimation.sharedFlow(
             duration = 250.milliseconds,
@@ -67,6 +54,8 @@
 
     val lockscreenAlpha: Flow<Float> = shortcutsAlpha
 
+    val deviceEntryBackgroundViewAlpha: Flow<Float> =
+        transitionAnimation.immediatelyTransitionTo(1f)
     override val deviceEntryParentViewAlpha: Flow<Float> =
         transitionAnimation.immediatelyTransitionTo(1f)
 }
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaDataRepository.kt b/packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaDataRepository.kt
new file mode 100644
index 0000000..b6fd287
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaDataRepository.kt
@@ -0,0 +1,123 @@
+/*
+ * 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.media.controls.data.repository
+
+import android.util.Log
+import com.android.systemui.Dumpable
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.media.controls.util.MediaFlags
+import java.io.PrintWriter
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+private const val TAG = "MediaDataRepository"
+private const val DEBUG = true
+
+/** A repository that holds the state of all media controls in carousel. */
+@SysUISingleton
+class MediaDataRepository
+@Inject
+constructor(
+    private val mediaFlags: MediaFlags,
+    dumpManager: DumpManager,
+) : Dumpable {
+
+    private val _mediaEntries: MutableStateFlow<Map<String, MediaData>> =
+        MutableStateFlow(LinkedHashMap())
+    val mediaEntries: StateFlow<Map<String, MediaData>> = _mediaEntries.asStateFlow()
+
+    private val _smartspaceMediaData: MutableStateFlow<SmartspaceMediaData> =
+        MutableStateFlow(SmartspaceMediaData())
+    val smartspaceMediaData: StateFlow<SmartspaceMediaData> = _smartspaceMediaData.asStateFlow()
+
+    init {
+        dumpManager.registerNormalDumpable(TAG, this)
+    }
+
+    /** Updates the recommendation data with a new smartspace media data. */
+    fun setRecommendation(recommendation: SmartspaceMediaData) {
+        _smartspaceMediaData.value = recommendation
+    }
+
+    /**
+     * Marks the recommendation data as inactive.
+     *
+     * @return true if the recommendation was actually marked as inactive, false otherwise.
+     */
+    fun setRecommendationInactive(key: String): Boolean {
+        if (!mediaFlags.isPersistentSsCardEnabled()) {
+            Log.e(TAG, "Only persistent recommendation can be inactive!")
+            return false
+        }
+        if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive")
+
+        if (smartspaceMediaData.value.targetId != key || !smartspaceMediaData.value.isValid()) {
+            // If this doesn't match, or we've already invalidated the data, no action needed
+            return false
+        }
+
+        setRecommendation(smartspaceMediaData.value.copy(isActive = false))
+        return true
+    }
+
+    /**
+     * Marks the recommendation data as dismissed.
+     *
+     * @return true if the recommendation was dismissed or already inactive, false otherwise.
+     */
+    fun dismissSmartspaceRecommendation(key: String): Boolean {
+        val data = smartspaceMediaData.value
+        if (data.targetId != key || !data.isValid()) {
+            // If this doesn't match, or we've already invalidated the data, no action needed
+            return false
+        }
+
+        if (DEBUG) Log.d(TAG, "Dismissing Smartspace media target")
+        if (data.isActive) {
+            setRecommendation(
+                SmartspaceMediaData(
+                    targetId = smartspaceMediaData.value.targetId,
+                    instanceId = smartspaceMediaData.value.instanceId
+                )
+            )
+        }
+        return true
+    }
+
+    fun removeMediaEntry(key: String): MediaData? {
+        val entries = LinkedHashMap<String, MediaData>(_mediaEntries.value)
+        val mediaData = entries.remove(key)
+        _mediaEntries.value = entries
+        return mediaData
+    }
+
+    fun addMediaEntry(key: String, data: MediaData): MediaData? {
+        val entries = LinkedHashMap<String, MediaData>(_mediaEntries.value)
+        val mediaData = entries.put(key, data)
+        _mediaEntries.value = entries
+        return mediaData
+    }
+
+    override fun dump(pw: PrintWriter, args: Array<out String>) {
+        pw.apply { println("mediaEntries: ${mediaEntries.value}") }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaFilterRepository.kt b/packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaFilterRepository.kt
new file mode 100644
index 0000000..b94a4af
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaFilterRepository.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.media.controls.data.repository
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** A repository that holds the state of filtered media data on the device. */
+@SysUISingleton
+class MediaFilterRepository @Inject constructor() {
+
+    /** Key of media control that recommendations card reactivated. */
+    private val _reactivatedKey: MutableStateFlow<String?> = MutableStateFlow(null)
+    val reactivatedKey: StateFlow<String?> = _reactivatedKey.asStateFlow()
+
+    private val _smartspaceMediaData: MutableStateFlow<SmartspaceMediaData> =
+        MutableStateFlow(SmartspaceMediaData())
+    val smartspaceMediaData: StateFlow<SmartspaceMediaData> = _smartspaceMediaData.asStateFlow()
+
+    private val _selectedUserEntries: MutableStateFlow<Map<String, MediaData>> =
+        MutableStateFlow(LinkedHashMap())
+    val selectedUserEntries: StateFlow<Map<String, MediaData>> = _selectedUserEntries.asStateFlow()
+
+    private val _allUserEntries: MutableStateFlow<Map<String, MediaData>> =
+        MutableStateFlow(LinkedHashMap())
+    val allUserEntries: StateFlow<Map<String, MediaData>> = _allUserEntries.asStateFlow()
+
+    fun addMediaEntry(key: String, data: MediaData) {
+        val entries = LinkedHashMap<String, MediaData>(_allUserEntries.value)
+        entries[key] = data
+        _allUserEntries.value = entries
+    }
+
+    /**
+     * Removes the media entry corresponding to the given [key].
+     *
+     * @return media data if an entry is actually removed, `null` otherwise.
+     */
+    fun removeMediaEntry(key: String): MediaData? {
+        val entries = LinkedHashMap<String, MediaData>(_allUserEntries.value)
+        val mediaData = entries.remove(key)
+        _allUserEntries.value = entries
+        return mediaData
+    }
+
+    fun addSelectedUserMediaEntry(key: String, data: MediaData) {
+        val entries = LinkedHashMap<String, MediaData>(_selectedUserEntries.value)
+        entries[key] = data
+        _selectedUserEntries.value = entries
+    }
+
+    /**
+     * Removes selected user media entry given the corresponding key.
+     *
+     * @return media data if an entry is actually removed, `null` otherwise.
+     */
+    fun removeSelectedUserMediaEntry(key: String): MediaData? {
+        val entries = LinkedHashMap<String, MediaData>(_selectedUserEntries.value)
+        val mediaData = entries.remove(key)
+        _selectedUserEntries.value = entries
+        return mediaData
+    }
+
+    /**
+     * Removes selected user media entry given a key and media data.
+     *
+     * @return true if media data is removed, false otherwise.
+     */
+    fun removeSelectedUserMediaEntry(key: String, data: MediaData): Boolean {
+        val entries = LinkedHashMap<String, MediaData>(_selectedUserEntries.value)
+        val succeed = entries.remove(key, data)
+        if (!succeed) {
+            return false
+        }
+        _selectedUserEntries.value = entries
+        return true
+    }
+
+    fun clearSelectedUserMedia() {
+        _selectedUserEntries.value = LinkedHashMap()
+    }
+
+    /** Updates recommendation data with a new smartspace media data. */
+    fun setRecommendation(smartspaceMediaData: SmartspaceMediaData) {
+        _smartspaceMediaData.value = smartspaceMediaData
+    }
+
+    /** Updates media control key that recommendations card reactivated. */
+    fun setReactivatedKey(key: String?) {
+        _reactivatedKey.value = key
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/MediaDomainModule.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/MediaDomainModule.kt
new file mode 100644
index 0000000..e0c5419
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/MediaDomainModule.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.media.controls.domain
+
+import com.android.systemui.CoreStartable
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.media.controls.domain.pipeline.LegacyMediaDataManagerImpl
+import com.android.systemui.media.controls.domain.pipeline.MediaDataManager
+import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor
+import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor
+import com.android.systemui.media.controls.util.MediaFlags
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.multibindings.ClassKey
+import dagger.multibindings.IntoMap
+import javax.inject.Provider
+
+/** Dagger module for injecting media controls domain interfaces. */
+@Module
+interface MediaDomainModule {
+
+    @Binds
+    @IntoMap
+    @ClassKey(MediaCarouselInteractor::class)
+    fun bindMediaCarouselInteractor(interactor: MediaCarouselInteractor): CoreStartable
+
+    @Binds
+    @IntoMap
+    @ClassKey(MediaDataProcessor::class)
+    fun bindMediaDataProcessor(interactor: MediaDataProcessor): CoreStartable
+    companion object {
+
+        @Provides
+        @SysUISingleton
+        fun providesMediaDataManager(
+            legacyProvider: Provider<LegacyMediaDataManagerImpl>,
+            newProvider: Provider<MediaCarouselInteractor>,
+            mediaFlags: MediaFlags,
+        ): MediaDataManager {
+            return if (mediaFlags.isMediaControlsRefactorEnabled()) {
+                newProvider.get()
+            } else {
+                legacyProvider.get()
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilter.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataFilterImpl.kt
similarity index 98%
rename from packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilter.kt
rename to packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataFilterImpl.kt
index bc539ef..c02478b 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilter.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataFilterImpl.kt
@@ -61,7 +61,7 @@
  * This is added at the end of the pipeline since we may still need to handle callbacks from
  * background users (e.g. timeouts).
  */
-class MediaDataFilter
+class LegacyMediaDataFilterImpl
 @Inject
 constructor(
     private val context: Context,
@@ -74,9 +74,9 @@
     private val mediaFlags: MediaFlags,
 ) : MediaDataManager.Listener {
     private val _listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
-    internal val listeners: Set<MediaDataManager.Listener>
+    val listeners: Set<MediaDataManager.Listener>
         get() = _listeners.toSet()
-    internal lateinit var mediaDataManager: MediaDataManager
+    lateinit var mediaDataManager: MediaDataManager
 
     private val allEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
     // The filtered userEntries, which will be a subset of all userEntries in MediaDataManager
@@ -279,7 +279,7 @@
         val mediaKeys = userEntries.keys.toSet()
         mediaKeys.forEach {
             // Force updates to listeners, needed for re-activated card
-            mediaDataManager.setTimedOut(it, timedOut = true, forceUpdate = true)
+            mediaDataManager.setInactive(it, timedOut = true, forceUpdate = true)
         }
         if (smartspaceMediaData.isActive) {
             val dismissIntent = smartspaceMediaData.dismissIntent
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt
new file mode 100644
index 0000000..3a831156
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt
@@ -0,0 +1,1693 @@
+/*
+ * Copyright (C) 2020 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.media.controls.domain.pipeline
+
+import android.annotation.SuppressLint
+import android.app.ActivityOptions
+import android.app.BroadcastOptions
+import android.app.Notification
+import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME
+import android.app.PendingIntent
+import android.app.StatusBarManager
+import android.app.UriGrantsManager
+import android.app.smartspace.SmartspaceAction
+import android.app.smartspace.SmartspaceConfig
+import android.app.smartspace.SmartspaceManager
+import android.app.smartspace.SmartspaceSession
+import android.app.smartspace.SmartspaceTarget
+import android.content.BroadcastReceiver
+import android.content.ContentProvider
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.Icon
+import android.media.MediaDescription
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.net.Uri
+import android.os.Parcelable
+import android.os.Process
+import android.os.UserHandle
+import android.provider.Settings
+import android.service.notification.StatusBarNotification
+import android.support.v4.media.MediaMetadataCompat
+import android.text.TextUtils
+import android.util.Log
+import android.util.Pair as APair
+import androidx.media.utils.MediaConstants
+import com.android.app.tracing.traceSection
+import com.android.internal.annotations.Keep
+import com.android.internal.logging.InstanceId
+import com.android.keyguard.KeyguardUpdateMonitor
+import com.android.systemui.Dumpable
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.media.controls.domain.pipeline.MediaDataManager.Companion.isMediaNotification
+import com.android.systemui.media.controls.domain.resume.MediaResumeListener
+import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser
+import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_SOURCE
+import com.android.systemui.media.controls.shared.model.EXTRA_VALUE_TRIGGER_PERIODIC
+import com.android.systemui.media.controls.shared.model.MediaAction
+import com.android.systemui.media.controls.shared.model.MediaButton
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.MediaDeviceData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaDataProvider
+import com.android.systemui.media.controls.ui.view.MediaViewHolder
+import com.android.systemui.media.controls.util.MediaControllerFactory
+import com.android.systemui.media.controls.util.MediaDataUtils
+import com.android.systemui.media.controls.util.MediaFlags
+import com.android.systemui.media.controls.util.MediaUiEventLogger
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.plugins.BcSmartspaceDataPlugin
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.NotificationMediaManager.isConnectingState
+import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
+import com.android.systemui.statusbar.notification.row.HybridGroupManager
+import com.android.systemui.tuner.TunerService
+import com.android.systemui.util.Assert
+import com.android.systemui.util.Utils
+import com.android.systemui.util.concurrency.DelayableExecutor
+import com.android.systemui.util.concurrency.ThreadFactory
+import com.android.systemui.util.time.SystemClock
+import java.io.IOException
+import java.io.PrintWriter
+import java.util.concurrent.Executor
+import javax.inject.Inject
+
+// URI fields to try loading album art from
+private val ART_URIS =
+    arrayOf(
+        MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
+        MediaMetadata.METADATA_KEY_ART_URI,
+        MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI
+    )
+
+private const val TAG = "MediaDataManager"
+private const val DEBUG = true
+private const val EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY = "dismiss_intent"
+
+private val LOADING =
+    MediaData(
+        userId = -1,
+        initialized = false,
+        app = null,
+        appIcon = null,
+        artist = null,
+        song = null,
+        artwork = null,
+        actions = emptyList(),
+        actionsToShowInCompact = emptyList(),
+        packageName = "INVALID",
+        token = null,
+        clickIntent = null,
+        device = null,
+        active = true,
+        resumeAction = null,
+        instanceId = InstanceId.fakeInstanceId(-1),
+        appUid = Process.INVALID_UID
+    )
+
+internal val EMPTY_SMARTSPACE_MEDIA_DATA =
+    SmartspaceMediaData(
+        targetId = "INVALID",
+        isActive = false,
+        packageName = "INVALID",
+        cardAction = null,
+        recommendations = emptyList(),
+        dismissIntent = null,
+        headphoneConnectionTimeMillis = 0,
+        instanceId = InstanceId.fakeInstanceId(-1),
+        expiryTimeMs = 0,
+    )
+
+const val MEDIA_TITLE_ERROR_MESSAGE = "Invalid media data: title is null or blank."
+
+/**
+ * Allow recommendations from smartspace to show in media controls. Requires
+ * [Utils.useQsMediaPlayer] to be enabled. On by default, but can be disabled by setting to 0
+ */
+private fun allowMediaRecommendations(context: Context): Boolean {
+    val flag =
+        Settings.Secure.getInt(
+            context.contentResolver,
+            Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
+            1
+        )
+    return Utils.useQsMediaPlayer(context) && flag > 0
+}
+
+/** A class that facilitates management and loading of Media Data, ready for binding. */
+@SysUISingleton
+class LegacyMediaDataManagerImpl(
+    private val context: Context,
+    @Background private val backgroundExecutor: Executor,
+    @Main private val uiExecutor: Executor,
+    @Main private val foregroundExecutor: DelayableExecutor,
+    private val mediaControllerFactory: MediaControllerFactory,
+    private val broadcastDispatcher: BroadcastDispatcher,
+    dumpManager: DumpManager,
+    mediaTimeoutListener: MediaTimeoutListener,
+    mediaResumeListener: MediaResumeListener,
+    mediaSessionBasedFilter: MediaSessionBasedFilter,
+    private val mediaDeviceManager: MediaDeviceManager,
+    mediaDataCombineLatest: MediaDataCombineLatest,
+    private val mediaDataFilter: LegacyMediaDataFilterImpl,
+    private val activityStarter: ActivityStarter,
+    private val smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
+    private var useMediaResumption: Boolean,
+    private val useQsMediaPlayer: Boolean,
+    private val systemClock: SystemClock,
+    private val tunerService: TunerService,
+    private val mediaFlags: MediaFlags,
+    private val logger: MediaUiEventLogger,
+    private val smartspaceManager: SmartspaceManager?,
+    private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
+) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener, MediaDataManager {
+
+    companion object {
+        // UI surface label for subscribing Smartspace updates.
+        @JvmField val SMARTSPACE_UI_SURFACE_LABEL = "media_data_manager"
+
+        // Smartspace package name's extra key.
+        @JvmField val EXTRAS_MEDIA_SOURCE_PACKAGE_NAME = "package_name"
+
+        // Maximum number of actions allowed in compact view
+        @JvmField val MAX_COMPACT_ACTIONS = 3
+
+        // Maximum number of actions allowed in expanded view
+        @JvmField val MAX_NOTIFICATION_ACTIONS = MediaViewHolder.genericButtonIds.size
+    }
+
+    private val themeText =
+        com.android.settingslib.Utils.getColorAttr(
+                context,
+                com.android.internal.R.attr.textColorPrimary
+            )
+            .defaultColor
+
+    // Internal listeners are part of the internal pipeline. External listeners (those registered
+    // with [MediaDeviceManager.addListener]) receive events after they have propagated through
+    // the internal pipeline.
+    // Another way to think of the distinction between internal and external listeners is the
+    // following. Internal listeners are listeners that MediaDataManager depends on, and external
+    // listeners are listeners that depend on MediaDataManager.
+    // TODO(b/159539991#comment5): Move internal listeners to separate package.
+    private val internalListeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
+    private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
+    // There should ONLY be at most one Smartspace media recommendation.
+    var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
+    @Keep private var smartspaceSession: SmartspaceSession? = null
+    private var allowMediaRecommendations = allowMediaRecommendations(context)
+
+    private val artworkWidth =
+        context.resources.getDimensionPixelSize(
+            com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize
+        )
+    private val artworkHeight =
+        context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded)
+
+    @SuppressLint("WrongConstant") // sysui allowed to call STATUS_BAR_SERVICE
+    private val statusBarManager =
+        context.getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager
+
+    /** Check whether this notification is an RCN */
+    private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean {
+        return sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE)
+    }
+
+    @Inject
+    constructor(
+        context: Context,
+        threadFactory: ThreadFactory,
+        @Main uiExecutor: Executor,
+        @Main foregroundExecutor: DelayableExecutor,
+        mediaControllerFactory: MediaControllerFactory,
+        dumpManager: DumpManager,
+        broadcastDispatcher: BroadcastDispatcher,
+        mediaTimeoutListener: MediaTimeoutListener,
+        mediaResumeListener: MediaResumeListener,
+        mediaSessionBasedFilter: MediaSessionBasedFilter,
+        mediaDeviceManager: MediaDeviceManager,
+        mediaDataCombineLatest: MediaDataCombineLatest,
+        mediaDataFilter: LegacyMediaDataFilterImpl,
+        activityStarter: ActivityStarter,
+        smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
+        clock: SystemClock,
+        tunerService: TunerService,
+        mediaFlags: MediaFlags,
+        logger: MediaUiEventLogger,
+        smartspaceManager: SmartspaceManager?,
+        keyguardUpdateMonitor: KeyguardUpdateMonitor,
+    ) : this(
+        context,
+        // Loading bitmap for UMO background can take longer time, so it cannot run on the default
+        // background thread. Use a custom thread for media.
+        threadFactory.buildExecutorOnNewThread(TAG),
+        uiExecutor,
+        foregroundExecutor,
+        mediaControllerFactory,
+        broadcastDispatcher,
+        dumpManager,
+        mediaTimeoutListener,
+        mediaResumeListener,
+        mediaSessionBasedFilter,
+        mediaDeviceManager,
+        mediaDataCombineLatest,
+        mediaDataFilter,
+        activityStarter,
+        smartspaceMediaDataProvider,
+        Utils.useMediaResumption(context),
+        Utils.useQsMediaPlayer(context),
+        clock,
+        tunerService,
+        mediaFlags,
+        logger,
+        smartspaceManager,
+        keyguardUpdateMonitor,
+    )
+
+    private val appChangeReceiver =
+        object : BroadcastReceiver() {
+            override fun onReceive(context: Context, intent: Intent) {
+                when (intent.action) {
+                    Intent.ACTION_PACKAGES_SUSPENDED -> {
+                        val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST)
+                        packages?.forEach { removeAllForPackage(it) }
+                    }
+                    Intent.ACTION_PACKAGE_REMOVED,
+                    Intent.ACTION_PACKAGE_RESTARTED -> {
+                        intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) }
+                    }
+                }
+            }
+        }
+
+    init {
+        dumpManager.registerDumpable(TAG, this)
+
+        // Initialize the internal processing pipeline. The listeners at the front of the pipeline
+        // are set as internal listeners so that they receive events. From there, events are
+        // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter,
+        // so it is responsible for dispatching events to external listeners. To achieve this,
+        // external listeners that are registered with [MediaDataManager.addListener] are actually
+        // registered as listeners to mediaDataFilter.
+        addInternalListener(mediaTimeoutListener)
+        addInternalListener(mediaResumeListener)
+        addInternalListener(mediaSessionBasedFilter)
+        mediaSessionBasedFilter.addListener(mediaDeviceManager)
+        mediaSessionBasedFilter.addListener(mediaDataCombineLatest)
+        mediaDeviceManager.addListener(mediaDataCombineLatest)
+        mediaDataCombineLatest.addListener(mediaDataFilter)
+
+        // Set up links back into the pipeline for listeners that need to send events upstream.
+        mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean ->
+            setInactive(key, timedOut)
+        }
+        mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
+            updateState(key, state)
+        }
+        mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) }
+        mediaResumeListener.setManager(this)
+        mediaDataFilter.mediaDataManager = this
+
+        val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED)
+        broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL)
+
+        val uninstallFilter =
+            IntentFilter().apply {
+                addAction(Intent.ACTION_PACKAGE_REMOVED)
+                addAction(Intent.ACTION_PACKAGE_RESTARTED)
+                addDataScheme("package")
+            }
+        // BroadcastDispatcher does not allow filters with data schemes
+        context.registerReceiver(appChangeReceiver, uninstallFilter)
+
+        // Register for Smartspace data updates.
+        smartspaceMediaDataProvider.registerListener(this)
+        smartspaceSession =
+            smartspaceManager?.createSmartspaceSession(
+                SmartspaceConfig.Builder(context, SMARTSPACE_UI_SURFACE_LABEL).build()
+            )
+        smartspaceSession?.let {
+            it.addOnTargetsAvailableListener(
+                // Use a main uiExecutor thread listening to Smartspace updates instead of using
+                // the existing background executor.
+                // SmartspaceSession has scheduled routine updates which can be unpredictable on
+                // test simulators, using the backgroundExecutor makes it's hard to test the threads
+                // numbers.
+                uiExecutor,
+                SmartspaceSession.OnTargetsAvailableListener { targets ->
+                    smartspaceMediaDataProvider.onTargetsAvailable(targets)
+                }
+            )
+        }
+        smartspaceSession?.let { it.requestSmartspaceUpdate() }
+        tunerService.addTunable(
+            object : TunerService.Tunable {
+                override fun onTuningChanged(key: String?, newValue: String?) {
+                    allowMediaRecommendations = allowMediaRecommendations(context)
+                    if (!allowMediaRecommendations) {
+                        dismissSmartspaceRecommendation(
+                            key = smartspaceMediaData.targetId,
+                            delay = 0L
+                        )
+                    }
+                }
+            },
+            Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION
+        )
+    }
+
+    override fun destroy() {
+        smartspaceMediaDataProvider.unregisterListener(this)
+        smartspaceSession?.close()
+        smartspaceSession = null
+        context.unregisterReceiver(appChangeReceiver)
+    }
+
+    override fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
+        if (useQsMediaPlayer && isMediaNotification(sbn)) {
+            var isNewlyActiveEntry = false
+            Assert.isMainThread()
+            val oldKey = findExistingEntry(key, sbn.packageName)
+            if (oldKey == null) {
+                val instanceId = logger.getNewInstanceId()
+                val temp =
+                    LOADING.copy(
+                        packageName = sbn.packageName,
+                        instanceId = instanceId,
+                        createdTimestampMillis = systemClock.currentTimeMillis(),
+                    )
+                mediaEntries.put(key, temp)
+                isNewlyActiveEntry = true
+            } else if (oldKey != key) {
+                // Resume -> active conversion; move to new key
+                val oldData = mediaEntries.remove(oldKey)!!
+                isNewlyActiveEntry = true
+                mediaEntries.put(key, oldData)
+            }
+            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
+        } else {
+            onNotificationRemoved(key)
+        }
+    }
+
+    private fun removeAllForPackage(packageName: String) {
+        Assert.isMainThread()
+        val toRemove = mediaEntries.filter { it.value.packageName == packageName }
+        toRemove.forEach { removeEntry(it.key) }
+    }
+
+    override fun setResumeAction(key: String, action: Runnable?) {
+        mediaEntries.get(key)?.let {
+            it.resumeAction = action
+            it.hasCheckedForResume = true
+        }
+    }
+
+    override fun addResumptionControls(
+        userId: Int,
+        desc: MediaDescription,
+        action: Runnable,
+        token: MediaSession.Token,
+        appName: String,
+        appIntent: PendingIntent,
+        packageName: String
+    ) {
+        // Resume controls don't have a notification key, so store by package name instead
+        if (!mediaEntries.containsKey(packageName)) {
+            val instanceId = logger.getNewInstanceId()
+            val appUid =
+                try {
+                    context.packageManager.getApplicationInfo(packageName, 0)?.uid!!
+                } catch (e: PackageManager.NameNotFoundException) {
+                    Log.w(TAG, "Could not get app UID for $packageName", e)
+                    Process.INVALID_UID
+                }
+
+            val resumeData =
+                LOADING.copy(
+                    packageName = packageName,
+                    resumeAction = action,
+                    hasCheckedForResume = true,
+                    instanceId = instanceId,
+                    appUid = appUid,
+                    createdTimestampMillis = systemClock.currentTimeMillis(),
+                )
+            mediaEntries.put(packageName, resumeData)
+            logSingleVsMultipleMediaAdded(appUid, packageName, instanceId)
+            logger.logResumeMediaAdded(appUid, packageName, instanceId)
+        }
+        backgroundExecutor.execute {
+            loadMediaDataInBgForResumption(
+                userId,
+                desc,
+                action,
+                token,
+                appName,
+                appIntent,
+                packageName
+            )
+        }
+    }
+
+    /**
+     * Check if there is an existing entry that matches the key or package name. Returns the key
+     * that matches, or null if not found.
+     */
+    private fun findExistingEntry(key: String, packageName: String): String? {
+        if (mediaEntries.containsKey(key)) {
+            return key
+        }
+        // Check if we already had a resume player
+        if (mediaEntries.containsKey(packageName)) {
+            return packageName
+        }
+        return null
+    }
+
+    private fun loadMediaData(
+        key: String,
+        sbn: StatusBarNotification,
+        oldKey: String?,
+        isNewlyActiveEntry: Boolean = false,
+    ) {
+        backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
+    }
+
+    /** Add a listener for changes in this class */
+    override fun addListener(listener: MediaDataManager.Listener) {
+        // mediaDataFilter is the current end of the internal pipeline. Register external
+        // listeners as listeners to it.
+        mediaDataFilter.addListener(listener)
+    }
+
+    /** Remove a listener for changes in this class */
+    override fun removeListener(listener: MediaDataManager.Listener) {
+        // Since mediaDataFilter is the current end of the internal pipelie, external listeners
+        // have been registered to it. So, they need to be removed from it too.
+        mediaDataFilter.removeListener(listener)
+    }
+
+    /** Add a listener for internal events. */
+    private fun addInternalListener(listener: MediaDataManager.Listener) =
+        internalListeners.add(listener)
+
+    /**
+     * Notify internal listeners of media loaded event.
+     *
+     * External listeners registered with [addListener] will be notified after the event propagates
+     * through the internal listener pipeline.
+     */
+    private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
+        internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) }
+    }
+
+    /**
+     * Notify internal listeners of Smartspace media loaded event.
+     *
+     * External listeners registered with [addListener] will be notified after the event propagates
+     * through the internal listener pipeline.
+     */
+    private fun notifySmartspaceMediaDataLoaded(key: String, info: SmartspaceMediaData) {
+        internalListeners.forEach { it.onSmartspaceMediaDataLoaded(key, info) }
+    }
+
+    /**
+     * Notify internal listeners of media removed event.
+     *
+     * External listeners registered with [addListener] will be notified after the event propagates
+     * through the internal listener pipeline.
+     */
+    private fun notifyMediaDataRemoved(key: String) {
+        internalListeners.forEach { it.onMediaDataRemoved(key) }
+    }
+
+    /**
+     * Notify internal listeners of Smartspace media removed event.
+     *
+     * External listeners registered with [addListener] will be notified after the event propagates
+     * through the internal listener pipeline.
+     *
+     * @param immediately indicates should apply the UI changes immediately, otherwise wait until
+     *   the next refresh-round before UI becomes visible. Should only be true if the update is
+     *   initiated by user's interaction.
+     */
+    private fun notifySmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
+        internalListeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
+    }
+
+    /**
+     * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This
+     * will make the player not active anymore, hiding it from QQS and Keyguard.
+     *
+     * @see MediaData.active
+     */
+    override fun setInactive(key: String, timedOut: Boolean, forceUpdate: Boolean) {
+        mediaEntries[key]?.let {
+            if (timedOut && !forceUpdate) {
+                // Only log this event when media expires on its own
+                logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId)
+            }
+            if (it.active == !timedOut && !forceUpdate) {
+                if (it.resumption) {
+                    if (DEBUG) Log.d(TAG, "timing out resume player $key")
+                    dismissMediaData(key, 0L /* delay */)
+                }
+                return
+            }
+            // Update last active if media was still active.
+            if (it.active) {
+                it.lastActive = systemClock.elapsedRealtime()
+            }
+            it.active = !timedOut
+            if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut")
+            onMediaDataLoaded(key, key, it)
+        }
+
+        if (key == smartspaceMediaData.targetId) {
+            if (DEBUG) Log.d(TAG, "smartspace card expired")
+            dismissSmartspaceRecommendation(key, delay = 0L)
+        }
+    }
+
+    /** Called when the player's [PlaybackState] has been updated with new actions and/or state */
+    private fun updateState(key: String, state: PlaybackState) {
+        mediaEntries.get(key)?.let {
+            val token = it.token
+            if (token == null) {
+                if (DEBUG) Log.d(TAG, "State updated, but token was null")
+                return
+            }
+            val actions =
+                createActionsFromState(
+                    it.packageName,
+                    mediaControllerFactory.create(it.token),
+                    UserHandle(it.userId)
+                )
+
+            // Control buttons
+            // If flag is enabled and controller has a PlaybackState,
+            // create actions from session info
+            // otherwise, no need to update semantic actions.
+            val data =
+                if (actions != null) {
+                    it.copy(semanticActions = actions, isPlaying = isPlayingState(state.state))
+                } else {
+                    it.copy(isPlaying = isPlayingState(state.state))
+                }
+            if (DEBUG) Log.d(TAG, "State updated outside of notification")
+            onMediaDataLoaded(key, key, data)
+        }
+    }
+
+    private fun removeEntry(key: String, logEvent: Boolean = true) {
+        mediaEntries.remove(key)?.let {
+            if (logEvent) {
+                logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
+            }
+        }
+        notifyMediaDataRemoved(key)
+    }
+
+    /** Dismiss a media entry. Returns false if the key was not found. */
+    override fun dismissMediaData(key: String, delay: Long): Boolean {
+        val existed = mediaEntries[key] != null
+        backgroundExecutor.execute {
+            mediaEntries[key]?.let { mediaData ->
+                if (mediaData.isLocalSession()) {
+                    mediaData.token?.let {
+                        val mediaController = mediaControllerFactory.create(it)
+                        mediaController.transportControls.stop()
+                    }
+                }
+            }
+        }
+        foregroundExecutor.executeDelayed({ removeEntry(key) }, delay)
+        return existed
+    }
+
+    /**
+     * Called whenever the recommendation has been expired or removed by the user. This will remove
+     * the recommendation card entirely from the carousel.
+     */
+    override fun dismissSmartspaceRecommendation(key: String, delay: Long) {
+        if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
+            // If this doesn't match, or we've already invalidated the data, no action needed
+            return
+        }
+
+        if (DEBUG) Log.d(TAG, "Dismissing Smartspace media target")
+        if (smartspaceMediaData.isActive) {
+            smartspaceMediaData =
+                EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+                    targetId = smartspaceMediaData.targetId,
+                    instanceId = smartspaceMediaData.instanceId
+                )
+        }
+        foregroundExecutor.executeDelayed(
+            { notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = true) },
+            delay
+        )
+    }
+
+    /** Called when the recommendation card should no longer be visible in QQS or lockscreen */
+    override fun setRecommendationInactive(key: String) {
+        if (!mediaFlags.isPersistentSsCardEnabled()) {
+            Log.e(TAG, "Only persistent recommendation can be inactive!")
+            return
+        }
+        if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive")
+
+        if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
+            // If this doesn't match, or we've already invalidated the data, no action needed
+            return
+        }
+
+        smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
+        notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
+    }
+
+    private fun loadMediaDataInBgForResumption(
+        userId: Int,
+        desc: MediaDescription,
+        resumeAction: Runnable,
+        token: MediaSession.Token,
+        appName: String,
+        appIntent: PendingIntent,
+        packageName: String
+    ) {
+        if (desc.title.isNullOrBlank()) {
+            Log.e(TAG, "Description incomplete")
+            // Delete the placeholder entry
+            mediaEntries.remove(packageName)
+            return
+        }
+
+        if (DEBUG) {
+            Log.d(TAG, "adding track for $userId from browser: $desc")
+        }
+
+        val currentEntry = mediaEntries.get(packageName)
+        val appUid = currentEntry?.appUid ?: Process.INVALID_UID
+
+        // Album art
+        var artworkBitmap = desc.iconBitmap
+        if (artworkBitmap == null && desc.iconUri != null) {
+            artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName)
+        }
+        val artworkIcon =
+            if (artworkBitmap != null) {
+                Icon.createWithBitmap(artworkBitmap)
+            } else {
+                null
+            }
+
+        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
+        val isExplicit =
+            desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
+                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
+
+        val progress =
+            if (mediaFlags.isResumeProgressEnabled()) {
+                MediaDataUtils.getDescriptionProgress(desc.extras)
+            } else null
+
+        val mediaAction = getResumeMediaAction(resumeAction)
+        val lastActive = systemClock.elapsedRealtime()
+        val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
+        foregroundExecutor.execute {
+            onMediaDataLoaded(
+                packageName,
+                null,
+                MediaData(
+                    userId,
+                    true,
+                    appName,
+                    null,
+                    desc.subtitle,
+                    desc.title,
+                    artworkIcon,
+                    listOf(mediaAction),
+                    listOf(0),
+                    MediaButton(playOrPause = mediaAction),
+                    packageName,
+                    token,
+                    appIntent,
+                    device = null,
+                    active = false,
+                    resumeAction = resumeAction,
+                    resumption = true,
+                    notificationKey = packageName,
+                    hasCheckedForResume = true,
+                    lastActive = lastActive,
+                    createdTimestampMillis = createdTimestampMillis,
+                    instanceId = instanceId,
+                    appUid = appUid,
+                    isExplicit = isExplicit,
+                    resumeProgress = progress,
+                )
+            )
+        }
+    }
+
+    fun loadMediaDataInBg(
+        key: String,
+        sbn: StatusBarNotification,
+        oldKey: String?,
+        isNewlyActiveEntry: Boolean = false,
+    ) {
+        val token =
+            sbn.notification.extras.getParcelable(
+                Notification.EXTRA_MEDIA_SESSION,
+                MediaSession.Token::class.java
+            )
+        if (token == null) {
+            return
+        }
+        val mediaController = mediaControllerFactory.create(token)
+        val metadata = mediaController.metadata
+        val notif: Notification = sbn.notification
+
+        val appInfo =
+            notif.extras.getParcelable(
+                Notification.EXTRA_BUILDER_APPLICATION_INFO,
+                ApplicationInfo::class.java
+            )
+                ?: getAppInfoFromPackage(sbn.packageName)
+
+        // App name
+        val appName = getAppName(sbn, appInfo)
+
+        // Song name
+        var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
+        if (song.isNullOrBlank()) {
+            song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
+        }
+        if (song.isNullOrBlank()) {
+            song = HybridGroupManager.resolveTitle(notif)
+        }
+        if (song.isNullOrBlank()) {
+            // For apps that don't include a title, log and add a placeholder
+            song = context.getString(R.string.controls_media_empty_title, appName)
+            try {
+                statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier)
+            } catch (e: RuntimeException) {
+                Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}")
+            }
+        }
+
+        // Album art
+        var artworkBitmap = metadata?.let { loadBitmapFromUri(it) }
+        if (artworkBitmap == null) {
+            artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART)
+        }
+        if (artworkBitmap == null) {
+            artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
+        }
+        val artWorkIcon =
+            if (artworkBitmap == null) {
+                notif.getLargeIcon()
+            } else {
+                Icon.createWithBitmap(artworkBitmap)
+            }
+
+        // App Icon
+        val smallIcon = sbn.notification.smallIcon
+
+        // Explicit Indicator
+        var isExplicit = false
+        val mediaMetadataCompat = MediaMetadataCompat.fromMediaMetadata(metadata)
+        isExplicit =
+            mediaMetadataCompat?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
+                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
+
+        // Artist name
+        var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
+        if (artist.isNullOrBlank()) {
+            artist = HybridGroupManager.resolveText(notif)
+        }
+
+        // Device name (used for remote cast notifications)
+        var device: MediaDeviceData? = null
+        if (isRemoteCastNotification(sbn)) {
+            val extras = sbn.notification.extras
+            val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null)
+            val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1)
+            val deviceIntent =
+                extras.getParcelable(
+                    Notification.EXTRA_MEDIA_REMOTE_INTENT,
+                    PendingIntent::class.java
+                )
+            Log.d(TAG, "$key is RCN for $deviceName")
+
+            if (deviceName != null && deviceIcon > -1) {
+                // Name and icon must be present, but intent may be null
+                val enabled = deviceIntent != null && deviceIntent.isActivity
+                val deviceDrawable =
+                    Icon.createWithResource(sbn.packageName, deviceIcon)
+                        .loadDrawable(sbn.getPackageContext(context))
+                device =
+                    MediaDeviceData(
+                        enabled,
+                        deviceDrawable,
+                        deviceName,
+                        deviceIntent,
+                        showBroadcastButton = false
+                    )
+            }
+        }
+
+        // Control buttons
+        // If flag is enabled and controller has a PlaybackState, create actions from session info
+        // Otherwise, use the notification actions
+        var actionIcons: List<MediaAction> = emptyList()
+        var actionsToShowCollapsed: List<Int> = emptyList()
+        val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user)
+        if (semanticActions == null) {
+            val actions = createActionsFromNotification(sbn)
+            actionIcons = actions.first
+            actionsToShowCollapsed = actions.second
+        }
+
+        val playbackLocation =
+            if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
+            else if (
+                mediaController.playbackInfo?.playbackType ==
+                    MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL
+            )
+                MediaData.PLAYBACK_LOCAL
+            else MediaData.PLAYBACK_CAST_LOCAL
+        val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
+
+        val currentEntry = mediaEntries.get(key)
+        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
+        val appUid = appInfo?.uid ?: Process.INVALID_UID
+
+        if (isNewlyActiveEntry) {
+            logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId)
+            logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation)
+        } else if (playbackLocation != currentEntry?.playbackLocation) {
+            logger.logPlaybackLocationChange(appUid, sbn.packageName, instanceId, playbackLocation)
+        }
+
+        val lastActive = systemClock.elapsedRealtime()
+        val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
+        foregroundExecutor.execute {
+            val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
+            val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
+            val active = mediaEntries[key]?.active ?: true
+            onMediaDataLoaded(
+                key,
+                oldKey,
+                MediaData(
+                    sbn.normalizedUserId,
+                    true,
+                    appName,
+                    smallIcon,
+                    artist,
+                    song,
+                    artWorkIcon,
+                    actionIcons,
+                    actionsToShowCollapsed,
+                    semanticActions,
+                    sbn.packageName,
+                    token,
+                    notif.contentIntent,
+                    device,
+                    active,
+                    resumeAction = resumeAction,
+                    playbackLocation = playbackLocation,
+                    notificationKey = key,
+                    hasCheckedForResume = hasCheckedForResume,
+                    isPlaying = isPlaying,
+                    isClearable = !sbn.isOngoing,
+                    lastActive = lastActive,
+                    createdTimestampMillis = createdTimestampMillis,
+                    instanceId = instanceId,
+                    appUid = appUid,
+                    isExplicit = isExplicit,
+                )
+            )
+        }
+    }
+
+    private fun logSingleVsMultipleMediaAdded(
+        appUid: Int,
+        packageName: String,
+        instanceId: InstanceId
+    ) {
+        if (mediaEntries.size == 1) {
+            logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId)
+        } else if (mediaEntries.size == 2) {
+            // Since this method is only called when there is a new media session added.
+            // logging needed once there is more than one media session in carousel.
+            logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId)
+        }
+    }
+
+    private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? {
+        try {
+            return context.packageManager.getApplicationInfo(packageName, 0)
+        } catch (e: PackageManager.NameNotFoundException) {
+            Log.w(TAG, "Could not get app info for $packageName", e)
+        }
+        return null
+    }
+
+    private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String {
+        val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME)
+        if (name != null) {
+            return name
+        }
+
+        return if (appInfo != null) {
+            context.packageManager.getApplicationLabel(appInfo).toString()
+        } else {
+            sbn.packageName
+        }
+    }
+
+    /** Generate action buttons based on notification actions */
+    private fun createActionsFromNotification(
+        sbn: StatusBarNotification
+    ): Pair<List<MediaAction>, List<Int>> {
+        val notif = sbn.notification
+        val actionIcons: MutableList<MediaAction> = ArrayList()
+        val actions = notif.actions
+        var actionsToShowCollapsed =
+            notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList()
+                ?: mutableListOf()
+        if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) {
+            Log.e(
+                TAG,
+                "Too many compact actions for ${sbn.key}," +
+                    "limiting to first $MAX_COMPACT_ACTIONS"
+            )
+            actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS)
+        }
+
+        if (actions != null) {
+            for ((index, action) in actions.withIndex()) {
+                if (index == MAX_NOTIFICATION_ACTIONS) {
+                    Log.w(
+                        TAG,
+                        "Too many notification actions for ${sbn.key}," +
+                            " limiting to first $MAX_NOTIFICATION_ACTIONS"
+                    )
+                    break
+                }
+                if (action.getIcon() == null) {
+                    if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}")
+                    actionsToShowCollapsed.remove(index)
+                    continue
+                }
+                val runnable =
+                    if (action.actionIntent != null) {
+                        Runnable {
+                            if (action.actionIntent.isActivity) {
+                                activityStarter.startPendingIntentDismissingKeyguard(
+                                    action.actionIntent
+                                )
+                            } else if (action.isAuthenticationRequired()) {
+                                activityStarter.dismissKeyguardThenExecute(
+                                    {
+                                        var result = sendPendingIntent(action.actionIntent)
+                                        result
+                                    },
+                                    {},
+                                    true
+                                )
+                            } else {
+                                sendPendingIntent(action.actionIntent)
+                            }
+                        }
+                    } else {
+                        null
+                    }
+                val mediaActionIcon =
+                    if (action.getIcon()?.getType() == Icon.TYPE_RESOURCE) {
+                            Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId())
+                        } else {
+                            action.getIcon()
+                        }
+                        .setTint(themeText)
+                        .loadDrawable(context)
+                val mediaAction = MediaAction(mediaActionIcon, runnable, action.title, null)
+                actionIcons.add(mediaAction)
+            }
+        }
+        return Pair(actionIcons, actionsToShowCollapsed)
+    }
+
+    /**
+     * Generates action button info for this media session based on the PlaybackState
+     *
+     * @param packageName Package name for the media app
+     * @param controller MediaController for the current session
+     * @return a Pair consisting of a list of media actions, and a list of ints representing which
+     *
+     * ```
+     *      of those actions should be shown in the compact player
+     * ```
+     */
+    private fun createActionsFromState(
+        packageName: String,
+        controller: MediaController,
+        user: UserHandle
+    ): MediaButton? {
+        val state = controller.playbackState
+        if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) {
+            return null
+        }
+
+        // First, check for standard actions
+        val playOrPause =
+            if (isConnectingState(state.state)) {
+                // Spinner needs to be animating to render anything. Start it here.
+                val drawable =
+                    context.getDrawable(com.android.internal.R.drawable.progress_small_material)
+                (drawable as Animatable).start()
+                MediaAction(
+                    drawable,
+                    null, // no action to perform when clicked
+                    context.getString(R.string.controls_media_button_connecting),
+                    context.getDrawable(R.drawable.ic_media_connecting_container),
+                    // Specify a rebind id to prevent the spinner from restarting on later binds.
+                    com.android.internal.R.drawable.progress_small_material
+                )
+            } else if (isPlayingState(state.state)) {
+                getStandardAction(controller, state.actions, PlaybackState.ACTION_PAUSE)
+            } else {
+                getStandardAction(controller, state.actions, PlaybackState.ACTION_PLAY)
+            }
+        val prevButton =
+            getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_PREVIOUS)
+        val nextButton =
+            getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_NEXT)
+
+        // Then, create a way to build any custom actions that will be needed
+        val customActions =
+            state.customActions
+                .asSequence()
+                .filterNotNull()
+                .map { getCustomAction(state, packageName, controller, it) }
+                .iterator()
+        fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
+
+        // Finally, assign the remaining button slots: play/pause A B C D
+        // A = previous, else custom action (if not reserved)
+        // B = next, else custom action (if not reserved)
+        // C and D are always custom actions
+        val reservePrev =
+            controller.extras?.getBoolean(
+                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV
+            ) == true
+        val reserveNext =
+            controller.extras?.getBoolean(
+                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT
+            ) == true
+
+        val prevOrCustom =
+            if (prevButton != null) {
+                prevButton
+            } else if (!reservePrev) {
+                nextCustomAction()
+            } else {
+                null
+            }
+
+        val nextOrCustom =
+            if (nextButton != null) {
+                nextButton
+            } else if (!reserveNext) {
+                nextCustomAction()
+            } else {
+                null
+            }
+
+        return MediaButton(
+            playOrPause,
+            nextOrCustom,
+            prevOrCustom,
+            nextCustomAction(),
+            nextCustomAction(),
+            reserveNext,
+            reservePrev
+        )
+    }
+
+    /**
+     * Create a [MediaAction] for a given action and media session
+     *
+     * @param controller MediaController for the session
+     * @param stateActions The actions included with the session's [PlaybackState]
+     * @param action A [PlaybackState.Actions] value representing what action to generate. One of:
+     * ```
+     *      [PlaybackState.ACTION_PLAY]
+     *      [PlaybackState.ACTION_PAUSE]
+     *      [PlaybackState.ACTION_SKIP_TO_PREVIOUS]
+     *      [PlaybackState.ACTION_SKIP_TO_NEXT]
+     * @return
+     * ```
+     *
+     * A [MediaAction] with correct values set, or null if the state doesn't support it
+     */
+    private fun getStandardAction(
+        controller: MediaController,
+        stateActions: Long,
+        @PlaybackState.Actions action: Long
+    ): MediaAction? {
+        if (!includesAction(stateActions, action)) {
+            return null
+        }
+
+        return when (action) {
+            PlaybackState.ACTION_PLAY -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_play),
+                    { controller.transportControls.play() },
+                    context.getString(R.string.controls_media_button_play),
+                    context.getDrawable(R.drawable.ic_media_play_container)
+                )
+            }
+            PlaybackState.ACTION_PAUSE -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_pause),
+                    { controller.transportControls.pause() },
+                    context.getString(R.string.controls_media_button_pause),
+                    context.getDrawable(R.drawable.ic_media_pause_container)
+                )
+            }
+            PlaybackState.ACTION_SKIP_TO_PREVIOUS -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_prev),
+                    { controller.transportControls.skipToPrevious() },
+                    context.getString(R.string.controls_media_button_prev),
+                    null
+                )
+            }
+            PlaybackState.ACTION_SKIP_TO_NEXT -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_next),
+                    { controller.transportControls.skipToNext() },
+                    context.getString(R.string.controls_media_button_next),
+                    null
+                )
+            }
+            else -> null
+        }
+    }
+
+    /** Check whether the actions from a [PlaybackState] include a specific action */
+    private fun includesAction(stateActions: Long, @PlaybackState.Actions action: Long): Boolean {
+        if (
+            (action == PlaybackState.ACTION_PLAY || action == PlaybackState.ACTION_PAUSE) &&
+                (stateActions and PlaybackState.ACTION_PLAY_PAUSE > 0L)
+        ) {
+            return true
+        }
+        return (stateActions and action != 0L)
+    }
+
+    /** Get a [MediaAction] representing a [PlaybackState.CustomAction] */
+    private fun getCustomAction(
+        state: PlaybackState,
+        packageName: String,
+        controller: MediaController,
+        customAction: PlaybackState.CustomAction
+    ): MediaAction {
+        return MediaAction(
+            Icon.createWithResource(packageName, customAction.icon).loadDrawable(context),
+            { controller.transportControls.sendCustomAction(customAction, customAction.extras) },
+            customAction.name,
+            null
+        )
+    }
+
+    /** Load a bitmap from the various Art metadata URIs */
+    private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? {
+        for (uri in ART_URIS) {
+            val uriString = metadata.getString(uri)
+            if (!TextUtils.isEmpty(uriString)) {
+                val albumArt = loadBitmapFromUri(Uri.parse(uriString))
+                if (albumArt != null) {
+                    if (DEBUG) Log.d(TAG, "loaded art from $uri")
+                    return albumArt
+                }
+            }
+        }
+        return null
+    }
+
+    private fun sendPendingIntent(intent: PendingIntent): Boolean {
+        return try {
+            val options = BroadcastOptions.makeBasic()
+            options.setInteractive(true)
+            options.setPendingIntentBackgroundActivityStartMode(
+                ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
+            )
+            intent.send(options.toBundle())
+            true
+        } catch (e: PendingIntent.CanceledException) {
+            Log.d(TAG, "Intent canceled", e)
+            false
+        }
+    }
+
+    /** Returns a bitmap if the user can access the given URI, else null */
+    private fun loadBitmapFromUriForUser(
+        uri: Uri,
+        userId: Int,
+        appUid: Int,
+        packageName: String,
+    ): Bitmap? {
+        try {
+            val ugm = UriGrantsManager.getService()
+            ugm.checkGrantUriPermission_ignoreNonSystem(
+                appUid,
+                packageName,
+                ContentProvider.getUriWithoutUserId(uri),
+                Intent.FLAG_GRANT_READ_URI_PERMISSION,
+                ContentProvider.getUserIdFromUri(uri, userId)
+            )
+            return loadBitmapFromUri(uri)
+        } catch (e: SecurityException) {
+            Log.e(TAG, "Failed to get URI permission: $e")
+        }
+        return null
+    }
+
+    /**
+     * Load a bitmap from a URI
+     *
+     * @param uri the uri to load
+     * @return bitmap, or null if couldn't be loaded
+     */
+    private fun loadBitmapFromUri(uri: Uri): Bitmap? {
+        // ImageDecoder requires a scheme of the following types
+        if (uri.scheme == null) {
+            return null
+        }
+
+        if (
+            !uri.scheme.equals(ContentResolver.SCHEME_CONTENT) &&
+                !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) &&
+                !uri.scheme.equals(ContentResolver.SCHEME_FILE)
+        ) {
+            return null
+        }
+
+        val source = ImageDecoder.createSource(context.contentResolver, uri)
+        return try {
+            ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
+                val width = info.size.width
+                val height = info.size.height
+                val scale =
+                    MediaDataUtils.getScaleFactor(
+                        APair(width, height),
+                        APair(artworkWidth, artworkHeight)
+                    )
+
+                // Downscale if needed
+                if (scale != 0f && scale < 1) {
+                    decoder.setTargetSize((scale * width).toInt(), (scale * height).toInt())
+                }
+                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, "Unable to load bitmap", e)
+            null
+        } catch (e: RuntimeException) {
+            Log.e(TAG, "Unable to load bitmap", e)
+            null
+        }
+    }
+
+    private fun getResumeMediaAction(action: Runnable): MediaAction {
+        return MediaAction(
+            Icon.createWithResource(context, R.drawable.ic_media_play)
+                .setTint(themeText)
+                .loadDrawable(context),
+            action,
+            context.getString(R.string.controls_media_resume),
+            context.getDrawable(R.drawable.ic_media_play_container)
+        )
+    }
+
+    fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) =
+        traceSection("MediaDataManager#onMediaDataLoaded") {
+            Assert.isMainThread()
+            if (mediaEntries.containsKey(key)) {
+                // Otherwise this was removed already
+                mediaEntries.put(key, data)
+                notifyMediaDataLoaded(key, oldKey, data)
+            }
+        }
+
+    override fun onSmartspaceTargetsUpdated(targets: List<Parcelable>) {
+        if (!allowMediaRecommendations) {
+            if (DEBUG) Log.d(TAG, "Smartspace recommendation is disabled in Settings.")
+            return
+        }
+
+        val mediaTargets = targets.filterIsInstance<SmartspaceTarget>()
+        when (mediaTargets.size) {
+            0 -> {
+                if (!smartspaceMediaData.isActive) {
+                    return
+                }
+                if (DEBUG) {
+                    Log.d(TAG, "Set Smartspace media to be inactive for the data update")
+                }
+                if (mediaFlags.isPersistentSsCardEnabled()) {
+                    // Smartspace uses this signal to hide the card (e.g. when it expires or user
+                    // disconnects headphones), so treat as setting inactive when flag is on
+                    smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
+                    notifySmartspaceMediaDataLoaded(
+                        smartspaceMediaData.targetId,
+                        smartspaceMediaData,
+                    )
+                } else {
+                    smartspaceMediaData =
+                        EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+                            targetId = smartspaceMediaData.targetId,
+                            instanceId = smartspaceMediaData.instanceId,
+                        )
+                    notifySmartspaceMediaDataRemoved(
+                        smartspaceMediaData.targetId,
+                        immediately = false,
+                    )
+                }
+            }
+            1 -> {
+                val newMediaTarget = mediaTargets.get(0)
+                if (smartspaceMediaData.targetId == newMediaTarget.smartspaceTargetId) {
+                    // The same Smartspace updates can be received. Skip the duplicate updates.
+                    return
+                }
+                if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.")
+                smartspaceMediaData = toSmartspaceMediaData(newMediaTarget)
+                notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
+            }
+            else -> {
+                // There should NOT be more than 1 Smartspace media update. When it happens, it
+                // indicates a bad state or an error. Reset the status accordingly.
+                Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...")
+                notifySmartspaceMediaDataRemoved(
+                    smartspaceMediaData.targetId,
+                    immediately = false,
+                )
+                smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
+            }
+        }
+    }
+
+    override fun onNotificationRemoved(key: String) {
+        Assert.isMainThread()
+        val removed = mediaEntries.remove(key) ?: return
+        if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) {
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        } else if (isAbleToResume(removed)) {
+            convertToResumePlayer(key, removed)
+        } else if (mediaFlags.isRetainingPlayersEnabled()) {
+            handlePossibleRemoval(key, removed, notificationRemoved = true)
+        } else {
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        }
+    }
+
+    private fun onSessionDestroyed(key: String) {
+        if (DEBUG) Log.d(TAG, "session destroyed for $key")
+        val entry = mediaEntries.remove(key) ?: return
+        // Clear token since the session is no longer valid
+        val updated = entry.copy(token = null)
+        handlePossibleRemoval(key, updated)
+    }
+
+    private fun isAbleToResume(data: MediaData): Boolean {
+        val isEligibleForResume =
+            data.isLocalSession() ||
+                (mediaFlags.isRemoteResumeAllowed() &&
+                    data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE)
+        return useMediaResumption && data.resumeAction != null && isEligibleForResume
+    }
+
+    /**
+     * Convert to resume state if the player is no longer valid and active, then notify listeners
+     * that the data was updated. Does not convert to resume state if the player is still valid, or
+     * if it was removed before becoming inactive. (Assumes that [removed] was removed from
+     * [mediaEntries] before this function was called)
+     */
+    private fun handlePossibleRemoval(
+        key: String,
+        removed: MediaData,
+        notificationRemoved: Boolean = false
+    ) {
+        val hasSession = removed.token != null
+        if (hasSession && removed.semanticActions != null) {
+            // The app was using session actions, and the session is still valid: keep player
+            if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
+            mediaEntries.put(key, removed)
+            notifyMediaDataLoaded(key, key, removed)
+        } else if (!notificationRemoved && removed.semanticActions == null) {
+            // The app was using notification actions, and notif wasn't removed yet: keep player
+            if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
+            mediaEntries.put(key, removed)
+            notifyMediaDataLoaded(key, key, removed)
+        } else if (removed.active && !isAbleToResume(removed)) {
+            // This player was still active - it didn't last long enough to time out,
+            // and its app doesn't normally support resume: remove
+            if (DEBUG) Log.d(TAG, "Removing still-active player $key")
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        } else if (mediaFlags.isRetainingPlayersEnabled() || isAbleToResume(removed)) {
+            // Convert to resume
+            if (DEBUG) {
+                Log.d(
+                    TAG,
+                    "Notification ($notificationRemoved) and/or session " +
+                        "($hasSession) gone for inactive player $key"
+                )
+            }
+            convertToResumePlayer(key, removed)
+        } else {
+            // Retaining players flag is off and app doesn't support resume: remove player.
+            if (DEBUG) Log.d(TAG, "Removing player $key")
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        }
+    }
+
+    /** Set the given [MediaData] as a resume state player and notify listeners */
+    private fun convertToResumePlayer(key: String, data: MediaData) {
+        if (DEBUG) Log.d(TAG, "Converting $key to resume")
+        // Resumption controls must have a title.
+        if (data.song.isNullOrBlank()) {
+            Log.e(TAG, "Description incomplete")
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
+            return
+        }
+        // Move to resume key (aka package name) if that key doesn't already exist.
+        val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
+        val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
+        val launcherIntent =
+            context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
+                PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
+            }
+        val lastActive =
+            if (data.active) {
+                systemClock.elapsedRealtime()
+            } else {
+                data.lastActive
+            }
+        val updated =
+            data.copy(
+                token = null,
+                actions = actions,
+                semanticActions = MediaButton(playOrPause = resumeAction),
+                actionsToShowInCompact = listOf(0),
+                active = false,
+                resumption = true,
+                isPlaying = false,
+                isClearable = true,
+                clickIntent = launcherIntent,
+                lastActive = lastActive,
+            )
+        val pkg = data.packageName
+        val migrate = mediaEntries.put(pkg, updated) == null
+        // Notify listeners of "new" controls when migrating or removed and update when not
+        Log.d(TAG, "migrating? $migrate from $key -> $pkg")
+        if (migrate) {
+            notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
+        } else {
+            // Since packageName is used for the key of the resumption controls, it is
+            // possible that another notification has already been reused for the resumption
+            // controls of this package. In this case, rather than renaming this player as
+            // packageName, just remove it and then send a update to the existing resumption
+            // controls.
+            notifyMediaDataRemoved(key)
+            notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
+        }
+        logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
+
+        // Limit total number of resume controls
+        val resumeEntries = mediaEntries.filter { (key, data) -> data.resumption }
+        val numResume = resumeEntries.size
+        if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
+            resumeEntries
+                .toList()
+                .sortedBy { (key, data) -> data.lastActive }
+                .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS)
+                .forEach { (key, data) ->
+                    Log.d(TAG, "Removing excess control $key")
+                    mediaEntries.remove(key)
+                    notifyMediaDataRemoved(key)
+                    logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
+                }
+        }
+    }
+
+    override fun setMediaResumptionEnabled(isEnabled: Boolean) {
+        if (useMediaResumption == isEnabled) {
+            return
+        }
+
+        useMediaResumption = isEnabled
+
+        if (!useMediaResumption) {
+            // Remove any existing resume controls
+            val filtered = mediaEntries.filter { !it.value.active }
+            filtered.forEach {
+                mediaEntries.remove(it.key)
+                notifyMediaDataRemoved(it.key)
+                logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId)
+            }
+        }
+    }
+
+    /** Invoked when the user has dismissed the media carousel */
+    override fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss()
+
+    /** Are there any media notifications active, including the recommendations? */
+    override fun hasActiveMediaOrRecommendation() = mediaDataFilter.hasActiveMediaOrRecommendation()
+
+    /**
+     * Are there any media entries we should display, including the recommendations?
+     * - If resumption is enabled, this will include inactive players
+     * - If resumption is disabled, we only want to show active players
+     */
+    override fun hasAnyMediaOrRecommendation() = mediaDataFilter.hasAnyMediaOrRecommendation()
+
+    /** Are there any resume media notifications active, excluding the recommendations? */
+    override fun hasActiveMedia() = mediaDataFilter.hasActiveMedia()
+
+    /**
+     * Are there any resume media notifications active, excluding the recommendations?
+     * - If resumption is enabled, this will include inactive players
+     * - If resumption is disabled, we only want to show active players
+     */
+    override fun hasAnyMedia() = mediaDataFilter.hasAnyMedia()
+    override fun isRecommendationActive() = smartspaceMediaData.isActive
+
+    /**
+     * Converts the pass-in SmartspaceTarget to SmartspaceMediaData
+     *
+     * @return An empty SmartspaceMediaData with the valid target Id is returned if the
+     *   SmartspaceTarget's data is invalid.
+     */
+    private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData {
+        val baseAction: SmartspaceAction? = target.baseAction
+        val dismissIntent =
+            baseAction?.extras?.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY) as Intent?
+
+        val isActive =
+            when {
+                !mediaFlags.isPersistentSsCardEnabled() -> true
+                baseAction == null -> true
+                else -> {
+                    val triggerSource = baseAction.extras?.getString(EXTRA_KEY_TRIGGER_SOURCE)
+                    triggerSource != EXTRA_VALUE_TRIGGER_PERIODIC
+                }
+            }
+
+        packageName(target)?.let {
+            return SmartspaceMediaData(
+                targetId = target.smartspaceTargetId,
+                isActive = isActive,
+                packageName = it,
+                cardAction = target.baseAction,
+                recommendations = target.iconGrid,
+                dismissIntent = dismissIntent,
+                headphoneConnectionTimeMillis = target.creationTimeMillis,
+                instanceId = logger.getNewInstanceId(),
+                expiryTimeMs = target.expiryTimeMillis,
+            )
+        }
+        return EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+            targetId = target.smartspaceTargetId,
+            isActive = isActive,
+            dismissIntent = dismissIntent,
+            headphoneConnectionTimeMillis = target.creationTimeMillis,
+            instanceId = logger.getNewInstanceId(),
+            expiryTimeMs = target.expiryTimeMillis,
+        )
+    }
+
+    private fun packageName(target: SmartspaceTarget): String? {
+        val recommendationList = target.iconGrid
+        if (recommendationList == null || recommendationList.isEmpty()) {
+            Log.w(TAG, "Empty or null media recommendation list.")
+            return null
+        }
+        for (recommendation in recommendationList) {
+            val extras = recommendation.extras
+            extras?.let {
+                it.getString(EXTRAS_MEDIA_SOURCE_PACKAGE_NAME)?.let { packageName ->
+                    return packageName
+                }
+            }
+        }
+        Log.w(TAG, "No valid package name is provided.")
+        return null
+    }
+
+    override fun dump(pw: PrintWriter, args: Array<out String>) {
+        pw.apply {
+            println("internalListeners: $internalListeners")
+            println("externalListeners: ${mediaDataFilter.listeners}")
+            println("mediaEntries: $mediaEntries")
+            println("useMediaResumption: $useMediaResumption")
+            println("allowMediaRecommendations: $allowMediaRecommendations")
+        }
+        mediaDeviceManager.dump(pw)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilter.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImpl.kt
similarity index 76%
copy from packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilter.kt
copy to packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImpl.kt
index bc539ef..a65db35 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilter.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImpl.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2020 The Android Open Source Project
+ * 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.
@@ -24,6 +24,7 @@
 import com.android.internal.annotations.VisibleForTesting
 import com.android.systemui.broadcast.BroadcastSender
 import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.media.controls.data.repository.MediaFilterRepository
 import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_RESUME
 import com.android.systemui.media.controls.shared.model.MediaData
 import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
@@ -36,7 +37,6 @@
 import java.util.concurrent.Executor
 import java.util.concurrent.TimeUnit
 import javax.inject.Inject
-import kotlin.collections.LinkedHashMap
 
 private const val TAG = "MediaDataFilter"
 private const val DEBUG = true
@@ -46,14 +46,6 @@
 private const val RESUMABLE_MEDIA_MAX_AGE_SECONDS_KEY = "resumable_media_max_age_seconds"
 
 /**
- * Maximum age of a media control to re-activate on smartspace signal. If there is no media control
- * available within this time window, smartspace recommendations will be shown instead.
- */
-@VisibleForTesting
-internal val SMARTSPACE_MAX_AGE =
-    SystemProperties.getLong("debug.sysui.smartspace_max_age", TimeUnit.MINUTES.toMillis(30))
-
-/**
  * Filters data updates from [MediaDataCombineLatest] based on the current user ID, and handles user
  * switches (removing entries for the previous user, adding back entries for the current user). Also
  * filters out smartspace updates in favor of local recent media, when avaialble.
@@ -61,28 +53,23 @@
  * This is added at the end of the pipeline since we may still need to handle callbacks from
  * background users (e.g. timeouts).
  */
-class MediaDataFilter
+class MediaDataFilterImpl
 @Inject
 constructor(
     private val context: Context,
-    private val userTracker: UserTracker,
+    userTracker: UserTracker,
     private val broadcastSender: BroadcastSender,
     private val lockscreenUserManager: NotificationLockscreenUserManager,
     @Main private val executor: Executor,
     private val systemClock: SystemClock,
     private val logger: MediaUiEventLogger,
     private val mediaFlags: MediaFlags,
+    private val mediaFilterRepository: MediaFilterRepository,
 ) : MediaDataManager.Listener {
     private val _listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
-    internal val listeners: Set<MediaDataManager.Listener>
+    val listeners: Set<MediaDataManager.Listener>
         get() = _listeners.toSet()
-    internal lateinit var mediaDataManager: MediaDataManager
-
-    private val allEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
-    // The filtered userEntries, which will be a subset of all userEntries in MediaDataManager
-    private val userEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
-    private var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
-    private var reactivatedKey: String? = null
+    lateinit var mediaDataManager: MediaDataManager
 
     // Ensure the field (and associated reference) isn't removed during optimization.
     @KeepForWeakReference
@@ -110,9 +97,9 @@
         isSsReactivated: Boolean
     ) {
         if (oldKey != null && oldKey != key) {
-            allEntries.remove(oldKey)
+            mediaFilterRepository.removeMediaEntry(oldKey)
         }
-        allEntries.put(key, data)
+        mediaFilterRepository.addMediaEntry(key, data)
 
         if (
             !lockscreenUserManager.isCurrentProfile(data.userId) ||
@@ -122,9 +109,9 @@
         }
 
         if (oldKey != null && oldKey != key) {
-            userEntries.remove(oldKey)
+            mediaFilterRepository.removeSelectedUserMediaEntry(oldKey)
         }
-        userEntries.put(key, data)
+        mediaFilterRepository.addSelectedUserMediaEntry(key, data)
 
         // Notify listeners
         listeners.forEach { it.onMediaDataLoaded(key, oldKey, data) }
@@ -144,10 +131,12 @@
 
         // Override the pass-in value here, as the order of Smartspace card is only determined here.
         var shouldPrioritizeMutable = false
-        smartspaceMediaData = data
+        mediaFilterRepository.setRecommendation(data)
 
         // Before forwarding the smartspace target, first check if we have recently inactive media
-        val sorted = userEntries.toSortedMap(compareBy { userEntries.get(it)?.lastActive ?: -1 })
+        val selectedUserEntries = mediaFilterRepository.selectedUserEntries.value
+        val sorted =
+            selectedUserEntries.toSortedMap(compareBy { selectedUserEntries[it]?.lastActive ?: -1 })
         val timeSinceActive = timeSinceActiveForMostRecentMedia(sorted)
         var smartspaceMaxAgeMillis = SMARTSPACE_MAX_AGE
         data.cardAction?.extras?.let {
@@ -162,7 +151,10 @@
         val shouldTriggerResume =
             data.cardAction?.extras?.getBoolean(EXTRA_KEY_TRIGGER_RESUME, true) ?: true
         val shouldReactivate =
-            shouldTriggerResume && !hasActiveMedia() && hasAnyMedia() && data.isActive
+            shouldTriggerResume &&
+                !selectedUserEntries.any { it.value.active } &&
+                selectedUserEntries.isNotEmpty() &&
+                data.isActive
 
         if (timeSinceActive < smartspaceMaxAgeMillis) {
             // It could happen there are existing active media resume cards, then we don't need to
@@ -171,8 +163,8 @@
                 val lastActiveKey = sorted.lastKey() // most recently active
                 // Notify listeners to consider this media active
                 Log.d(TAG, "reactivating $lastActiveKey instead of smartspace")
-                reactivatedKey = lastActiveKey
-                val mediaData = sorted.get(lastActiveKey)!!.copy(active = true)
+                mediaFilterRepository.setReactivatedKey(lastActiveKey)
+                val mediaData = sorted[lastActiveKey]!!.copy(active = true)
                 logger.logRecommendationActivated(
                     mediaData.appUid,
                     mediaData.packageName,
@@ -199,6 +191,7 @@
             Log.d(TAG, "Invalid recommendation data. Skip showing the rec card")
             return
         }
+        val smartspaceMediaData = mediaFilterRepository.smartspaceMediaData.value
         logger.logRecommendationAdded(
             smartspaceMediaData.packageName,
             smartspaceMediaData.instanceId
@@ -207,8 +200,8 @@
     }
 
     override fun onMediaDataRemoved(key: String) {
-        allEntries.remove(key)
-        userEntries.remove(key)?.let {
+        mediaFilterRepository.removeMediaEntry(key)
+        mediaFilterRepository.removeSelectedUserMediaEntry(key)?.let {
             // Only notify listeners if something actually changed
             listeners.forEach { it.onMediaDataRemoved(key) }
         }
@@ -216,24 +209,26 @@
 
     override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
         // First check if we had reactivated media instead of forwarding smartspace
-        reactivatedKey?.let {
+        mediaFilterRepository.reactivatedKey.value?.let {
             val lastActiveKey = it
-            reactivatedKey = null
+            mediaFilterRepository.setReactivatedKey(null)
             Log.d(TAG, "expiring reactivated key $lastActiveKey")
             // Notify listeners to update with actual active value
-            userEntries.get(lastActiveKey)?.let { mediaData ->
-                listeners.forEach {
-                    it.onMediaDataLoaded(lastActiveKey, lastActiveKey, mediaData, immediately)
+            mediaFilterRepository.selectedUserEntries.value[lastActiveKey]?.let { mediaData ->
+                listeners.forEach { listener ->
+                    listener.onMediaDataLoaded(lastActiveKey, lastActiveKey, mediaData, immediately)
                 }
             }
         }
 
+        val smartspaceMediaData = mediaFilterRepository.smartspaceMediaData.value
         if (smartspaceMediaData.isActive) {
-            smartspaceMediaData =
+            mediaFilterRepository.setRecommendation(
                 EMPTY_SMARTSPACE_MEDIA_DATA.copy(
                     targetId = smartspaceMediaData.targetId,
                     instanceId = smartspaceMediaData.instanceId
                 )
+            )
         }
         listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
     }
@@ -241,11 +236,11 @@
     @VisibleForTesting
     internal fun handleProfileChanged() {
         // TODO(b/317221348) re-add media removed when profile is available.
-        allEntries.forEach { (key, data) ->
+        mediaFilterRepository.allUserEntries.value.forEach { (key, data) ->
             if (!lockscreenUserManager.isProfileAvailable(data.userId)) {
                 // Only remove media when the profile is unavailable.
                 if (DEBUG) Log.d(TAG, "Removing $key after profile change")
-                userEntries.remove(key, data)
+                mediaFilterRepository.removeSelectedUserMediaEntry(key, data)
                 listeners.forEach { listener -> listener.onMediaDataRemoved(key) }
             }
         }
@@ -255,19 +250,19 @@
     internal fun handleUserSwitched() {
         // If the user changes, remove all current MediaData objects and inform listeners
         val listenersCopy = listeners
-        val keyCopy = userEntries.keys.toMutableList()
+        val keyCopy = mediaFilterRepository.selectedUserEntries.value.keys.toMutableList()
         // Clear the list first, to make sure callbacks from listeners if we have any entries
         // are up to date
-        userEntries.clear()
+        mediaFilterRepository.clearSelectedUserMedia()
         keyCopy.forEach {
             if (DEBUG) Log.d(TAG, "Removing $it after user change")
             listenersCopy.forEach { listener -> listener.onMediaDataRemoved(it) }
         }
 
-        allEntries.forEach { (key, data) ->
+        mediaFilterRepository.allUserEntries.value.forEach { (key, data) ->
             if (lockscreenUserManager.isCurrentProfile(data.userId)) {
                 if (DEBUG) Log.d(TAG, "Re-adding $key after user change")
-                userEntries.put(key, data)
+                mediaFilterRepository.addSelectedUserMediaEntry(key, data)
                 listenersCopy.forEach { listener -> listener.onMediaDataLoaded(key, null, data) }
             }
         }
@@ -276,11 +271,12 @@
     /** Invoked when the user has dismissed the media carousel */
     fun onSwipeToDismiss() {
         if (DEBUG) Log.d(TAG, "Media carousel swiped away")
-        val mediaKeys = userEntries.keys.toSet()
+        val mediaKeys = mediaFilterRepository.selectedUserEntries.value.keys.toSet()
         mediaKeys.forEach {
             // Force updates to listeners, needed for re-activated card
-            mediaDataManager.setTimedOut(it, timedOut = true, forceUpdate = true)
+            mediaDataManager.setInactive(it, timedOut = true, forceUpdate = true)
         }
+        val smartspaceMediaData = mediaFilterRepository.smartspaceMediaData.value
         if (smartspaceMediaData.isActive) {
             val dismissIntent = smartspaceMediaData.dismissIntent
             if (dismissIntent == null) {
@@ -298,14 +294,15 @@
             }
 
             if (mediaFlags.isPersistentSsCardEnabled()) {
-                smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
+                mediaFilterRepository.setRecommendation(smartspaceMediaData.copy(isActive = false))
                 mediaDataManager.setRecommendationInactive(smartspaceMediaData.targetId)
             } else {
-                smartspaceMediaData =
+                mediaFilterRepository.setRecommendation(
                     EMPTY_SMARTSPACE_MEDIA_DATA.copy(
                         targetId = smartspaceMediaData.targetId,
                         instanceId = smartspaceMediaData.instanceId,
                     )
+                )
                 mediaDataManager.dismissSmartspaceRecommendation(
                     smartspaceMediaData.targetId,
                     delay = 0L,
@@ -314,29 +311,6 @@
         }
     }
 
-    /** Are there any active media entries, including the recommendation? */
-    fun hasActiveMediaOrRecommendation() =
-        userEntries.any { it.value.active } ||
-            (smartspaceMediaData.isActive &&
-                (smartspaceMediaData.isValid() || reactivatedKey != null))
-
-    /** Are there any media entries we should display? */
-    fun hasAnyMediaOrRecommendation(): Boolean {
-        val hasSmartspace =
-            if (mediaFlags.isPersistentSsCardEnabled()) {
-                smartspaceMediaData.isValid()
-            } else {
-                smartspaceMediaData.isActive && smartspaceMediaData.isValid()
-            }
-        return userEntries.isNotEmpty() || hasSmartspace
-    }
-
-    /** Are there any media notifications active (excluding the recommendation)? */
-    fun hasActiveMedia() = userEntries.any { it.value.active }
-
-    /** Are there any media entries we should display (excluding the recommendation)? */
-    fun hasAnyMedia() = userEntries.isNotEmpty()
-
     /** Add a listener for filtered [MediaData] changes */
     fun addListener(listener: MediaDataManager.Listener) = _listeners.add(listener)
 
@@ -346,7 +320,7 @@
     /**
      * Return the time since last active for the most-recent media.
      *
-     * @param sortedEntries userEntries sorted from the earliest to the most-recent.
+     * @param sortedEntries selectedUserEntries sorted from the earliest to the most-recent.
      * @return The duration in milliseconds from the most-recent media's last active timestamp to
      *   the present. MAX_VALUE will be returned if there is no media.
      */
@@ -359,6 +333,21 @@
 
         val now = systemClock.elapsedRealtime()
         val lastActiveKey = sortedEntries.lastKey() // most recently active
-        return sortedEntries.get(lastActiveKey)?.let { now - it.lastActive } ?: Long.MAX_VALUE
+        return sortedEntries[lastActiveKey]?.let { now - it.lastActive } ?: Long.MAX_VALUE
+    }
+
+    companion object {
+        /**
+         * Maximum age of a media control to re-activate on smartspace signal. If there is no media
+         * control available within this time window, smartspace recommendations will be shown
+         * instead.
+         */
+        @VisibleForTesting
+        internal val SMARTSPACE_MAX_AGE: Long
+            get() =
+                SystemProperties.getLong(
+                    "debug.sysui.smartspace_max_age",
+                    TimeUnit.MINUTES.toMillis(30)
+                )
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManager.kt
index 865c49e..2b1070c 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManager.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2020 The Android Open Source Project
+ * 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.
@@ -16,424 +16,39 @@
 
 package com.android.systemui.media.controls.domain.pipeline
 
-import android.annotation.SuppressLint
-import android.app.ActivityOptions
-import android.app.BroadcastOptions
-import android.app.Notification
-import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME
 import android.app.PendingIntent
-import android.app.StatusBarManager
-import android.app.UriGrantsManager
-import android.app.smartspace.SmartspaceAction
-import android.app.smartspace.SmartspaceConfig
-import android.app.smartspace.SmartspaceManager
-import android.app.smartspace.SmartspaceSession
-import android.app.smartspace.SmartspaceTarget
-import android.content.BroadcastReceiver
-import android.content.ContentProvider
-import android.content.ContentResolver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.content.pm.ApplicationInfo
-import android.content.pm.PackageManager
-import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.graphics.drawable.Animatable
-import android.graphics.drawable.Icon
 import android.media.MediaDescription
-import android.media.MediaMetadata
-import android.media.session.MediaController
 import android.media.session.MediaSession
-import android.media.session.PlaybackState
-import android.net.Uri
-import android.os.Parcelable
-import android.os.Process
-import android.os.UserHandle
-import android.provider.Settings
 import android.service.notification.StatusBarNotification
-import android.support.v4.media.MediaMetadataCompat
-import android.text.TextUtils
-import android.util.Log
-import android.util.Pair as APair
-import androidx.media.utils.MediaConstants
-import com.android.app.tracing.traceSection
-import com.android.internal.annotations.Keep
-import com.android.internal.logging.InstanceId
-import com.android.keyguard.KeyguardUpdateMonitor
-import com.android.systemui.Dumpable
-import com.android.systemui.broadcast.BroadcastDispatcher
-import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.dagger.qualifiers.Background
-import com.android.systemui.dagger.qualifiers.Main
-import com.android.systemui.dump.DumpManager
-import com.android.systemui.media.controls.domain.resume.MediaResumeListener
-import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser
-import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_SOURCE
-import com.android.systemui.media.controls.shared.model.EXTRA_VALUE_TRIGGER_PERIODIC
-import com.android.systemui.media.controls.shared.model.MediaAction
-import com.android.systemui.media.controls.shared.model.MediaButton
 import com.android.systemui.media.controls.shared.model.MediaData
-import com.android.systemui.media.controls.shared.model.MediaDeviceData
 import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
-import com.android.systemui.media.controls.shared.model.SmartspaceMediaDataProvider
-import com.android.systemui.media.controls.ui.view.MediaViewHolder
-import com.android.systemui.media.controls.util.MediaControllerFactory
-import com.android.systemui.media.controls.util.MediaDataUtils
-import com.android.systemui.media.controls.util.MediaFlags
-import com.android.systemui.media.controls.util.MediaUiEventLogger
-import com.android.systemui.plugins.ActivityStarter
-import com.android.systemui.plugins.BcSmartspaceDataPlugin
-import com.android.systemui.res.R
-import com.android.systemui.statusbar.NotificationMediaManager.isConnectingState
-import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
-import com.android.systemui.statusbar.notification.row.HybridGroupManager
-import com.android.systemui.tuner.TunerService
-import com.android.systemui.util.Assert
-import com.android.systemui.util.Utils
-import com.android.systemui.util.concurrency.DelayableExecutor
-import com.android.systemui.util.concurrency.ThreadFactory
-import com.android.systemui.util.time.SystemClock
-import java.io.IOException
-import java.io.PrintWriter
-import java.util.concurrent.Executor
-import javax.inject.Inject
 
-// URI fields to try loading album art from
-private val ART_URIS =
-    arrayOf(
-        MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
-        MediaMetadata.METADATA_KEY_ART_URI,
-        MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI
-    )
+/** Facilitates management and loading of Media Data, ready for binding. */
+interface MediaDataManager {
 
-private const val TAG = "MediaDataManager"
-private const val DEBUG = true
-private const val EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY = "dismiss_intent"
+    /** Add a listener for changes in this class */
+    fun addListener(listener: Listener)
 
-private val LOADING =
-    MediaData(
-        userId = -1,
-        initialized = false,
-        app = null,
-        appIcon = null,
-        artist = null,
-        song = null,
-        artwork = null,
-        actions = emptyList(),
-        actionsToShowInCompact = emptyList(),
-        packageName = "INVALID",
-        token = null,
-        clickIntent = null,
-        device = null,
-        active = true,
-        resumeAction = null,
-        instanceId = InstanceId.fakeInstanceId(-1),
-        appUid = Process.INVALID_UID
-    )
+    /** Remove a listener for changes in this class */
+    fun removeListener(listener: Listener)
 
-internal val EMPTY_SMARTSPACE_MEDIA_DATA =
-    SmartspaceMediaData(
-        targetId = "INVALID",
-        isActive = false,
-        packageName = "INVALID",
-        cardAction = null,
-        recommendations = emptyList(),
-        dismissIntent = null,
-        headphoneConnectionTimeMillis = 0,
-        instanceId = InstanceId.fakeInstanceId(-1),
-        expiryTimeMs = 0,
-    )
+    /**
+     * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This
+     * will make the player not active anymore, hiding it from QQS and Keyguard.
+     *
+     * @see MediaData.active
+     */
+    fun setInactive(key: String, timedOut: Boolean, forceUpdate: Boolean = false)
 
-const val MEDIA_TITLE_ERROR_MESSAGE = "Invalid media data: title is null or blank."
+    /** Invoked when media notification is added. */
+    fun onNotificationAdded(key: String, sbn: StatusBarNotification)
 
-fun isMediaNotification(sbn: StatusBarNotification): Boolean {
-    return sbn.notification.isMediaNotification()
-}
+    fun destroy()
 
-/**
- * Allow recommendations from smartspace to show in media controls. Requires
- * [Utils.useQsMediaPlayer] to be enabled. On by default, but can be disabled by setting to 0
- */
-private fun allowMediaRecommendations(context: Context): Boolean {
-    val flag =
-        Settings.Secure.getInt(
-            context.contentResolver,
-            Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
-            1
-        )
-    return Utils.useQsMediaPlayer(context) && flag > 0
-}
+    /** Sets resume action. */
+    fun setResumeAction(key: String, action: Runnable?)
 
-/** A class that facilitates management and loading of Media Data, ready for binding. */
-@SysUISingleton
-class MediaDataManager(
-    private val context: Context,
-    @Background private val backgroundExecutor: Executor,
-    @Main private val uiExecutor: Executor,
-    @Main private val foregroundExecutor: DelayableExecutor,
-    private val mediaControllerFactory: MediaControllerFactory,
-    private val broadcastDispatcher: BroadcastDispatcher,
-    dumpManager: DumpManager,
-    mediaTimeoutListener: MediaTimeoutListener,
-    mediaResumeListener: MediaResumeListener,
-    mediaSessionBasedFilter: MediaSessionBasedFilter,
-    mediaDeviceManager: MediaDeviceManager,
-    mediaDataCombineLatest: MediaDataCombineLatest,
-    private val mediaDataFilter: MediaDataFilter,
-    private val activityStarter: ActivityStarter,
-    private val smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
-    private var useMediaResumption: Boolean,
-    private val useQsMediaPlayer: Boolean,
-    private val systemClock: SystemClock,
-    private val tunerService: TunerService,
-    private val mediaFlags: MediaFlags,
-    private val logger: MediaUiEventLogger,
-    private val smartspaceManager: SmartspaceManager?,
-    private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
-) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener {
-
-    companion object {
-        // UI surface label for subscribing Smartspace updates.
-        @JvmField val SMARTSPACE_UI_SURFACE_LABEL = "media_data_manager"
-
-        // Smartspace package name's extra key.
-        @JvmField val EXTRAS_MEDIA_SOURCE_PACKAGE_NAME = "package_name"
-
-        // Maximum number of actions allowed in compact view
-        @JvmField val MAX_COMPACT_ACTIONS = 3
-
-        // Maximum number of actions allowed in expanded view
-        @JvmField val MAX_NOTIFICATION_ACTIONS = MediaViewHolder.genericButtonIds.size
-    }
-
-    private val themeText =
-        com.android.settingslib.Utils.getColorAttr(
-                context,
-                com.android.internal.R.attr.textColorPrimary
-            )
-            .defaultColor
-
-    // Internal listeners are part of the internal pipeline. External listeners (those registered
-    // with [MediaDeviceManager.addListener]) receive events after they have propagated through
-    // the internal pipeline.
-    // Another way to think of the distinction between internal and external listeners is the
-    // following. Internal listeners are listeners that MediaDataManager depends on, and external
-    // listeners are listeners that depend on MediaDataManager.
-    // TODO(b/159539991#comment5): Move internal listeners to separate package.
-    private val internalListeners: MutableSet<Listener> = mutableSetOf()
-    private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
-    // There should ONLY be at most one Smartspace media recommendation.
-    var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
-    @Keep private var smartspaceSession: SmartspaceSession? = null
-    private var allowMediaRecommendations = allowMediaRecommendations(context)
-
-    private val artworkWidth =
-        context.resources.getDimensionPixelSize(
-            com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize
-        )
-    private val artworkHeight =
-        context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded)
-
-    @SuppressLint("WrongConstant") // sysui allowed to call STATUS_BAR_SERVICE
-    private val statusBarManager =
-        context.getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager
-
-    /** Check whether this notification is an RCN */
-    private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean {
-        return sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE)
-    }
-
-    @Inject
-    constructor(
-        context: Context,
-        threadFactory: ThreadFactory,
-        @Main uiExecutor: Executor,
-        @Main foregroundExecutor: DelayableExecutor,
-        mediaControllerFactory: MediaControllerFactory,
-        dumpManager: DumpManager,
-        broadcastDispatcher: BroadcastDispatcher,
-        mediaTimeoutListener: MediaTimeoutListener,
-        mediaResumeListener: MediaResumeListener,
-        mediaSessionBasedFilter: MediaSessionBasedFilter,
-        mediaDeviceManager: MediaDeviceManager,
-        mediaDataCombineLatest: MediaDataCombineLatest,
-        mediaDataFilter: MediaDataFilter,
-        activityStarter: ActivityStarter,
-        smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
-        clock: SystemClock,
-        tunerService: TunerService,
-        mediaFlags: MediaFlags,
-        logger: MediaUiEventLogger,
-        smartspaceManager: SmartspaceManager?,
-        keyguardUpdateMonitor: KeyguardUpdateMonitor,
-    ) : this(
-        context,
-        // Loading bitmap for UMO background can take longer time, so it cannot run on the default
-        // background thread. Use a custom thread for media.
-        threadFactory.buildExecutorOnNewThread(TAG),
-        uiExecutor,
-        foregroundExecutor,
-        mediaControllerFactory,
-        broadcastDispatcher,
-        dumpManager,
-        mediaTimeoutListener,
-        mediaResumeListener,
-        mediaSessionBasedFilter,
-        mediaDeviceManager,
-        mediaDataCombineLatest,
-        mediaDataFilter,
-        activityStarter,
-        smartspaceMediaDataProvider,
-        Utils.useMediaResumption(context),
-        Utils.useQsMediaPlayer(context),
-        clock,
-        tunerService,
-        mediaFlags,
-        logger,
-        smartspaceManager,
-        keyguardUpdateMonitor,
-    )
-
-    private val appChangeReceiver =
-        object : BroadcastReceiver() {
-            override fun onReceive(context: Context, intent: Intent) {
-                when (intent.action) {
-                    Intent.ACTION_PACKAGES_SUSPENDED -> {
-                        val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST)
-                        packages?.forEach { removeAllForPackage(it) }
-                    }
-                    Intent.ACTION_PACKAGE_REMOVED,
-                    Intent.ACTION_PACKAGE_RESTARTED -> {
-                        intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) }
-                    }
-                }
-            }
-        }
-
-    init {
-        dumpManager.registerDumpable(TAG, this)
-
-        // Initialize the internal processing pipeline. The listeners at the front of the pipeline
-        // are set as internal listeners so that they receive events. From there, events are
-        // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter,
-        // so it is responsible for dispatching events to external listeners. To achieve this,
-        // external listeners that are registered with [MediaDataManager.addListener] are actually
-        // registered as listeners to mediaDataFilter.
-        addInternalListener(mediaTimeoutListener)
-        addInternalListener(mediaResumeListener)
-        addInternalListener(mediaSessionBasedFilter)
-        mediaSessionBasedFilter.addListener(mediaDeviceManager)
-        mediaSessionBasedFilter.addListener(mediaDataCombineLatest)
-        mediaDeviceManager.addListener(mediaDataCombineLatest)
-        mediaDataCombineLatest.addListener(mediaDataFilter)
-
-        // Set up links back into the pipeline for listeners that need to send events upstream.
-        mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean ->
-            setTimedOut(key, timedOut)
-        }
-        mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
-            updateState(key, state)
-        }
-        mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) }
-        mediaResumeListener.setManager(this)
-        mediaDataFilter.mediaDataManager = this
-
-        val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED)
-        broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL)
-
-        val uninstallFilter =
-            IntentFilter().apply {
-                addAction(Intent.ACTION_PACKAGE_REMOVED)
-                addAction(Intent.ACTION_PACKAGE_RESTARTED)
-                addDataScheme("package")
-            }
-        // BroadcastDispatcher does not allow filters with data schemes
-        context.registerReceiver(appChangeReceiver, uninstallFilter)
-
-        // Register for Smartspace data updates.
-        smartspaceMediaDataProvider.registerListener(this)
-        smartspaceSession =
-            smartspaceManager?.createSmartspaceSession(
-                SmartspaceConfig.Builder(context, SMARTSPACE_UI_SURFACE_LABEL).build()
-            )
-        smartspaceSession?.let {
-            it.addOnTargetsAvailableListener(
-                // Use a main uiExecutor thread listening to Smartspace updates instead of using
-                // the existing background executor.
-                // SmartspaceSession has scheduled routine updates which can be unpredictable on
-                // test simulators, using the backgroundExecutor makes it's hard to test the threads
-                // numbers.
-                uiExecutor,
-                SmartspaceSession.OnTargetsAvailableListener { targets ->
-                    smartspaceMediaDataProvider.onTargetsAvailable(targets)
-                }
-            )
-        }
-        smartspaceSession?.let { it.requestSmartspaceUpdate() }
-        tunerService.addTunable(
-            object : TunerService.Tunable {
-                override fun onTuningChanged(key: String?, newValue: String?) {
-                    allowMediaRecommendations = allowMediaRecommendations(context)
-                    if (!allowMediaRecommendations) {
-                        dismissSmartspaceRecommendation(
-                            key = smartspaceMediaData.targetId,
-                            delay = 0L
-                        )
-                    }
-                }
-            },
-            Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION
-        )
-    }
-
-    fun destroy() {
-        smartspaceMediaDataProvider.unregisterListener(this)
-        smartspaceSession?.close()
-        smartspaceSession = null
-        context.unregisterReceiver(appChangeReceiver)
-    }
-
-    fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
-        if (useQsMediaPlayer && isMediaNotification(sbn)) {
-            var isNewlyActiveEntry = false
-            Assert.isMainThread()
-            val oldKey = findExistingEntry(key, sbn.packageName)
-            if (oldKey == null) {
-                val instanceId = logger.getNewInstanceId()
-                val temp =
-                    LOADING.copy(
-                        packageName = sbn.packageName,
-                        instanceId = instanceId,
-                        createdTimestampMillis = systemClock.currentTimeMillis(),
-                    )
-                mediaEntries.put(key, temp)
-                isNewlyActiveEntry = true
-            } else if (oldKey != key) {
-                // Resume -> active conversion; move to new key
-                val oldData = mediaEntries.remove(oldKey)!!
-                isNewlyActiveEntry = true
-                mediaEntries.put(key, oldData)
-            }
-            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
-        } else {
-            onNotificationRemoved(key)
-        }
-    }
-
-    private fun removeAllForPackage(packageName: String) {
-        Assert.isMainThread()
-        val toRemove = mediaEntries.filter { it.value.packageName == packageName }
-        toRemove.forEach { removeEntry(it.key) }
-    }
-
-    fun setResumeAction(key: String, action: Runnable?) {
-        mediaEntries.get(key)?.let {
-            it.resumeAction = action
-            it.hasCheckedForResume = true
-        }
-    }
-
+    /** Adds resume media data. */
     fun addResumptionControls(
         userId: Int,
         desc: MediaDescription,
@@ -442,1184 +57,45 @@
         appName: String,
         appIntent: PendingIntent,
         packageName: String
-    ) {
-        // Resume controls don't have a notification key, so store by package name instead
-        if (!mediaEntries.containsKey(packageName)) {
-            val instanceId = logger.getNewInstanceId()
-            val appUid =
-                try {
-                    context.packageManager.getApplicationInfo(packageName, 0)?.uid!!
-                } catch (e: PackageManager.NameNotFoundException) {
-                    Log.w(TAG, "Could not get app UID for $packageName", e)
-                    Process.INVALID_UID
-                }
-
-            val resumeData =
-                LOADING.copy(
-                    packageName = packageName,
-                    resumeAction = action,
-                    hasCheckedForResume = true,
-                    instanceId = instanceId,
-                    appUid = appUid,
-                    createdTimestampMillis = systemClock.currentTimeMillis(),
-                )
-            mediaEntries.put(packageName, resumeData)
-            logSingleVsMultipleMediaAdded(appUid, packageName, instanceId)
-            logger.logResumeMediaAdded(appUid, packageName, instanceId)
-        }
-        backgroundExecutor.execute {
-            loadMediaDataInBgForResumption(
-                userId,
-                desc,
-                action,
-                token,
-                appName,
-                appIntent,
-                packageName
-            )
-        }
-    }
-
-    /**
-     * Check if there is an existing entry that matches the key or package name. Returns the key
-     * that matches, or null if not found.
-     */
-    private fun findExistingEntry(key: String, packageName: String): String? {
-        if (mediaEntries.containsKey(key)) {
-            return key
-        }
-        // Check if we already had a resume player
-        if (mediaEntries.containsKey(packageName)) {
-            return packageName
-        }
-        return null
-    }
-
-    private fun loadMediaData(
-        key: String,
-        sbn: StatusBarNotification,
-        oldKey: String?,
-        isNewlyActiveEntry: Boolean = false,
-    ) {
-        backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
-    }
-
-    /** Add a listener for changes in this class */
-    fun addListener(listener: Listener) {
-        // mediaDataFilter is the current end of the internal pipeline. Register external
-        // listeners as listeners to it.
-        mediaDataFilter.addListener(listener)
-    }
-
-    /** Remove a listener for changes in this class */
-    fun removeListener(listener: Listener) {
-        // Since mediaDataFilter is the current end of the internal pipelie, external listeners
-        // have been registered to it. So, they need to be removed from it too.
-        mediaDataFilter.removeListener(listener)
-    }
-
-    /** Add a listener for internal events. */
-    private fun addInternalListener(listener: Listener) = internalListeners.add(listener)
-
-    /**
-     * Notify internal listeners of media loaded event.
-     *
-     * External listeners registered with [addListener] will be notified after the event propagates
-     * through the internal listener pipeline.
-     */
-    private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
-        internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) }
-    }
-
-    /**
-     * Notify internal listeners of Smartspace media loaded event.
-     *
-     * External listeners registered with [addListener] will be notified after the event propagates
-     * through the internal listener pipeline.
-     */
-    private fun notifySmartspaceMediaDataLoaded(key: String, info: SmartspaceMediaData) {
-        internalListeners.forEach { it.onSmartspaceMediaDataLoaded(key, info) }
-    }
-
-    /**
-     * Notify internal listeners of media removed event.
-     *
-     * External listeners registered with [addListener] will be notified after the event propagates
-     * through the internal listener pipeline.
-     */
-    private fun notifyMediaDataRemoved(key: String) {
-        internalListeners.forEach { it.onMediaDataRemoved(key) }
-    }
-
-    /**
-     * Notify internal listeners of Smartspace media removed event.
-     *
-     * External listeners registered with [addListener] will be notified after the event propagates
-     * through the internal listener pipeline.
-     *
-     * @param immediately indicates should apply the UI changes immediately, otherwise wait until
-     *   the next refresh-round before UI becomes visible. Should only be true if the update is
-     *   initiated by user's interaction.
-     */
-    private fun notifySmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
-        internalListeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
-    }
-
-    /**
-     * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This
-     * will make the player not active anymore, hiding it from QQS and Keyguard.
-     *
-     * @see MediaData.active
-     */
-    internal fun setTimedOut(key: String, timedOut: Boolean, forceUpdate: Boolean = false) {
-        mediaEntries[key]?.let {
-            if (timedOut && !forceUpdate) {
-                // Only log this event when media expires on its own
-                logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId)
-            }
-            if (it.active == !timedOut && !forceUpdate) {
-                if (it.resumption) {
-                    if (DEBUG) Log.d(TAG, "timing out resume player $key")
-                    dismissMediaData(key, 0L /* delay */)
-                }
-                return
-            }
-            // Update last active if media was still active.
-            if (it.active) {
-                it.lastActive = systemClock.elapsedRealtime()
-            }
-            it.active = !timedOut
-            if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut")
-            onMediaDataLoaded(key, key, it)
-        }
-
-        if (key == smartspaceMediaData.targetId) {
-            if (DEBUG) Log.d(TAG, "smartspace card expired")
-            dismissSmartspaceRecommendation(key, delay = 0L)
-        }
-    }
-
-    /** Called when the player's [PlaybackState] has been updated with new actions and/or state */
-    private fun updateState(key: String, state: PlaybackState) {
-        mediaEntries.get(key)?.let {
-            val token = it.token
-            if (token == null) {
-                if (DEBUG) Log.d(TAG, "State updated, but token was null")
-                return
-            }
-            val actions =
-                createActionsFromState(
-                    it.packageName,
-                    mediaControllerFactory.create(it.token),
-                    UserHandle(it.userId)
-                )
-
-            // Control buttons
-            // If flag is enabled and controller has a PlaybackState,
-            // create actions from session info
-            // otherwise, no need to update semantic actions.
-            val data =
-                if (actions != null) {
-                    it.copy(semanticActions = actions, isPlaying = isPlayingState(state.state))
-                } else {
-                    it.copy(isPlaying = isPlayingState(state.state))
-                }
-            if (DEBUG) Log.d(TAG, "State updated outside of notification")
-            onMediaDataLoaded(key, key, data)
-        }
-    }
-
-    private fun removeEntry(key: String, logEvent: Boolean = true) {
-        mediaEntries.remove(key)?.let {
-            if (logEvent) {
-                logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
-            }
-        }
-        notifyMediaDataRemoved(key)
-    }
+    )
 
     /** Dismiss a media entry. Returns false if the key was not found. */
-    fun dismissMediaData(key: String, delay: Long): Boolean {
-        val existed = mediaEntries[key] != null
-        backgroundExecutor.execute {
-            mediaEntries[key]?.let { mediaData ->
-                if (mediaData.isLocalSession()) {
-                    mediaData.token?.let {
-                        val mediaController = mediaControllerFactory.create(it)
-                        mediaController.transportControls.stop()
-                    }
-                }
-            }
-        }
-        foregroundExecutor.executeDelayed({ removeEntry(key) }, delay)
-        return existed
-    }
+    fun dismissMediaData(key: String, delay: Long): Boolean
 
     /**
      * Called whenever the recommendation has been expired or removed by the user. This will remove
      * the recommendation card entirely from the carousel.
      */
-    fun dismissSmartspaceRecommendation(key: String, delay: Long) {
-        if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
-            // If this doesn't match, or we've already invalidated the data, no action needed
-            return
-        }
-
-        if (DEBUG) Log.d(TAG, "Dismissing Smartspace media target")
-        if (smartspaceMediaData.isActive) {
-            smartspaceMediaData =
-                EMPTY_SMARTSPACE_MEDIA_DATA.copy(
-                    targetId = smartspaceMediaData.targetId,
-                    instanceId = smartspaceMediaData.instanceId
-                )
-        }
-        foregroundExecutor.executeDelayed(
-            { notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = true) },
-            delay
-        )
-    }
+    fun dismissSmartspaceRecommendation(key: String, delay: Long)
 
     /** Called when the recommendation card should no longer be visible in QQS or lockscreen */
-    fun setRecommendationInactive(key: String) {
-        if (!mediaFlags.isPersistentSsCardEnabled()) {
-            Log.e(TAG, "Only persistent recommendation can be inactive!")
-            return
-        }
-        if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive")
+    fun setRecommendationInactive(key: String)
 
-        if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
-            // If this doesn't match, or we've already invalidated the data, no action needed
-            return
-        }
+    /** Invoked when notification is removed. */
+    fun onNotificationRemoved(key: String)
 
-        smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
-        notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
-    }
-
-    private fun loadMediaDataInBgForResumption(
-        userId: Int,
-        desc: MediaDescription,
-        resumeAction: Runnable,
-        token: MediaSession.Token,
-        appName: String,
-        appIntent: PendingIntent,
-        packageName: String
-    ) {
-        if (desc.title.isNullOrBlank()) {
-            Log.e(TAG, "Description incomplete")
-            // Delete the placeholder entry
-            mediaEntries.remove(packageName)
-            return
-        }
-
-        if (DEBUG) {
-            Log.d(TAG, "adding track for $userId from browser: $desc")
-        }
-
-        val currentEntry = mediaEntries.get(packageName)
-        val appUid = currentEntry?.appUid ?: Process.INVALID_UID
-
-        // Album art
-        var artworkBitmap = desc.iconBitmap
-        if (artworkBitmap == null && desc.iconUri != null) {
-            artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName)
-        }
-        val artworkIcon =
-            if (artworkBitmap != null) {
-                Icon.createWithBitmap(artworkBitmap)
-            } else {
-                null
-            }
-
-        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
-        val isExplicit =
-            desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
-                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
-
-        val progress =
-            if (mediaFlags.isResumeProgressEnabled()) {
-                MediaDataUtils.getDescriptionProgress(desc.extras)
-            } else null
-
-        val mediaAction = getResumeMediaAction(resumeAction)
-        val lastActive = systemClock.elapsedRealtime()
-        val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
-        foregroundExecutor.execute {
-            onMediaDataLoaded(
-                packageName,
-                null,
-                MediaData(
-                    userId,
-                    true,
-                    appName,
-                    null,
-                    desc.subtitle,
-                    desc.title,
-                    artworkIcon,
-                    listOf(mediaAction),
-                    listOf(0),
-                    MediaButton(playOrPause = mediaAction),
-                    packageName,
-                    token,
-                    appIntent,
-                    device = null,
-                    active = false,
-                    resumeAction = resumeAction,
-                    resumption = true,
-                    notificationKey = packageName,
-                    hasCheckedForResume = true,
-                    lastActive = lastActive,
-                    createdTimestampMillis = createdTimestampMillis,
-                    instanceId = instanceId,
-                    appUid = appUid,
-                    isExplicit = isExplicit,
-                    resumeProgress = progress,
-                )
-            )
-        }
-    }
-
-    fun loadMediaDataInBg(
-        key: String,
-        sbn: StatusBarNotification,
-        oldKey: String?,
-        isNewlyActiveEntry: Boolean = false,
-    ) {
-        val token =
-            sbn.notification.extras.getParcelable(
-                Notification.EXTRA_MEDIA_SESSION,
-                MediaSession.Token::class.java
-            )
-        if (token == null) {
-            return
-        }
-        val mediaController = mediaControllerFactory.create(token)
-        val metadata = mediaController.metadata
-        val notif: Notification = sbn.notification
-
-        val appInfo =
-            notif.extras.getParcelable(
-                Notification.EXTRA_BUILDER_APPLICATION_INFO,
-                ApplicationInfo::class.java
-            )
-                ?: getAppInfoFromPackage(sbn.packageName)
-
-        // App name
-        val appName = getAppName(sbn, appInfo)
-
-        // Song name
-        var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
-        if (song.isNullOrBlank()) {
-            song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
-        }
-        if (song.isNullOrBlank()) {
-            song = HybridGroupManager.resolveTitle(notif)
-        }
-        if (song.isNullOrBlank()) {
-            // For apps that don't include a title, log and add a placeholder
-            song = context.getString(R.string.controls_media_empty_title, appName)
-            try {
-                statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier)
-            } catch (e: RuntimeException) {
-                Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}")
-            }
-        }
-
-        // Album art
-        var artworkBitmap = metadata?.let { loadBitmapFromUri(it) }
-        if (artworkBitmap == null) {
-            artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART)
-        }
-        if (artworkBitmap == null) {
-            artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
-        }
-        val artWorkIcon =
-            if (artworkBitmap == null) {
-                notif.getLargeIcon()
-            } else {
-                Icon.createWithBitmap(artworkBitmap)
-            }
-
-        // App Icon
-        val smallIcon = sbn.notification.smallIcon
-
-        // Explicit Indicator
-        var isExplicit = false
-        val mediaMetadataCompat = MediaMetadataCompat.fromMediaMetadata(metadata)
-        isExplicit =
-            mediaMetadataCompat?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
-                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
-
-        // Artist name
-        var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
-        if (artist.isNullOrBlank()) {
-            artist = HybridGroupManager.resolveText(notif)
-        }
-
-        // Device name (used for remote cast notifications)
-        var device: MediaDeviceData? = null
-        if (isRemoteCastNotification(sbn)) {
-            val extras = sbn.notification.extras
-            val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null)
-            val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1)
-            val deviceIntent =
-                extras.getParcelable(
-                    Notification.EXTRA_MEDIA_REMOTE_INTENT,
-                    PendingIntent::class.java
-                )
-            Log.d(TAG, "$key is RCN for $deviceName")
-
-            if (deviceName != null && deviceIcon > -1) {
-                // Name and icon must be present, but intent may be null
-                val enabled = deviceIntent != null && deviceIntent.isActivity
-                val deviceDrawable =
-                    Icon.createWithResource(sbn.packageName, deviceIcon)
-                        .loadDrawable(sbn.getPackageContext(context))
-                device =
-                    MediaDeviceData(
-                        enabled,
-                        deviceDrawable,
-                        deviceName,
-                        deviceIntent,
-                        showBroadcastButton = false
-                    )
-            }
-        }
-
-        // Control buttons
-        // If flag is enabled and controller has a PlaybackState, create actions from session info
-        // Otherwise, use the notification actions
-        var actionIcons: List<MediaAction> = emptyList()
-        var actionsToShowCollapsed: List<Int> = emptyList()
-        val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user)
-        if (semanticActions == null) {
-            val actions = createActionsFromNotification(sbn)
-            actionIcons = actions.first
-            actionsToShowCollapsed = actions.second
-        }
-
-        val playbackLocation =
-            if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
-            else if (
-                mediaController.playbackInfo?.playbackType ==
-                    MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL
-            )
-                MediaData.PLAYBACK_LOCAL
-            else MediaData.PLAYBACK_CAST_LOCAL
-        val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
-
-        val currentEntry = mediaEntries.get(key)
-        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
-        val appUid = appInfo?.uid ?: Process.INVALID_UID
-
-        if (isNewlyActiveEntry) {
-            logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId)
-            logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation)
-        } else if (playbackLocation != currentEntry?.playbackLocation) {
-            logger.logPlaybackLocationChange(appUid, sbn.packageName, instanceId, playbackLocation)
-        }
-
-        val lastActive = systemClock.elapsedRealtime()
-        val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
-        foregroundExecutor.execute {
-            val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
-            val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
-            val active = mediaEntries[key]?.active ?: true
-            onMediaDataLoaded(
-                key,
-                oldKey,
-                MediaData(
-                    sbn.normalizedUserId,
-                    true,
-                    appName,
-                    smallIcon,
-                    artist,
-                    song,
-                    artWorkIcon,
-                    actionIcons,
-                    actionsToShowCollapsed,
-                    semanticActions,
-                    sbn.packageName,
-                    token,
-                    notif.contentIntent,
-                    device,
-                    active,
-                    resumeAction = resumeAction,
-                    playbackLocation = playbackLocation,
-                    notificationKey = key,
-                    hasCheckedForResume = hasCheckedForResume,
-                    isPlaying = isPlaying,
-                    isClearable = !sbn.isOngoing,
-                    lastActive = lastActive,
-                    createdTimestampMillis = createdTimestampMillis,
-                    instanceId = instanceId,
-                    appUid = appUid,
-                    isExplicit = isExplicit,
-                )
-            )
-        }
-    }
-
-    private fun logSingleVsMultipleMediaAdded(
-        appUid: Int,
-        packageName: String,
-        instanceId: InstanceId
-    ) {
-        if (mediaEntries.size == 1) {
-            logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId)
-        } else if (mediaEntries.size == 2) {
-            // Since this method is only called when there is a new media session added.
-            // logging needed once there is more than one media session in carousel.
-            logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId)
-        }
-    }
-
-    private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? {
-        try {
-            return context.packageManager.getApplicationInfo(packageName, 0)
-        } catch (e: PackageManager.NameNotFoundException) {
-            Log.w(TAG, "Could not get app info for $packageName", e)
-        }
-        return null
-    }
-
-    private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String {
-        val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME)
-        if (name != null) {
-            return name
-        }
-
-        return if (appInfo != null) {
-            context.packageManager.getApplicationLabel(appInfo).toString()
-        } else {
-            sbn.packageName
-        }
-    }
-
-    /** Generate action buttons based on notification actions */
-    private fun createActionsFromNotification(
-        sbn: StatusBarNotification
-    ): Pair<List<MediaAction>, List<Int>> {
-        val notif = sbn.notification
-        val actionIcons: MutableList<MediaAction> = ArrayList()
-        val actions = notif.actions
-        var actionsToShowCollapsed =
-            notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList()
-                ?: mutableListOf()
-        if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) {
-            Log.e(
-                TAG,
-                "Too many compact actions for ${sbn.key}," +
-                    "limiting to first $MAX_COMPACT_ACTIONS"
-            )
-            actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS)
-        }
-
-        if (actions != null) {
-            for ((index, action) in actions.withIndex()) {
-                if (index == MAX_NOTIFICATION_ACTIONS) {
-                    Log.w(
-                        TAG,
-                        "Too many notification actions for ${sbn.key}," +
-                            " limiting to first $MAX_NOTIFICATION_ACTIONS"
-                    )
-                    break
-                }
-                if (action.getIcon() == null) {
-                    if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}")
-                    actionsToShowCollapsed.remove(index)
-                    continue
-                }
-                val runnable =
-                    if (action.actionIntent != null) {
-                        Runnable {
-                            if (action.actionIntent.isActivity) {
-                                activityStarter.startPendingIntentDismissingKeyguard(
-                                    action.actionIntent
-                                )
-                            } else if (action.isAuthenticationRequired()) {
-                                activityStarter.dismissKeyguardThenExecute(
-                                    {
-                                        var result = sendPendingIntent(action.actionIntent)
-                                        result
-                                    },
-                                    {},
-                                    true
-                                )
-                            } else {
-                                sendPendingIntent(action.actionIntent)
-                            }
-                        }
-                    } else {
-                        null
-                    }
-                val mediaActionIcon =
-                    if (action.getIcon()?.getType() == Icon.TYPE_RESOURCE) {
-                            Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId())
-                        } else {
-                            action.getIcon()
-                        }
-                        .setTint(themeText)
-                        .loadDrawable(context)
-                val mediaAction = MediaAction(mediaActionIcon, runnable, action.title, null)
-                actionIcons.add(mediaAction)
-            }
-        }
-        return Pair(actionIcons, actionsToShowCollapsed)
-    }
-
-    /**
-     * Generates action button info for this media session based on the PlaybackState
-     *
-     * @param packageName Package name for the media app
-     * @param controller MediaController for the current session
-     * @return a Pair consisting of a list of media actions, and a list of ints representing which
-     *
-     * ```
-     *      of those actions should be shown in the compact player
-     * ```
-     */
-    private fun createActionsFromState(
-        packageName: String,
-        controller: MediaController,
-        user: UserHandle
-    ): MediaButton? {
-        val state = controller.playbackState
-        if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) {
-            return null
-        }
-
-        // First, check for standard actions
-        val playOrPause =
-            if (isConnectingState(state.state)) {
-                // Spinner needs to be animating to render anything. Start it here.
-                val drawable =
-                    context.getDrawable(com.android.internal.R.drawable.progress_small_material)
-                (drawable as Animatable).start()
-                MediaAction(
-                    drawable,
-                    null, // no action to perform when clicked
-                    context.getString(R.string.controls_media_button_connecting),
-                    context.getDrawable(R.drawable.ic_media_connecting_container),
-                    // Specify a rebind id to prevent the spinner from restarting on later binds.
-                    com.android.internal.R.drawable.progress_small_material
-                )
-            } else if (isPlayingState(state.state)) {
-                getStandardAction(controller, state.actions, PlaybackState.ACTION_PAUSE)
-            } else {
-                getStandardAction(controller, state.actions, PlaybackState.ACTION_PLAY)
-            }
-        val prevButton =
-            getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_PREVIOUS)
-        val nextButton =
-            getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_NEXT)
-
-        // Then, create a way to build any custom actions that will be needed
-        val customActions =
-            state.customActions
-                .asSequence()
-                .filterNotNull()
-                .map { getCustomAction(state, packageName, controller, it) }
-                .iterator()
-        fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
-
-        // Finally, assign the remaining button slots: play/pause A B C D
-        // A = previous, else custom action (if not reserved)
-        // B = next, else custom action (if not reserved)
-        // C and D are always custom actions
-        val reservePrev =
-            controller.extras?.getBoolean(
-                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV
-            ) == true
-        val reserveNext =
-            controller.extras?.getBoolean(
-                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT
-            ) == true
-
-        val prevOrCustom =
-            if (prevButton != null) {
-                prevButton
-            } else if (!reservePrev) {
-                nextCustomAction()
-            } else {
-                null
-            }
-
-        val nextOrCustom =
-            if (nextButton != null) {
-                nextButton
-            } else if (!reserveNext) {
-                nextCustomAction()
-            } else {
-                null
-            }
-
-        return MediaButton(
-            playOrPause,
-            nextOrCustom,
-            prevOrCustom,
-            nextCustomAction(),
-            nextCustomAction(),
-            reserveNext,
-            reservePrev
-        )
-    }
-
-    /**
-     * Create a [MediaAction] for a given action and media session
-     *
-     * @param controller MediaController for the session
-     * @param stateActions The actions included with the session's [PlaybackState]
-     * @param action A [PlaybackState.Actions] value representing what action to generate. One of:
-     * ```
-     *      [PlaybackState.ACTION_PLAY]
-     *      [PlaybackState.ACTION_PAUSE]
-     *      [PlaybackState.ACTION_SKIP_TO_PREVIOUS]
-     *      [PlaybackState.ACTION_SKIP_TO_NEXT]
-     * @return
-     * ```
-     *
-     * A [MediaAction] with correct values set, or null if the state doesn't support it
-     */
-    private fun getStandardAction(
-        controller: MediaController,
-        stateActions: Long,
-        @PlaybackState.Actions action: Long
-    ): MediaAction? {
-        if (!includesAction(stateActions, action)) {
-            return null
-        }
-
-        return when (action) {
-            PlaybackState.ACTION_PLAY -> {
-                MediaAction(
-                    context.getDrawable(R.drawable.ic_media_play),
-                    { controller.transportControls.play() },
-                    context.getString(R.string.controls_media_button_play),
-                    context.getDrawable(R.drawable.ic_media_play_container)
-                )
-            }
-            PlaybackState.ACTION_PAUSE -> {
-                MediaAction(
-                    context.getDrawable(R.drawable.ic_media_pause),
-                    { controller.transportControls.pause() },
-                    context.getString(R.string.controls_media_button_pause),
-                    context.getDrawable(R.drawable.ic_media_pause_container)
-                )
-            }
-            PlaybackState.ACTION_SKIP_TO_PREVIOUS -> {
-                MediaAction(
-                    context.getDrawable(R.drawable.ic_media_prev),
-                    { controller.transportControls.skipToPrevious() },
-                    context.getString(R.string.controls_media_button_prev),
-                    null
-                )
-            }
-            PlaybackState.ACTION_SKIP_TO_NEXT -> {
-                MediaAction(
-                    context.getDrawable(R.drawable.ic_media_next),
-                    { controller.transportControls.skipToNext() },
-                    context.getString(R.string.controls_media_button_next),
-                    null
-                )
-            }
-            else -> null
-        }
-    }
-
-    /** Check whether the actions from a [PlaybackState] include a specific action */
-    private fun includesAction(stateActions: Long, @PlaybackState.Actions action: Long): Boolean {
-        if (
-            (action == PlaybackState.ACTION_PLAY || action == PlaybackState.ACTION_PAUSE) &&
-                (stateActions and PlaybackState.ACTION_PLAY_PAUSE > 0L)
-        ) {
-            return true
-        }
-        return (stateActions and action != 0L)
-    }
-
-    /** Get a [MediaAction] representing a [PlaybackState.CustomAction] */
-    private fun getCustomAction(
-        state: PlaybackState,
-        packageName: String,
-        controller: MediaController,
-        customAction: PlaybackState.CustomAction
-    ): MediaAction {
-        return MediaAction(
-            Icon.createWithResource(packageName, customAction.icon).loadDrawable(context),
-            { controller.transportControls.sendCustomAction(customAction, customAction.extras) },
-            customAction.name,
-            null
-        )
-    }
-
-    /** Load a bitmap from the various Art metadata URIs */
-    private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? {
-        for (uri in ART_URIS) {
-            val uriString = metadata.getString(uri)
-            if (!TextUtils.isEmpty(uriString)) {
-                val albumArt = loadBitmapFromUri(Uri.parse(uriString))
-                if (albumArt != null) {
-                    if (DEBUG) Log.d(TAG, "loaded art from $uri")
-                    return albumArt
-                }
-            }
-        }
-        return null
-    }
-
-    private fun sendPendingIntent(intent: PendingIntent): Boolean {
-        return try {
-            val options = BroadcastOptions.makeBasic()
-            options.setInteractive(true)
-            options.setPendingIntentBackgroundActivityStartMode(
-                ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
-            )
-            intent.send(options.toBundle())
-            true
-        } catch (e: PendingIntent.CanceledException) {
-            Log.d(TAG, "Intent canceled", e)
-            false
-        }
-    }
-
-    /** Returns a bitmap if the user can access the given URI, else null */
-    private fun loadBitmapFromUriForUser(
-        uri: Uri,
-        userId: Int,
-        appUid: Int,
-        packageName: String,
-    ): Bitmap? {
-        try {
-            val ugm = UriGrantsManager.getService()
-            ugm.checkGrantUriPermission_ignoreNonSystem(
-                appUid,
-                packageName,
-                ContentProvider.getUriWithoutUserId(uri),
-                Intent.FLAG_GRANT_READ_URI_PERMISSION,
-                ContentProvider.getUserIdFromUri(uri, userId)
-            )
-            return loadBitmapFromUri(uri)
-        } catch (e: SecurityException) {
-            Log.e(TAG, "Failed to get URI permission: $e")
-        }
-        return null
-    }
-
-    /**
-     * Load a bitmap from a URI
-     *
-     * @param uri the uri to load
-     * @return bitmap, or null if couldn't be loaded
-     */
-    private fun loadBitmapFromUri(uri: Uri): Bitmap? {
-        // ImageDecoder requires a scheme of the following types
-        if (uri.scheme == null) {
-            return null
-        }
-
-        if (
-            !uri.scheme.equals(ContentResolver.SCHEME_CONTENT) &&
-                !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) &&
-                !uri.scheme.equals(ContentResolver.SCHEME_FILE)
-        ) {
-            return null
-        }
-
-        val source = ImageDecoder.createSource(context.contentResolver, uri)
-        return try {
-            ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
-                val width = info.size.width
-                val height = info.size.height
-                val scale =
-                    MediaDataUtils.getScaleFactor(
-                        APair(width, height),
-                        APair(artworkWidth, artworkHeight)
-                    )
-
-                // Downscale if needed
-                if (scale != 0f && scale < 1) {
-                    decoder.setTargetSize((scale * width).toInt(), (scale * height).toInt())
-                }
-                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
-            }
-        } catch (e: IOException) {
-            Log.e(TAG, "Unable to load bitmap", e)
-            null
-        } catch (e: RuntimeException) {
-            Log.e(TAG, "Unable to load bitmap", e)
-            null
-        }
-    }
-
-    private fun getResumeMediaAction(action: Runnable): MediaAction {
-        return MediaAction(
-            Icon.createWithResource(context, R.drawable.ic_media_play)
-                .setTint(themeText)
-                .loadDrawable(context),
-            action,
-            context.getString(R.string.controls_media_resume),
-            context.getDrawable(R.drawable.ic_media_play_container)
-        )
-    }
-
-    fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) =
-        traceSection("MediaDataManager#onMediaDataLoaded") {
-            Assert.isMainThread()
-            if (mediaEntries.containsKey(key)) {
-                // Otherwise this was removed already
-                mediaEntries.put(key, data)
-                notifyMediaDataLoaded(key, oldKey, data)
-            }
-        }
-
-    override fun onSmartspaceTargetsUpdated(targets: List<Parcelable>) {
-        if (!allowMediaRecommendations) {
-            if (DEBUG) Log.d(TAG, "Smartspace recommendation is disabled in Settings.")
-            return
-        }
-
-        val mediaTargets = targets.filterIsInstance<SmartspaceTarget>()
-        when (mediaTargets.size) {
-            0 -> {
-                if (!smartspaceMediaData.isActive) {
-                    return
-                }
-                if (DEBUG) {
-                    Log.d(TAG, "Set Smartspace media to be inactive for the data update")
-                }
-                if (mediaFlags.isPersistentSsCardEnabled()) {
-                    // Smartspace uses this signal to hide the card (e.g. when it expires or user
-                    // disconnects headphones), so treat as setting inactive when flag is on
-                    smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
-                    notifySmartspaceMediaDataLoaded(
-                        smartspaceMediaData.targetId,
-                        smartspaceMediaData,
-                    )
-                } else {
-                    smartspaceMediaData =
-                        EMPTY_SMARTSPACE_MEDIA_DATA.copy(
-                            targetId = smartspaceMediaData.targetId,
-                            instanceId = smartspaceMediaData.instanceId,
-                        )
-                    notifySmartspaceMediaDataRemoved(
-                        smartspaceMediaData.targetId,
-                        immediately = false,
-                    )
-                }
-            }
-            1 -> {
-                val newMediaTarget = mediaTargets.get(0)
-                if (smartspaceMediaData.targetId == newMediaTarget.smartspaceTargetId) {
-                    // The same Smartspace updates can be received. Skip the duplicate updates.
-                    return
-                }
-                if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.")
-                smartspaceMediaData = toSmartspaceMediaData(newMediaTarget)
-                notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
-            }
-            else -> {
-                // There should NOT be more than 1 Smartspace media update. When it happens, it
-                // indicates a bad state or an error. Reset the status accordingly.
-                Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...")
-                notifySmartspaceMediaDataRemoved(
-                    smartspaceMediaData.targetId,
-                    immediately = false,
-                )
-                smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
-            }
-        }
-    }
-
-    fun onNotificationRemoved(key: String) {
-        Assert.isMainThread()
-        val removed = mediaEntries.remove(key) ?: return
-        if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) {
-            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
-        } else if (isAbleToResume(removed)) {
-            convertToResumePlayer(key, removed)
-        } else if (mediaFlags.isRetainingPlayersEnabled()) {
-            handlePossibleRemoval(key, removed, notificationRemoved = true)
-        } else {
-            notifyMediaDataRemoved(key)
-            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
-        }
-    }
-
-    private fun onSessionDestroyed(key: String) {
-        if (DEBUG) Log.d(TAG, "session destroyed for $key")
-        val entry = mediaEntries.remove(key) ?: return
-        // Clear token since the session is no longer valid
-        val updated = entry.copy(token = null)
-        handlePossibleRemoval(key, updated)
-    }
-
-    private fun isAbleToResume(data: MediaData): Boolean {
-        val isEligibleForResume =
-            data.isLocalSession() ||
-                (mediaFlags.isRemoteResumeAllowed() &&
-                    data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE)
-        return useMediaResumption && data.resumeAction != null && isEligibleForResume
-    }
-
-    /**
-     * Convert to resume state if the player is no longer valid and active, then notify listeners
-     * that the data was updated. Does not convert to resume state if the player is still valid, or
-     * if it was removed before becoming inactive. (Assumes that [removed] was removed from
-     * [mediaEntries] before this function was called)
-     */
-    private fun handlePossibleRemoval(
-        key: String,
-        removed: MediaData,
-        notificationRemoved: Boolean = false
-    ) {
-        val hasSession = removed.token != null
-        if (hasSession && removed.semanticActions != null) {
-            // The app was using session actions, and the session is still valid: keep player
-            if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
-            mediaEntries.put(key, removed)
-            notifyMediaDataLoaded(key, key, removed)
-        } else if (!notificationRemoved && removed.semanticActions == null) {
-            // The app was using notification actions, and notif wasn't removed yet: keep player
-            if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
-            mediaEntries.put(key, removed)
-            notifyMediaDataLoaded(key, key, removed)
-        } else if (removed.active && !isAbleToResume(removed)) {
-            // This player was still active - it didn't last long enough to time out,
-            // and its app doesn't normally support resume: remove
-            if (DEBUG) Log.d(TAG, "Removing still-active player $key")
-            notifyMediaDataRemoved(key)
-            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
-        } else if (mediaFlags.isRetainingPlayersEnabled() || isAbleToResume(removed)) {
-            // Convert to resume
-            if (DEBUG) {
-                Log.d(
-                    TAG,
-                    "Notification ($notificationRemoved) and/or session " +
-                        "($hasSession) gone for inactive player $key"
-                )
-            }
-            convertToResumePlayer(key, removed)
-        } else {
-            // Retaining players flag is off and app doesn't support resume: remove player.
-            if (DEBUG) Log.d(TAG, "Removing player $key")
-            notifyMediaDataRemoved(key)
-            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
-        }
-    }
-
-    /** Set the given [MediaData] as a resume state player and notify listeners */
-    private fun convertToResumePlayer(key: String, data: MediaData) {
-        if (DEBUG) Log.d(TAG, "Converting $key to resume")
-        // Resumption controls must have a title.
-        if (data.song.isNullOrBlank()) {
-            Log.e(TAG, "Description incomplete")
-            notifyMediaDataRemoved(key)
-            logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
-            return
-        }
-        // Move to resume key (aka package name) if that key doesn't already exist.
-        val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
-        val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
-        val launcherIntent =
-            context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
-                PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
-            }
-        val lastActive =
-            if (data.active) {
-                systemClock.elapsedRealtime()
-            } else {
-                data.lastActive
-            }
-        val updated =
-            data.copy(
-                token = null,
-                actions = actions,
-                semanticActions = MediaButton(playOrPause = resumeAction),
-                actionsToShowInCompact = listOf(0),
-                active = false,
-                resumption = true,
-                isPlaying = false,
-                isClearable = true,
-                clickIntent = launcherIntent,
-                lastActive = lastActive,
-            )
-        val pkg = data.packageName
-        val migrate = mediaEntries.put(pkg, updated) == null
-        // Notify listeners of "new" controls when migrating or removed and update when not
-        Log.d(TAG, "migrating? $migrate from $key -> $pkg")
-        if (migrate) {
-            notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
-        } else {
-            // Since packageName is used for the key of the resumption controls, it is
-            // possible that another notification has already been reused for the resumption
-            // controls of this package. In this case, rather than renaming this player as
-            // packageName, just remove it and then send a update to the existing resumption
-            // controls.
-            notifyMediaDataRemoved(key)
-            notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
-        }
-        logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
-
-        // Limit total number of resume controls
-        val resumeEntries = mediaEntries.filter { (key, data) -> data.resumption }
-        val numResume = resumeEntries.size
-        if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
-            resumeEntries
-                .toList()
-                .sortedBy { (key, data) -> data.lastActive }
-                .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS)
-                .forEach { (key, data) ->
-                    Log.d(TAG, "Removing excess control $key")
-                    mediaEntries.remove(key)
-                    notifyMediaDataRemoved(key)
-                    logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
-                }
-        }
-    }
-
-    fun setMediaResumptionEnabled(isEnabled: Boolean) {
-        if (useMediaResumption == isEnabled) {
-            return
-        }
-
-        useMediaResumption = isEnabled
-
-        if (!useMediaResumption) {
-            // Remove any existing resume controls
-            val filtered = mediaEntries.filter { !it.value.active }
-            filtered.forEach {
-                mediaEntries.remove(it.key)
-                notifyMediaDataRemoved(it.key)
-                logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId)
-            }
-        }
-    }
+    fun setMediaResumptionEnabled(isEnabled: Boolean)
 
     /** Invoked when the user has dismissed the media carousel */
-    fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss()
+    fun onSwipeToDismiss()
 
     /** Are there any media notifications active, including the recommendations? */
-    fun hasActiveMediaOrRecommendation() = mediaDataFilter.hasActiveMediaOrRecommendation()
+    fun hasActiveMediaOrRecommendation(): Boolean
 
-    /**
-     * Are there any media entries we should display, including the recommendations?
-     * - If resumption is enabled, this will include inactive players
-     * - If resumption is disabled, we only want to show active players
-     */
-    fun hasAnyMediaOrRecommendation() = mediaDataFilter.hasAnyMediaOrRecommendation()
+    /** Are there any media entries we should display, including the recommendations? */
+    fun hasAnyMediaOrRecommendation(): Boolean
 
     /** Are there any resume media notifications active, excluding the recommendations? */
-    fun hasActiveMedia() = mediaDataFilter.hasActiveMedia()
+    fun hasActiveMedia(): Boolean
 
-    /**
-     * Are there any resume media notifications active, excluding the recommendations?
-     * - If resumption is enabled, this will include inactive players
-     * - If resumption is disabled, we only want to show active players
-     */
-    fun hasAnyMedia() = mediaDataFilter.hasAnyMedia()
+    /** Are there any resume media notifications active, excluding the recommendations? */
+    fun hasAnyMedia(): Boolean
 
-    interface Listener {
+    /** Is recommendation card active? */
+    fun isRecommendationActive(): Boolean
+
+    // Uses [MediaDataProcessor.Listener] in order to link the new logic code with UI layer.
+    interface Listener : MediaDataProcessor.Listener {
 
         /**
          * Called whenever there's new MediaData Loaded for the consumption in views.
@@ -1637,13 +113,13 @@
          * @param isSsReactivated indicates resume media card is reactivated by Smartspace
          *   recommendation signal
          */
-        fun onMediaDataLoaded(
+        override fun onMediaDataLoaded(
             key: String,
             oldKey: String?,
             data: MediaData,
-            immediately: Boolean = true,
-            receivedSmartspaceCardLatency: Int = 0,
-            isSsReactivated: Boolean = false
+            immediately: Boolean,
+            receivedSmartspaceCardLatency: Int,
+            isSsReactivated: Boolean,
         ) {}
 
         /**
@@ -1653,14 +129,14 @@
          *   it will be prioritized as the first card. Otherwise, it will show up as the last card
          *   as default.
          */
-        fun onSmartspaceMediaDataLoaded(
+        override fun onSmartspaceMediaDataLoaded(
             key: String,
             data: SmartspaceMediaData,
-            shouldPrioritize: Boolean = false
+            shouldPrioritize: Boolean,
         ) {}
 
         /** Called whenever a previously existing Media notification was removed. */
-        fun onMediaDataRemoved(key: String) {}
+        override fun onMediaDataRemoved(key: String) {}
 
         /**
          * Called whenever a previously existing Smartspace media data was removed.
@@ -1669,78 +145,14 @@
          *   until the next refresh-round before UI becomes visible. True by default to take in
          *   place immediately.
          */
-        fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean = true) {}
+        override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {}
     }
 
-    /**
-     * Converts the pass-in SmartspaceTarget to SmartspaceMediaData
-     *
-     * @return An empty SmartspaceMediaData with the valid target Id is returned if the
-     *   SmartspaceTarget's data is invalid.
-     */
-    private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData {
-        val baseAction: SmartspaceAction? = target.baseAction
-        val dismissIntent =
-            baseAction?.extras?.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY) as Intent?
+    companion object {
 
-        val isActive =
-            when {
-                !mediaFlags.isPersistentSsCardEnabled() -> true
-                baseAction == null -> true
-                else -> {
-                    val triggerSource = baseAction.extras?.getString(EXTRA_KEY_TRIGGER_SOURCE)
-                    triggerSource != EXTRA_VALUE_TRIGGER_PERIODIC
-                }
-            }
-
-        packageName(target)?.let {
-            return SmartspaceMediaData(
-                targetId = target.smartspaceTargetId,
-                isActive = isActive,
-                packageName = it,
-                cardAction = target.baseAction,
-                recommendations = target.iconGrid,
-                dismissIntent = dismissIntent,
-                headphoneConnectionTimeMillis = target.creationTimeMillis,
-                instanceId = logger.getNewInstanceId(),
-                expiryTimeMs = target.expiryTimeMillis,
-            )
-        }
-        return EMPTY_SMARTSPACE_MEDIA_DATA.copy(
-            targetId = target.smartspaceTargetId,
-            isActive = isActive,
-            dismissIntent = dismissIntent,
-            headphoneConnectionTimeMillis = target.creationTimeMillis,
-            instanceId = logger.getNewInstanceId(),
-            expiryTimeMs = target.expiryTimeMillis,
-        )
-    }
-
-    private fun packageName(target: SmartspaceTarget): String? {
-        val recommendationList = target.iconGrid
-        if (recommendationList == null || recommendationList.isEmpty()) {
-            Log.w(TAG, "Empty or null media recommendation list.")
-            return null
-        }
-        for (recommendation in recommendationList) {
-            val extras = recommendation.extras
-            extras?.let {
-                it.getString(EXTRAS_MEDIA_SOURCE_PACKAGE_NAME)?.let { packageName ->
-                    return packageName
-                }
-            }
-        }
-        Log.w(TAG, "No valid package name is provided.")
-        return null
-    }
-
-    override fun dump(pw: PrintWriter, args: Array<out String>) {
-        pw.apply {
-            println("internalListeners: $internalListeners")
-            println("externalListeners: ${mediaDataFilter.listeners}")
-            println("mediaEntries: $mediaEntries")
-            println("useMediaResumption: $useMediaResumption")
-            println("allowMediaRecommendations: $allowMediaRecommendations")
+        @JvmStatic
+        fun isMediaNotification(sbn: StatusBarNotification): Boolean {
+            return sbn.notification.isMediaNotification()
         }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt
new file mode 100644
index 0000000..7412290
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt
@@ -0,0 +1,1654 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import android.annotation.SuppressLint
+import android.app.ActivityOptions
+import android.app.BroadcastOptions
+import android.app.Notification
+import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME
+import android.app.PendingIntent
+import android.app.StatusBarManager
+import android.app.UriGrantsManager
+import android.app.smartspace.SmartspaceAction
+import android.app.smartspace.SmartspaceConfig
+import android.app.smartspace.SmartspaceManager
+import android.app.smartspace.SmartspaceSession
+import android.app.smartspace.SmartspaceTarget
+import android.content.BroadcastReceiver
+import android.content.ContentProvider
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.Icon
+import android.media.MediaDescription
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.net.Uri
+import android.os.Handler
+import android.os.Parcelable
+import android.os.Process
+import android.os.UserHandle
+import android.provider.Settings
+import android.service.notification.StatusBarNotification
+import android.support.v4.media.MediaMetadataCompat
+import android.text.TextUtils
+import android.util.Log
+import android.util.Pair as APair
+import androidx.media.utils.MediaConstants
+import com.android.app.tracing.traceSection
+import com.android.internal.annotations.Keep
+import com.android.internal.logging.InstanceId
+import com.android.keyguard.KeyguardUpdateMonitor
+import com.android.systemui.CoreStartable
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.media.controls.data.repository.MediaDataRepository
+import com.android.systemui.media.controls.domain.pipeline.MediaDataManager.Companion.isMediaNotification
+import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor
+import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser
+import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_SOURCE
+import com.android.systemui.media.controls.shared.model.EXTRA_VALUE_TRIGGER_PERIODIC
+import com.android.systemui.media.controls.shared.model.MediaAction
+import com.android.systemui.media.controls.shared.model.MediaButton
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.MediaDeviceData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaDataProvider
+import com.android.systemui.media.controls.ui.view.MediaViewHolder
+import com.android.systemui.media.controls.util.MediaControllerFactory
+import com.android.systemui.media.controls.util.MediaDataUtils
+import com.android.systemui.media.controls.util.MediaFlags
+import com.android.systemui.media.controls.util.MediaUiEventLogger
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.plugins.BcSmartspaceDataPlugin
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.NotificationMediaManager.isConnectingState
+import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
+import com.android.systemui.statusbar.notification.row.HybridGroupManager
+import com.android.systemui.util.Assert
+import com.android.systemui.util.Utils
+import com.android.systemui.util.concurrency.DelayableExecutor
+import com.android.systemui.util.concurrency.ThreadFactory
+import com.android.systemui.util.settings.SecureSettings
+import com.android.systemui.util.settings.SettingsProxyExt.observerFlow
+import com.android.systemui.util.time.SystemClock
+import java.io.IOException
+import java.io.PrintWriter
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+// URI fields to try loading album art from
+private val ART_URIS =
+    arrayOf(
+        MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
+        MediaMetadata.METADATA_KEY_ART_URI,
+        MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI
+    )
+
+private const val TAG = "MediaDataProcessor"
+private const val DEBUG = true
+private const val EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY = "dismiss_intent"
+
+/** Processes all media data fields and encapsulates logic for managing media data entries. */
+@SysUISingleton
+class MediaDataProcessor(
+    private val context: Context,
+    @Application private val applicationScope: CoroutineScope,
+    @Background private val backgroundDispatcher: CoroutineDispatcher,
+    @Background private val backgroundExecutor: Executor,
+    @Main private val uiExecutor: Executor,
+    @Main private val foregroundExecutor: DelayableExecutor,
+    @Main private val handler: Handler,
+    private val mediaControllerFactory: MediaControllerFactory,
+    private val broadcastDispatcher: BroadcastDispatcher,
+    private val dumpManager: DumpManager,
+    private val activityStarter: ActivityStarter,
+    private val smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
+    private var useMediaResumption: Boolean,
+    private val useQsMediaPlayer: Boolean,
+    private val systemClock: SystemClock,
+    private val secureSettings: SecureSettings,
+    private val mediaFlags: MediaFlags,
+    private val logger: MediaUiEventLogger,
+    private val smartspaceManager: SmartspaceManager?,
+    private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
+    private val mediaDataRepository: MediaDataRepository,
+) : CoreStartable, BcSmartspaceDataPlugin.SmartspaceTargetListener {
+
+    companion object {
+        /**
+         * UI surface label for subscribing Smartspace updates. String must match with
+         * [BcSmartspaceDataPlugin.UI_SURFACE_MEDIA]
+         */
+        @JvmField val SMARTSPACE_UI_SURFACE_LABEL = "media_data_manager"
+
+        // Smartspace package name's extra key.
+        @JvmField val EXTRAS_MEDIA_SOURCE_PACKAGE_NAME = "package_name"
+
+        // Maximum number of actions allowed in compact view
+        @JvmField val MAX_COMPACT_ACTIONS = 3
+
+        /**
+         * Maximum number of actions allowed in expanded view. Number must match with the size of
+         * [MediaViewHolder.genericButtonIds]
+         */
+        @JvmField val MAX_NOTIFICATION_ACTIONS = 5
+    }
+
+    private val themeText =
+        com.android.settingslib.Utils.getColorAttr(
+                context,
+                com.android.internal.R.attr.textColorPrimary
+            )
+            .defaultColor
+
+    // Internal listeners are part of the internal pipeline. External listeners (those registered
+    // with [MediaDeviceManager.addListener]) receive events after they have propagated through
+    // the internal pipeline.
+    // Another way to think of the distinction between internal and external listeners is the
+    // following. Internal listeners are listeners that MediaDataProcessor depends on, and external
+    // listeners are listeners that depend on MediaDataProcessor.
+    private val internalListeners: MutableSet<Listener> = mutableSetOf()
+
+    // There should ONLY be at most one Smartspace media recommendation.
+    @Keep private var smartspaceSession: SmartspaceSession? = null
+    private var allowMediaRecommendations = false
+
+    private val artworkWidth =
+        context.resources.getDimensionPixelSize(
+            com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize
+        )
+    private val artworkHeight =
+        context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded)
+
+    @SuppressLint("WrongConstant") // sysui allowed to call STATUS_BAR_SERVICE
+    private val statusBarManager =
+        context.getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager
+
+    /** Check whether this notification is an RCN */
+    private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean {
+        return sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE)
+    }
+
+    @Inject
+    constructor(
+        context: Context,
+        @Application applicationScope: CoroutineScope,
+        @Background backgroundDispatcher: CoroutineDispatcher,
+        threadFactory: ThreadFactory,
+        @Main uiExecutor: Executor,
+        @Main foregroundExecutor: DelayableExecutor,
+        @Main handler: Handler,
+        mediaControllerFactory: MediaControllerFactory,
+        dumpManager: DumpManager,
+        broadcastDispatcher: BroadcastDispatcher,
+        activityStarter: ActivityStarter,
+        smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
+        clock: SystemClock,
+        secureSettings: SecureSettings,
+        mediaFlags: MediaFlags,
+        logger: MediaUiEventLogger,
+        smartspaceManager: SmartspaceManager?,
+        keyguardUpdateMonitor: KeyguardUpdateMonitor,
+        mediaDataRepository: MediaDataRepository,
+    ) : this(
+        context,
+        applicationScope,
+        backgroundDispatcher,
+        // Loading bitmap for UMO background can take longer time, so it cannot run on the default
+        // background thread. Use a custom thread for media.
+        threadFactory.buildExecutorOnNewThread(TAG),
+        uiExecutor,
+        foregroundExecutor,
+        handler,
+        mediaControllerFactory,
+        broadcastDispatcher,
+        dumpManager,
+        activityStarter,
+        smartspaceMediaDataProvider,
+        Utils.useMediaResumption(context),
+        Utils.useQsMediaPlayer(context),
+        clock,
+        secureSettings,
+        mediaFlags,
+        logger,
+        smartspaceManager,
+        keyguardUpdateMonitor,
+        mediaDataRepository,
+    )
+
+    private val appChangeReceiver =
+        object : BroadcastReceiver() {
+            override fun onReceive(context: Context, intent: Intent) {
+                when (intent.action) {
+                    Intent.ACTION_PACKAGES_SUSPENDED -> {
+                        val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST)
+                        packages?.forEach { removeAllForPackage(it) }
+                    }
+                    Intent.ACTION_PACKAGE_REMOVED,
+                    Intent.ACTION_PACKAGE_RESTARTED -> {
+                        intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) }
+                    }
+                }
+            }
+        }
+
+    override fun start() {
+        if (!mediaFlags.isMediaControlsRefactorEnabled()) {
+            return
+        }
+
+        dumpManager.registerNormalDumpable(TAG, this)
+
+        val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED)
+        broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL)
+
+        val uninstallFilter =
+            IntentFilter().apply {
+                addAction(Intent.ACTION_PACKAGE_REMOVED)
+                addAction(Intent.ACTION_PACKAGE_RESTARTED)
+                addDataScheme("package")
+            }
+        // BroadcastDispatcher does not allow filters with data schemes
+        context.registerReceiver(appChangeReceiver, uninstallFilter)
+
+        // Register for Smartspace data updates.
+        smartspaceMediaDataProvider.registerListener(this)
+        smartspaceSession =
+            smartspaceManager?.createSmartspaceSession(
+                SmartspaceConfig.Builder(context, SMARTSPACE_UI_SURFACE_LABEL).build()
+            )
+        smartspaceSession?.let {
+            it.addOnTargetsAvailableListener(
+                // Use a main uiExecutor thread listening to Smartspace updates instead of using
+                // the existing background executor.
+                // SmartspaceSession has scheduled routine updates which can be unpredictable on
+                // test simulators, using the backgroundExecutor makes it's hard to test the threads
+                // numbers.
+                uiExecutor
+            ) { targets ->
+                smartspaceMediaDataProvider.onTargetsAvailable(targets)
+            }
+        }
+        smartspaceSession?.requestSmartspaceUpdate()
+
+        // Track media controls recommendation setting.
+        applicationScope.launch { trackMediaControlsRecommendationSetting() }
+    }
+
+    fun destroy() {
+        smartspaceMediaDataProvider.unregisterListener(this)
+        smartspaceSession?.close()
+        smartspaceSession = null
+        context.unregisterReceiver(appChangeReceiver)
+        internalListeners.clear()
+    }
+
+    fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
+        if (useQsMediaPlayer && isMediaNotification(sbn)) {
+            var isNewlyActiveEntry = false
+            Assert.isMainThread()
+            val oldKey = findExistingEntry(key, sbn.packageName)
+            if (oldKey == null) {
+                val instanceId = logger.getNewInstanceId()
+                val temp =
+                    MediaData()
+                        .copy(
+                            packageName = sbn.packageName,
+                            instanceId = instanceId,
+                            createdTimestampMillis = systemClock.currentTimeMillis(),
+                        )
+                mediaDataRepository.addMediaEntry(key, temp)
+                isNewlyActiveEntry = true
+            } else if (oldKey != key) {
+                // Resume -> active conversion; move to new key
+                val oldData = mediaDataRepository.removeMediaEntry(oldKey)!!
+                isNewlyActiveEntry = true
+                mediaDataRepository.addMediaEntry(key, oldData)
+            }
+            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
+        } else {
+            onNotificationRemoved(key)
+        }
+    }
+
+    /**
+     * Allow recommendations from smartspace to show in media controls. Requires
+     * [Utils.useQsMediaPlayer] to be enabled. On by default, but can be disabled by setting to 0
+     */
+    private suspend fun allowMediaRecommendations(): Boolean {
+        return withContext(backgroundDispatcher) {
+            val flag =
+                secureSettings.getBoolForUser(
+                    Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
+                    true,
+                    UserHandle.USER_CURRENT
+                )
+
+            useQsMediaPlayer && flag
+        }
+    }
+
+    private suspend fun trackMediaControlsRecommendationSetting() {
+        secureSettings
+            .observerFlow(UserHandle.USER_ALL, Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION)
+            // perform a query at the beginning.
+            .onStart { emit(Unit) }
+            .map { allowMediaRecommendations() }
+            .distinctUntilChanged()
+            // only track the most recent emission
+            .collectLatest {
+                allowMediaRecommendations = it
+                if (!allowMediaRecommendations) {
+                    dismissSmartspaceRecommendation(
+                        key = mediaDataRepository.smartspaceMediaData.value.targetId,
+                        delay = 0L
+                    )
+                }
+            }
+    }
+
+    private fun removeAllForPackage(packageName: String) {
+        Assert.isMainThread()
+        val toRemove =
+            mediaDataRepository.mediaEntries.value.filter { it.value.packageName == packageName }
+        toRemove.forEach { removeEntry(it.key) }
+    }
+
+    fun setResumeAction(key: String, action: Runnable?) {
+        mediaDataRepository.mediaEntries.value.get(key)?.let {
+            it.resumeAction = action
+            it.hasCheckedForResume = true
+        }
+    }
+
+    fun addResumptionControls(
+        userId: Int,
+        desc: MediaDescription,
+        action: Runnable,
+        token: MediaSession.Token,
+        appName: String,
+        appIntent: PendingIntent,
+        packageName: String
+    ) {
+        // Resume controls don't have a notification key, so store by package name instead
+        if (!mediaDataRepository.mediaEntries.value.containsKey(packageName)) {
+            val instanceId = logger.getNewInstanceId()
+            val appUid =
+                try {
+                    context.packageManager.getApplicationInfo(packageName, 0).uid
+                } catch (e: PackageManager.NameNotFoundException) {
+                    Log.w(TAG, "Could not get app UID for $packageName", e)
+                    Process.INVALID_UID
+                }
+
+            val resumeData =
+                MediaData()
+                    .copy(
+                        packageName = packageName,
+                        resumeAction = action,
+                        hasCheckedForResume = true,
+                        instanceId = instanceId,
+                        appUid = appUid,
+                        createdTimestampMillis = systemClock.currentTimeMillis(),
+                    )
+            mediaDataRepository.addMediaEntry(packageName, resumeData)
+            logSingleVsMultipleMediaAdded(appUid, packageName, instanceId)
+            logger.logResumeMediaAdded(appUid, packageName, instanceId)
+        }
+        backgroundExecutor.execute {
+            loadMediaDataInBgForResumption(
+                userId,
+                desc,
+                action,
+                token,
+                appName,
+                appIntent,
+                packageName
+            )
+        }
+    }
+
+    /**
+     * Check if there is an existing entry that matches the key or package name. Returns the key
+     * that matches, or null if not found.
+     */
+    private fun findExistingEntry(key: String, packageName: String): String? {
+        val mediaEntries = mediaDataRepository.mediaEntries.value
+        if (mediaEntries.containsKey(key)) {
+            return key
+        }
+        // Check if we already had a resume player
+        if (mediaEntries.containsKey(packageName)) {
+            return packageName
+        }
+        return null
+    }
+
+    private fun loadMediaData(
+        key: String,
+        sbn: StatusBarNotification,
+        oldKey: String?,
+        isNewlyActiveEntry: Boolean = false,
+    ) {
+        backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
+    }
+
+    /** Add a listener for internal events. */
+    fun addInternalListener(listener: Listener) = internalListeners.add(listener)
+
+    /**
+     * Notify internal listeners of media loaded event.
+     *
+     * External listeners registered with [MediaCarouselInteractor.addListener] will be notified
+     * after the event propagates through the internal listener pipeline.
+     */
+    private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
+        internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) }
+    }
+
+    /**
+     * Notify internal listeners of Smartspace media loaded event.
+     *
+     * External listeners registered with [MediaCarouselInteractor.addListener] will be notified
+     * after the event propagates through the internal listener pipeline.
+     */
+    private fun notifySmartspaceMediaDataLoaded(key: String, info: SmartspaceMediaData) {
+        internalListeners.forEach { it.onSmartspaceMediaDataLoaded(key, info) }
+    }
+
+    /**
+     * Notify internal listeners of media removed event.
+     *
+     * External listeners registered with [MediaCarouselInteractor.addListener] will be notified
+     * after the event propagates through the internal listener pipeline.
+     */
+    private fun notifyMediaDataRemoved(key: String) {
+        internalListeners.forEach { it.onMediaDataRemoved(key) }
+    }
+
+    /**
+     * Notify internal listeners of Smartspace media removed event.
+     *
+     * External listeners registered with [MediaCarouselInteractor.addListener] will be notified
+     * after the event propagates through the internal listener pipeline.
+     *
+     * @param immediately indicates should apply the UI changes immediately, otherwise wait until
+     *   the next refresh-round before UI becomes visible. Should only be true if the update is
+     *   initiated by user's interaction.
+     */
+    private fun notifySmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
+        internalListeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
+    }
+
+    /**
+     * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This
+     * will make the player not active anymore, hiding it from QQS and Keyguard.
+     *
+     * @see MediaData.active
+     */
+    fun setInactive(key: String, timedOut: Boolean, forceUpdate: Boolean = false) {
+        mediaDataRepository.mediaEntries.value[key]?.let {
+            if (timedOut && !forceUpdate) {
+                // Only log this event when media expires on its own
+                logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId)
+            }
+            if (it.active == !timedOut && !forceUpdate) {
+                if (it.resumption) {
+                    if (DEBUG) Log.d(TAG, "timing out resume player $key")
+                    dismissMediaData(key, 0L /* delay */)
+                }
+                return
+            }
+            // Update last active if media was still active.
+            if (it.active) {
+                it.lastActive = systemClock.elapsedRealtime()
+            }
+            it.active = !timedOut
+            if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut")
+            onMediaDataLoaded(key, key, it)
+        }
+
+        if (key == mediaDataRepository.smartspaceMediaData.value.targetId) {
+            if (DEBUG) Log.d(TAG, "smartspace card expired")
+            dismissSmartspaceRecommendation(key, delay = 0L)
+        }
+    }
+
+    /** Called when the player's [PlaybackState] has been updated with new actions and/or state */
+    internal fun updateState(key: String, state: PlaybackState) {
+        mediaDataRepository.mediaEntries.value.get(key)?.let {
+            val token = it.token
+            if (token == null) {
+                if (DEBUG) Log.d(TAG, "State updated, but token was null")
+                return
+            }
+            val actions =
+                createActionsFromState(
+                    it.packageName,
+                    mediaControllerFactory.create(it.token),
+                    UserHandle(it.userId)
+                )
+
+            // Control buttons
+            // If flag is enabled and controller has a PlaybackState,
+            // create actions from session info
+            // otherwise, no need to update semantic actions.
+            val data =
+                if (actions != null) {
+                    it.copy(semanticActions = actions, isPlaying = isPlayingState(state.state))
+                } else {
+                    it.copy(isPlaying = isPlayingState(state.state))
+                }
+            if (DEBUG) Log.d(TAG, "State updated outside of notification")
+            onMediaDataLoaded(key, key, data)
+        }
+    }
+
+    private fun removeEntry(key: String, logEvent: Boolean = true) {
+        mediaDataRepository.removeMediaEntry(key)?.let {
+            if (logEvent) {
+                logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
+            }
+        }
+        notifyMediaDataRemoved(key)
+    }
+
+    /** Dismiss a media entry. Returns false if the key was not found. */
+    fun dismissMediaData(key: String, delay: Long): Boolean {
+        val existed = mediaDataRepository.mediaEntries.value[key] != null
+        backgroundExecutor.execute {
+            mediaDataRepository.mediaEntries.value[key]?.let { mediaData ->
+                if (mediaData.isLocalSession()) {
+                    mediaData.token?.let {
+                        val mediaController = mediaControllerFactory.create(it)
+                        mediaController.transportControls.stop()
+                    }
+                }
+            }
+        }
+        foregroundExecutor.executeDelayed({ removeEntry(key) }, delay)
+        return existed
+    }
+
+    /**
+     * Called whenever the recommendation has been expired or removed by the user. This will remove
+     * the recommendation card entirely from the carousel.
+     */
+    fun dismissSmartspaceRecommendation(key: String, delay: Long) {
+        if (mediaDataRepository.dismissSmartspaceRecommendation(key)) {
+            foregroundExecutor.executeDelayed(
+                { notifySmartspaceMediaDataRemoved(key, immediately = true) },
+                delay
+            )
+        }
+    }
+
+    /** Called when the recommendation card should no longer be visible in QQS or lockscreen */
+    fun setRecommendationInactive(key: String) {
+        if (mediaDataRepository.setRecommendationInactive(key)) {
+            val recommendation = mediaDataRepository.smartspaceMediaData.value
+            notifySmartspaceMediaDataLoaded(recommendation.targetId, recommendation)
+        }
+    }
+
+    private fun loadMediaDataInBgForResumption(
+        userId: Int,
+        desc: MediaDescription,
+        resumeAction: Runnable,
+        token: MediaSession.Token,
+        appName: String,
+        appIntent: PendingIntent,
+        packageName: String
+    ) {
+        if (desc.title.isNullOrBlank()) {
+            Log.e(TAG, "Description incomplete")
+            // Delete the placeholder entry
+            mediaDataRepository.removeMediaEntry(packageName)
+            return
+        }
+
+        if (DEBUG) {
+            Log.d(TAG, "adding track for $userId from browser: $desc")
+        }
+
+        val currentEntry = mediaDataRepository.mediaEntries.value.get(packageName)
+        val appUid = currentEntry?.appUid ?: Process.INVALID_UID
+
+        // Album art
+        var artworkBitmap = desc.iconBitmap
+        if (artworkBitmap == null && desc.iconUri != null) {
+            artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName)
+        }
+        val artworkIcon =
+            if (artworkBitmap != null) {
+                Icon.createWithBitmap(artworkBitmap)
+            } else {
+                null
+            }
+
+        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
+        val isExplicit =
+            desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
+                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
+
+        val progress =
+            if (mediaFlags.isResumeProgressEnabled()) {
+                MediaDataUtils.getDescriptionProgress(desc.extras)
+            } else null
+
+        val mediaAction = getResumeMediaAction(resumeAction)
+        val lastActive = systemClock.elapsedRealtime()
+        val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
+        foregroundExecutor.execute {
+            onMediaDataLoaded(
+                packageName,
+                null,
+                MediaData(
+                    userId,
+                    true,
+                    appName,
+                    null,
+                    desc.subtitle,
+                    desc.title,
+                    artworkIcon,
+                    listOf(mediaAction),
+                    listOf(0),
+                    MediaButton(playOrPause = mediaAction),
+                    packageName,
+                    token,
+                    appIntent,
+                    device = null,
+                    active = false,
+                    resumeAction = resumeAction,
+                    resumption = true,
+                    notificationKey = packageName,
+                    hasCheckedForResume = true,
+                    lastActive = lastActive,
+                    createdTimestampMillis = createdTimestampMillis,
+                    instanceId = instanceId,
+                    appUid = appUid,
+                    isExplicit = isExplicit,
+                    resumeProgress = progress,
+                )
+            )
+        }
+    }
+
+    fun loadMediaDataInBg(
+        key: String,
+        sbn: StatusBarNotification,
+        oldKey: String?,
+        isNewlyActiveEntry: Boolean = false,
+    ) {
+        val token =
+            sbn.notification.extras.getParcelable(
+                Notification.EXTRA_MEDIA_SESSION,
+                MediaSession.Token::class.java
+            )
+        if (token == null) {
+            return
+        }
+        val mediaController = mediaControllerFactory.create(token)
+        val metadata = mediaController.metadata
+        val notif: Notification = sbn.notification
+
+        val appInfo =
+            notif.extras.getParcelable(
+                Notification.EXTRA_BUILDER_APPLICATION_INFO,
+                ApplicationInfo::class.java
+            )
+                ?: getAppInfoFromPackage(sbn.packageName)
+
+        // App name
+        val appName = getAppName(sbn, appInfo)
+
+        // Song name
+        var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
+        if (song.isNullOrBlank()) {
+            song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
+        }
+        if (song.isNullOrBlank()) {
+            song = HybridGroupManager.resolveTitle(notif)
+        }
+        if (song.isNullOrBlank()) {
+            // For apps that don't include a title, log and add a placeholder
+            song = context.getString(R.string.controls_media_empty_title, appName)
+            try {
+                statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier)
+            } catch (e: RuntimeException) {
+                Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}")
+            }
+        }
+
+        // Album art
+        var artworkBitmap = metadata?.let { loadBitmapFromUri(it) }
+        if (artworkBitmap == null) {
+            artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART)
+        }
+        if (artworkBitmap == null) {
+            artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
+        }
+        val artWorkIcon =
+            if (artworkBitmap == null) {
+                notif.getLargeIcon()
+            } else {
+                Icon.createWithBitmap(artworkBitmap)
+            }
+
+        // App Icon
+        val smallIcon = sbn.notification.smallIcon
+
+        // Explicit Indicator
+        val isExplicit: Boolean
+        val mediaMetadataCompat = MediaMetadataCompat.fromMediaMetadata(metadata)
+        isExplicit =
+            mediaMetadataCompat?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
+                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
+
+        // Artist name
+        var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
+        if (artist.isNullOrBlank()) {
+            artist = HybridGroupManager.resolveText(notif)
+        }
+
+        // Device name (used for remote cast notifications)
+        var device: MediaDeviceData? = null
+        if (isRemoteCastNotification(sbn)) {
+            val extras = sbn.notification.extras
+            val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null)
+            val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1)
+            val deviceIntent =
+                extras.getParcelable(
+                    Notification.EXTRA_MEDIA_REMOTE_INTENT,
+                    PendingIntent::class.java
+                )
+            Log.d(TAG, "$key is RCN for $deviceName")
+
+            if (deviceName != null && deviceIcon > -1) {
+                // Name and icon must be present, but intent may be null
+                val enabled = deviceIntent != null && deviceIntent.isActivity
+                val deviceDrawable =
+                    Icon.createWithResource(sbn.packageName, deviceIcon)
+                        .loadDrawable(sbn.getPackageContext(context))
+                device =
+                    MediaDeviceData(
+                        enabled,
+                        deviceDrawable,
+                        deviceName,
+                        deviceIntent,
+                        showBroadcastButton = false
+                    )
+            }
+        }
+
+        // Control buttons
+        // If flag is enabled and controller has a PlaybackState, create actions from session info
+        // Otherwise, use the notification actions
+        var actionIcons: List<MediaAction> = emptyList()
+        var actionsToShowCollapsed: List<Int> = emptyList()
+        val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user)
+        if (semanticActions == null) {
+            val actions = createActionsFromNotification(sbn)
+            actionIcons = actions.first
+            actionsToShowCollapsed = actions.second
+        }
+
+        val playbackLocation =
+            if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
+            else if (
+                mediaController.playbackInfo?.playbackType ==
+                    MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL
+            )
+                MediaData.PLAYBACK_LOCAL
+            else MediaData.PLAYBACK_CAST_LOCAL
+        val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) }
+
+        val currentEntry = mediaDataRepository.mediaEntries.value.get(key)
+        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
+        val appUid = appInfo?.uid ?: Process.INVALID_UID
+
+        if (isNewlyActiveEntry) {
+            logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId)
+            logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation)
+        } else if (playbackLocation != currentEntry?.playbackLocation) {
+            logger.logPlaybackLocationChange(appUid, sbn.packageName, instanceId, playbackLocation)
+        }
+
+        val lastActive = systemClock.elapsedRealtime()
+        val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
+        foregroundExecutor.execute {
+            val resumeAction: Runnable? = mediaDataRepository.mediaEntries.value[key]?.resumeAction
+            val hasCheckedForResume =
+                mediaDataRepository.mediaEntries.value[key]?.hasCheckedForResume == true
+            val active = mediaDataRepository.mediaEntries.value[key]?.active ?: true
+            onMediaDataLoaded(
+                key,
+                oldKey,
+                MediaData(
+                    sbn.normalizedUserId,
+                    true,
+                    appName,
+                    smallIcon,
+                    artist,
+                    song,
+                    artWorkIcon,
+                    actionIcons,
+                    actionsToShowCollapsed,
+                    semanticActions,
+                    sbn.packageName,
+                    token,
+                    notif.contentIntent,
+                    device,
+                    active,
+                    resumeAction = resumeAction,
+                    playbackLocation = playbackLocation,
+                    notificationKey = key,
+                    hasCheckedForResume = hasCheckedForResume,
+                    isPlaying = isPlaying,
+                    isClearable = !sbn.isOngoing,
+                    lastActive = lastActive,
+                    createdTimestampMillis = createdTimestampMillis,
+                    instanceId = instanceId,
+                    appUid = appUid,
+                    isExplicit = isExplicit,
+                )
+            )
+        }
+    }
+
+    private fun logSingleVsMultipleMediaAdded(
+        appUid: Int,
+        packageName: String,
+        instanceId: InstanceId
+    ) {
+        if (mediaDataRepository.mediaEntries.value.size == 1) {
+            logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId)
+        } else if (mediaDataRepository.mediaEntries.value.size == 2) {
+            // Since this method is only called when there is a new media session added.
+            // logging needed once there is more than one media session in carousel.
+            logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId)
+        }
+    }
+
+    private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? {
+        try {
+            return context.packageManager.getApplicationInfo(packageName, 0)
+        } catch (e: PackageManager.NameNotFoundException) {
+            Log.w(TAG, "Could not get app info for $packageName", e)
+        }
+        return null
+    }
+
+    private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String {
+        val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME)
+        if (name != null) {
+            return name
+        }
+
+        return if (appInfo != null) {
+            context.packageManager.getApplicationLabel(appInfo).toString()
+        } else {
+            sbn.packageName
+        }
+    }
+
+    /** Generate action buttons based on notification actions */
+    private fun createActionsFromNotification(
+        sbn: StatusBarNotification
+    ): Pair<List<MediaAction>, List<Int>> {
+        val notif = sbn.notification
+        val actionIcons: MutableList<MediaAction> = ArrayList()
+        val actions = notif.actions
+        var actionsToShowCollapsed =
+            notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList()
+                ?: mutableListOf()
+        if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) {
+            Log.e(
+                TAG,
+                "Too many compact actions for ${sbn.key}," +
+                    "limiting to first $MAX_COMPACT_ACTIONS"
+            )
+            actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS)
+        }
+
+        if (actions != null) {
+            for ((index, action) in actions.withIndex()) {
+                if (index == MAX_NOTIFICATION_ACTIONS) {
+                    Log.w(
+                        TAG,
+                        "Too many notification actions for ${sbn.key}," +
+                            " limiting to first $MAX_NOTIFICATION_ACTIONS"
+                    )
+                    break
+                }
+                if (action.getIcon() == null) {
+                    if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}")
+                    actionsToShowCollapsed.remove(index)
+                    continue
+                }
+                val runnable =
+                    if (action.actionIntent != null) {
+                        Runnable {
+                            if (action.actionIntent.isActivity) {
+                                activityStarter.startPendingIntentDismissingKeyguard(
+                                    action.actionIntent
+                                )
+                            } else if (action.isAuthenticationRequired()) {
+                                activityStarter.dismissKeyguardThenExecute(
+                                    {
+                                        var result = sendPendingIntent(action.actionIntent)
+                                        result
+                                    },
+                                    {},
+                                    true
+                                )
+                            } else {
+                                sendPendingIntent(action.actionIntent)
+                            }
+                        }
+                    } else {
+                        null
+                    }
+                val mediaActionIcon =
+                    if (action.getIcon()?.getType() == Icon.TYPE_RESOURCE) {
+                            Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId())
+                        } else {
+                            action.getIcon()
+                        }
+                        .setTint(themeText)
+                        .loadDrawable(context)
+                val mediaAction = MediaAction(mediaActionIcon, runnable, action.title, null)
+                actionIcons.add(mediaAction)
+            }
+        }
+        return Pair(actionIcons, actionsToShowCollapsed)
+    }
+
+    /**
+     * Generates action button info for this media session based on the PlaybackState
+     *
+     * @param packageName Package name for the media app
+     * @param controller MediaController for the current session
+     * @return a Pair consisting of a list of media actions, and a list of ints representing which
+     *
+     * ```
+     *      of those actions should be shown in the compact player
+     * ```
+     */
+    private fun createActionsFromState(
+        packageName: String,
+        controller: MediaController,
+        user: UserHandle
+    ): MediaButton? {
+        val state = controller.playbackState
+        if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) {
+            return null
+        }
+
+        // First, check for standard actions
+        val playOrPause =
+            if (isConnectingState(state.state)) {
+                // Spinner needs to be animating to render anything. Start it here.
+                val drawable =
+                    context.getDrawable(com.android.internal.R.drawable.progress_small_material)
+                (drawable as Animatable).start()
+                MediaAction(
+                    drawable,
+                    null, // no action to perform when clicked
+                    context.getString(R.string.controls_media_button_connecting),
+                    context.getDrawable(R.drawable.ic_media_connecting_container),
+                    // Specify a rebind id to prevent the spinner from restarting on later binds.
+                    com.android.internal.R.drawable.progress_small_material
+                )
+            } else if (isPlayingState(state.state)) {
+                getStandardAction(controller, state.actions, PlaybackState.ACTION_PAUSE)
+            } else {
+                getStandardAction(controller, state.actions, PlaybackState.ACTION_PLAY)
+            }
+        val prevButton =
+            getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_PREVIOUS)
+        val nextButton =
+            getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_NEXT)
+
+        // Then, create a way to build any custom actions that will be needed
+        val customActions =
+            state.customActions
+                .asSequence()
+                .filterNotNull()
+                .map { getCustomAction(packageName, controller, it) }
+                .iterator()
+        fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
+
+        // Finally, assign the remaining button slots: play/pause A B C D
+        // A = previous, else custom action (if not reserved)
+        // B = next, else custom action (if not reserved)
+        // C and D are always custom actions
+        val reservePrev =
+            controller.extras?.getBoolean(
+                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV
+            ) == true
+        val reserveNext =
+            controller.extras?.getBoolean(
+                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT
+            ) == true
+
+        val prevOrCustom =
+            if (prevButton != null) {
+                prevButton
+            } else if (!reservePrev) {
+                nextCustomAction()
+            } else {
+                null
+            }
+
+        val nextOrCustom =
+            if (nextButton != null) {
+                nextButton
+            } else if (!reserveNext) {
+                nextCustomAction()
+            } else {
+                null
+            }
+
+        return MediaButton(
+            playOrPause,
+            nextOrCustom,
+            prevOrCustom,
+            nextCustomAction(),
+            nextCustomAction(),
+            reserveNext,
+            reservePrev
+        )
+    }
+
+    /**
+     * Create a [MediaAction] for a given action and media session
+     *
+     * @param controller MediaController for the session
+     * @param stateActions The actions included with the session's [PlaybackState]
+     * @param action A [PlaybackState.Actions] value representing what action to generate. One of:
+     * ```
+     *      [PlaybackState.ACTION_PLAY]
+     *      [PlaybackState.ACTION_PAUSE]
+     *      [PlaybackState.ACTION_SKIP_TO_PREVIOUS]
+     *      [PlaybackState.ACTION_SKIP_TO_NEXT]
+     * @return
+     * ```
+     *
+     * A [MediaAction] with correct values set, or null if the state doesn't support it
+     */
+    private fun getStandardAction(
+        controller: MediaController,
+        stateActions: Long,
+        @PlaybackState.Actions action: Long
+    ): MediaAction? {
+        if (!includesAction(stateActions, action)) {
+            return null
+        }
+
+        return when (action) {
+            PlaybackState.ACTION_PLAY -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_play),
+                    { controller.transportControls.play() },
+                    context.getString(R.string.controls_media_button_play),
+                    context.getDrawable(R.drawable.ic_media_play_container)
+                )
+            }
+            PlaybackState.ACTION_PAUSE -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_pause),
+                    { controller.transportControls.pause() },
+                    context.getString(R.string.controls_media_button_pause),
+                    context.getDrawable(R.drawable.ic_media_pause_container)
+                )
+            }
+            PlaybackState.ACTION_SKIP_TO_PREVIOUS -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_prev),
+                    { controller.transportControls.skipToPrevious() },
+                    context.getString(R.string.controls_media_button_prev),
+                    null
+                )
+            }
+            PlaybackState.ACTION_SKIP_TO_NEXT -> {
+                MediaAction(
+                    context.getDrawable(R.drawable.ic_media_next),
+                    { controller.transportControls.skipToNext() },
+                    context.getString(R.string.controls_media_button_next),
+                    null
+                )
+            }
+            else -> null
+        }
+    }
+
+    /** Check whether the actions from a [PlaybackState] include a specific action */
+    private fun includesAction(stateActions: Long, @PlaybackState.Actions action: Long): Boolean {
+        if (
+            (action == PlaybackState.ACTION_PLAY || action == PlaybackState.ACTION_PAUSE) &&
+                (stateActions and PlaybackState.ACTION_PLAY_PAUSE > 0L)
+        ) {
+            return true
+        }
+        return (stateActions and action != 0L)
+    }
+
+    /** Get a [MediaAction] representing a [PlaybackState.CustomAction] */
+    private fun getCustomAction(
+        packageName: String,
+        controller: MediaController,
+        customAction: PlaybackState.CustomAction
+    ): MediaAction {
+        return MediaAction(
+            Icon.createWithResource(packageName, customAction.icon).loadDrawable(context),
+            { controller.transportControls.sendCustomAction(customAction, customAction.extras) },
+            customAction.name,
+            null
+        )
+    }
+
+    /** Load a bitmap from the various Art metadata URIs */
+    private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? {
+        for (uri in ART_URIS) {
+            val uriString = metadata.getString(uri)
+            if (!TextUtils.isEmpty(uriString)) {
+                val albumArt = loadBitmapFromUri(Uri.parse(uriString))
+                if (albumArt != null) {
+                    if (DEBUG) Log.d(TAG, "loaded art from $uri")
+                    return albumArt
+                }
+            }
+        }
+        return null
+    }
+
+    private fun sendPendingIntent(intent: PendingIntent): Boolean {
+        return try {
+            val options = BroadcastOptions.makeBasic()
+            options.setInteractive(true)
+            options.setPendingIntentBackgroundActivityStartMode(
+                ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
+            )
+            intent.send(options.toBundle())
+            true
+        } catch (e: PendingIntent.CanceledException) {
+            Log.d(TAG, "Intent canceled", e)
+            false
+        }
+    }
+
+    /** Returns a bitmap if the user can access the given URI, else null */
+    private fun loadBitmapFromUriForUser(
+        uri: Uri,
+        userId: Int,
+        appUid: Int,
+        packageName: String,
+    ): Bitmap? {
+        try {
+            val ugm = UriGrantsManager.getService()
+            ugm.checkGrantUriPermission_ignoreNonSystem(
+                appUid,
+                packageName,
+                ContentProvider.getUriWithoutUserId(uri),
+                Intent.FLAG_GRANT_READ_URI_PERMISSION,
+                ContentProvider.getUserIdFromUri(uri, userId)
+            )
+            return loadBitmapFromUri(uri)
+        } catch (e: SecurityException) {
+            Log.e(TAG, "Failed to get URI permission: $e")
+        }
+        return null
+    }
+
+    /**
+     * Load a bitmap from a URI
+     *
+     * @param uri the uri to load
+     * @return bitmap, or null if couldn't be loaded
+     */
+    private fun loadBitmapFromUri(uri: Uri): Bitmap? {
+        // ImageDecoder requires a scheme of the following types
+        if (uri.scheme == null) {
+            return null
+        }
+
+        if (
+            !uri.scheme.equals(ContentResolver.SCHEME_CONTENT) &&
+                !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) &&
+                !uri.scheme.equals(ContentResolver.SCHEME_FILE)
+        ) {
+            return null
+        }
+
+        val source = ImageDecoder.createSource(context.contentResolver, uri)
+        return try {
+            ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
+                val width = info.size.width
+                val height = info.size.height
+                val scale =
+                    MediaDataUtils.getScaleFactor(
+                        APair(width, height),
+                        APair(artworkWidth, artworkHeight)
+                    )
+
+                // Downscale if needed
+                if (scale != 0f && scale < 1) {
+                    decoder.setTargetSize((scale * width).toInt(), (scale * height).toInt())
+                }
+                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, "Unable to load bitmap", e)
+            null
+        } catch (e: RuntimeException) {
+            Log.e(TAG, "Unable to load bitmap", e)
+            null
+        }
+    }
+
+    private fun getResumeMediaAction(action: Runnable): MediaAction {
+        return MediaAction(
+            Icon.createWithResource(context, R.drawable.ic_media_play)
+                .setTint(themeText)
+                .loadDrawable(context),
+            action,
+            context.getString(R.string.controls_media_resume),
+            context.getDrawable(R.drawable.ic_media_play_container)
+        )
+    }
+
+    fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) =
+        traceSection("MediaDataProcessor#onMediaDataLoaded") {
+            Assert.isMainThread()
+            if (mediaDataRepository.mediaEntries.value.containsKey(key)) {
+                // Otherwise this was removed already
+                mediaDataRepository.addMediaEntry(key, data)
+                notifyMediaDataLoaded(key, oldKey, data)
+            }
+        }
+
+    override fun onSmartspaceTargetsUpdated(targets: List<Parcelable>) {
+        if (!allowMediaRecommendations) {
+            if (DEBUG) Log.d(TAG, "Smartspace recommendation is disabled in Settings.")
+            return
+        }
+
+        val mediaTargets = targets.filterIsInstance<SmartspaceTarget>()
+        val smartspaceMediaData = mediaDataRepository.smartspaceMediaData.value
+        when (mediaTargets.size) {
+            0 -> {
+                if (!smartspaceMediaData.isActive) {
+                    return
+                }
+                if (DEBUG) {
+                    Log.d(TAG, "Set Smartspace media to be inactive for the data update")
+                }
+                if (mediaFlags.isPersistentSsCardEnabled()) {
+                    // Smartspace uses this signal to hide the card (e.g. when it expires or user
+                    // disconnects headphones), so treat as setting inactive when flag is on
+                    val recommendation = smartspaceMediaData.copy(isActive = false)
+                    mediaDataRepository.setRecommendation(recommendation)
+                    notifySmartspaceMediaDataLoaded(recommendation.targetId, recommendation)
+                } else {
+                    notifySmartspaceMediaDataRemoved(
+                        smartspaceMediaData.targetId,
+                        immediately = false
+                    )
+                    mediaDataRepository.setRecommendation(
+                        SmartspaceMediaData(
+                            targetId = smartspaceMediaData.targetId,
+                            instanceId = smartspaceMediaData.instanceId,
+                        )
+                    )
+                }
+            }
+            1 -> {
+                val newMediaTarget = mediaTargets.get(0)
+                if (smartspaceMediaData.targetId == newMediaTarget.smartspaceTargetId) {
+                    // The same Smartspace updates can be received. Skip the duplicate updates.
+                    return
+                }
+                if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.")
+                val recommendation = toSmartspaceMediaData(newMediaTarget)
+                mediaDataRepository.setRecommendation(recommendation)
+                notifySmartspaceMediaDataLoaded(recommendation.targetId, recommendation)
+            }
+            else -> {
+                // There should NOT be more than 1 Smartspace media update. When it happens, it
+                // indicates a bad state or an error. Reset the status accordingly.
+                Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...")
+                notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = false)
+                mediaDataRepository.setRecommendation(SmartspaceMediaData())
+            }
+        }
+    }
+
+    fun onNotificationRemoved(key: String) {
+        Assert.isMainThread()
+        val removed = mediaDataRepository.removeMediaEntry(key) ?: return
+        if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) {
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        } else if (isAbleToResume(removed)) {
+            convertToResumePlayer(key, removed)
+        } else if (mediaFlags.isRetainingPlayersEnabled()) {
+            handlePossibleRemoval(key, removed, notificationRemoved = true)
+        } else {
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        }
+    }
+
+    internal fun onSessionDestroyed(key: String) {
+        if (DEBUG) Log.d(TAG, "session destroyed for $key")
+        val entry = mediaDataRepository.removeMediaEntry(key) ?: return
+        // Clear token since the session is no longer valid
+        val updated = entry.copy(token = null)
+        handlePossibleRemoval(key, updated)
+    }
+
+    private fun isAbleToResume(data: MediaData): Boolean {
+        val isEligibleForResume =
+            data.isLocalSession() ||
+                (mediaFlags.isRemoteResumeAllowed() &&
+                    data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE)
+        return useMediaResumption && data.resumeAction != null && isEligibleForResume
+    }
+
+    /**
+     * Convert to resume state if the player is no longer valid and active, then notify listeners
+     * that the data was updated. Does not convert to resume state if the player is still valid, or
+     * if it was removed before becoming inactive. (Assumes that [removed] was removed from
+     * [mediaDataRepository.mediaEntries] state before this function was called)
+     */
+    private fun handlePossibleRemoval(
+        key: String,
+        removed: MediaData,
+        notificationRemoved: Boolean = false
+    ) {
+        val hasSession = removed.token != null
+        if (hasSession && removed.semanticActions != null) {
+            // The app was using session actions, and the session is still valid: keep player
+            if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
+            mediaDataRepository.addMediaEntry(key, removed)
+            notifyMediaDataLoaded(key, key, removed)
+        } else if (!notificationRemoved && removed.semanticActions == null) {
+            // The app was using notification actions, and notif wasn't removed yet: keep player
+            if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
+            mediaDataRepository.addMediaEntry(key, removed)
+            notifyMediaDataLoaded(key, key, removed)
+        } else if (removed.active && !isAbleToResume(removed)) {
+            // This player was still active - it didn't last long enough to time out,
+            // and its app doesn't normally support resume: remove
+            if (DEBUG) Log.d(TAG, "Removing still-active player $key")
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        } else if (mediaFlags.isRetainingPlayersEnabled() || isAbleToResume(removed)) {
+            // Convert to resume
+            if (DEBUG) {
+                Log.d(
+                    TAG,
+                    "Notification ($notificationRemoved) and/or session " +
+                        "($hasSession) gone for inactive player $key"
+                )
+            }
+            convertToResumePlayer(key, removed)
+        } else {
+            // Retaining players flag is off and app doesn't support resume: remove player.
+            if (DEBUG) Log.d(TAG, "Removing player $key")
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
+        }
+    }
+
+    /** Set the given [MediaData] as a resume state player and notify listeners */
+    private fun convertToResumePlayer(key: String, data: MediaData) {
+        if (DEBUG) Log.d(TAG, "Converting $key to resume")
+        // Resumption controls must have a title.
+        if (data.song.isNullOrBlank()) {
+            Log.e(TAG, "Description incomplete")
+            notifyMediaDataRemoved(key)
+            logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
+            return
+        }
+        // Move to resume key (aka package name) if that key doesn't already exist.
+        val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
+        val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
+        val launcherIntent =
+            context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
+                PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
+            }
+        val lastActive =
+            if (data.active) {
+                systemClock.elapsedRealtime()
+            } else {
+                data.lastActive
+            }
+        val updated =
+            data.copy(
+                token = null,
+                actions = actions,
+                semanticActions = MediaButton(playOrPause = resumeAction),
+                actionsToShowInCompact = listOf(0),
+                active = false,
+                resumption = true,
+                isPlaying = false,
+                isClearable = true,
+                clickIntent = launcherIntent,
+                lastActive = lastActive,
+            )
+        val pkg = data.packageName
+        val migrate = mediaDataRepository.addMediaEntry(pkg, updated) == null
+        // Notify listeners of "new" controls when migrating or removed and update when not
+        Log.d(TAG, "migrating? $migrate from $key -> $pkg")
+        if (migrate) {
+            notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
+        } else {
+            // Since packageName is used for the key of the resumption controls, it is
+            // possible that another notification has already been reused for the resumption
+            // controls of this package. In this case, rather than renaming this player as
+            // packageName, just remove it and then send a update to the existing resumption
+            // controls.
+            notifyMediaDataRemoved(key)
+            notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
+        }
+        logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
+
+        // Limit total number of resume controls
+        val resumeEntries =
+            mediaDataRepository.mediaEntries.value.filter { (_, data) -> data.resumption }
+        val numResume = resumeEntries.size
+        if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
+            resumeEntries
+                .toList()
+                .sortedBy { (_, data) -> data.lastActive }
+                .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS)
+                .forEach { (key, data) ->
+                    Log.d(TAG, "Removing excess control $key")
+                    mediaDataRepository.removeMediaEntry(key)
+                    notifyMediaDataRemoved(key)
+                    logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
+                }
+        }
+    }
+
+    fun setMediaResumptionEnabled(isEnabled: Boolean) {
+        if (useMediaResumption == isEnabled) {
+            return
+        }
+
+        useMediaResumption = isEnabled
+
+        if (!useMediaResumption) {
+            // Remove any existing resume controls
+            val filtered = mediaDataRepository.mediaEntries.value.filter { !it.value.active }
+            filtered.forEach {
+                mediaDataRepository.removeMediaEntry(it.key)
+                notifyMediaDataRemoved(it.key)
+                logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId)
+            }
+        }
+    }
+
+    /** Listener to data changes. */
+    interface Listener {
+
+        /**
+         * Called whenever there's new MediaData Loaded for the consumption in views.
+         *
+         * oldKey is provided to check whether the view has changed keys, which can happen when a
+         * player has gone from resume state (key is package name) to active state (key is
+         * notification key) or vice versa.
+         *
+         * @param immediately indicates should apply the UI changes immediately, otherwise wait
+         *   until the next refresh-round before UI becomes visible. True by default to take in
+         *   place immediately.
+         * @param receivedSmartspaceCardLatency is the latency between headphone connects and sysUI
+         *   displays Smartspace media targets. Will be 0 if the data is not activated by Smartspace
+         *   signal.
+         * @param isSsReactivated indicates resume media card is reactivated by Smartspace
+         *   recommendation signal
+         */
+        fun onMediaDataLoaded(
+            key: String,
+            oldKey: String?,
+            data: MediaData,
+            immediately: Boolean = true,
+            receivedSmartspaceCardLatency: Int = 0,
+            isSsReactivated: Boolean = false
+        ) {}
+
+        /**
+         * Called whenever there's new Smartspace media data loaded.
+         *
+         * @param shouldPrioritize indicates the sorting priority of the Smartspace card. If true,
+         *   it will be prioritized as the first card. Otherwise, it will show up as the last card
+         *   as default.
+         */
+        fun onSmartspaceMediaDataLoaded(
+            key: String,
+            data: SmartspaceMediaData,
+            shouldPrioritize: Boolean = false
+        ) {}
+
+        /** Called whenever a previously existing Media notification was removed. */
+        fun onMediaDataRemoved(key: String) {}
+
+        /**
+         * Called whenever a previously existing Smartspace media data was removed.
+         *
+         * @param immediately indicates should apply the UI changes immediately, otherwise wait
+         *   until the next refresh-round before UI becomes visible. True by default to take in
+         *   place immediately.
+         */
+        fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean = true) {}
+    }
+
+    /**
+     * Converts the pass-in SmartspaceTarget to SmartspaceMediaData
+     *
+     * @return An empty SmartspaceMediaData with the valid target Id is returned if the
+     *   SmartspaceTarget's data is invalid.
+     */
+    private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData {
+        val baseAction: SmartspaceAction? = target.baseAction
+        val dismissIntent =
+            baseAction
+                ?.extras
+                ?.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY, Intent::class.java)
+
+        val isActive =
+            when {
+                !mediaFlags.isPersistentSsCardEnabled() -> true
+                baseAction == null -> true
+                else -> {
+                    val triggerSource = baseAction.extras?.getString(EXTRA_KEY_TRIGGER_SOURCE)
+                    triggerSource != EXTRA_VALUE_TRIGGER_PERIODIC
+                }
+            }
+
+        packageName(target)?.let {
+            return SmartspaceMediaData(
+                targetId = target.smartspaceTargetId,
+                isActive = isActive,
+                packageName = it,
+                cardAction = target.baseAction,
+                recommendations = target.iconGrid,
+                dismissIntent = dismissIntent,
+                headphoneConnectionTimeMillis = target.creationTimeMillis,
+                instanceId = logger.getNewInstanceId(),
+                expiryTimeMs = target.expiryTimeMillis,
+            )
+        }
+        return SmartspaceMediaData(
+            targetId = target.smartspaceTargetId,
+            isActive = isActive,
+            dismissIntent = dismissIntent,
+            headphoneConnectionTimeMillis = target.creationTimeMillis,
+            instanceId = logger.getNewInstanceId(),
+            expiryTimeMs = target.expiryTimeMillis,
+        )
+    }
+
+    private fun packageName(target: SmartspaceTarget): String? {
+        val recommendationList: MutableList<SmartspaceAction> = target.iconGrid
+        if (recommendationList.isEmpty()) {
+            Log.w(TAG, "Empty or null media recommendation list.")
+            return null
+        }
+        for (recommendation in recommendationList) {
+            val extras = recommendation.extras
+            extras?.let {
+                it.getString(EXTRAS_MEDIA_SOURCE_PACKAGE_NAME)?.let { packageName ->
+                    return packageName
+                }
+            }
+        }
+        Log.w(TAG, "No valid package name is provided.")
+        return null
+    }
+
+    override fun dump(pw: PrintWriter, args: Array<out String>) {
+        pw.apply {
+            println("internalListeners: $internalListeners")
+            println("useMediaResumption: $useMediaResumption")
+            println("allowMediaRecommendations: $allowMediaRecommendations")
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManager.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManager.kt
index f4d70a5..c7cfb0b 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManager.kt
@@ -35,10 +35,8 @@
 import com.android.settingslib.media.LocalMediaManager
 import com.android.settingslib.media.MediaDevice
 import com.android.settingslib.media.PhoneMediaDevice
-import com.android.systemui.Dumpable
 import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.dagger.qualifiers.Main
-import com.android.systemui.dump.DumpManager
 import com.android.systemui.media.controls.shared.model.MediaData
 import com.android.systemui.media.controls.shared.model.MediaDeviceData
 import com.android.systemui.media.controls.util.LocalMediaManagerFactory
@@ -70,16 +68,11 @@
     private val localBluetoothManager: Lazy<LocalBluetoothManager?>,
     @Main private val fgExecutor: Executor,
     @Background private val bgExecutor: Executor,
-    dumpManager: DumpManager,
-) : MediaDataManager.Listener, Dumpable {
+) : MediaDataManager.Listener {
 
     private val listeners: MutableSet<Listener> = mutableSetOf()
     private val entries: MutableMap<String, Entry> = mutableMapOf()
 
-    init {
-        dumpManager.registerDumpable(this)
-    }
-
     /** Add a listener for changes to the media route (ie. device). */
     fun addListener(listener: Listener) = listeners.add(listener)
 
@@ -123,7 +116,7 @@
         token?.let { listeners.forEach { it.onKeyRemoved(key) } }
     }
 
-    override fun dump(pw: PrintWriter, args: Array<String>) {
+    fun dump(pw: PrintWriter) {
         with(pw) {
             println("MediaDeviceManager state:")
             entries.forEach { (key, entry) ->
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractor.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractor.kt
new file mode 100644
index 0000000..4a92b71
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractor.kt
@@ -0,0 +1,234 @@
+/*
+ * 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.media.controls.domain.pipeline.interactor
+
+import android.app.PendingIntent
+import android.media.MediaDescription
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.service.notification.StatusBarNotification
+import com.android.systemui.CoreStartable
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.media.controls.data.repository.MediaDataRepository
+import com.android.systemui.media.controls.data.repository.MediaFilterRepository
+import com.android.systemui.media.controls.domain.pipeline.MediaDataCombineLatest
+import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl
+import com.android.systemui.media.controls.domain.pipeline.MediaDataManager
+import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor
+import com.android.systemui.media.controls.domain.pipeline.MediaDeviceManager
+import com.android.systemui.media.controls.domain.pipeline.MediaSessionBasedFilter
+import com.android.systemui.media.controls.domain.pipeline.MediaTimeoutListener
+import com.android.systemui.media.controls.domain.resume.MediaResumeListener
+import com.android.systemui.media.controls.util.MediaFlags
+import java.io.PrintWriter
+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.distinctUntilChanged
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+
+/** Encapsulates business logic for media pipeline. */
+@OptIn(ExperimentalCoroutinesApi::class)
+@SysUISingleton
+class MediaCarouselInteractor
+@Inject
+constructor(
+    @Application applicationScope: CoroutineScope,
+    private val mediaDataRepository: MediaDataRepository,
+    private val mediaDataProcessor: MediaDataProcessor,
+    private val mediaTimeoutListener: MediaTimeoutListener,
+    private val mediaResumeListener: MediaResumeListener,
+    private val mediaSessionBasedFilter: MediaSessionBasedFilter,
+    private val mediaDeviceManager: MediaDeviceManager,
+    private val mediaDataCombineLatest: MediaDataCombineLatest,
+    private val mediaDataFilter: MediaDataFilterImpl,
+    mediaFilterRepository: MediaFilterRepository,
+    private val mediaFlags: MediaFlags,
+) : MediaDataManager, CoreStartable {
+
+    /** Are there any media notifications active, including the recommendations? */
+    val hasActiveMediaOrRecommendation: StateFlow<Boolean> =
+        combine(
+                mediaFilterRepository.selectedUserEntries,
+                mediaFilterRepository.smartspaceMediaData,
+                mediaFilterRepository.reactivatedKey
+            ) { entries, smartspaceMediaData, reactivatedKey ->
+                entries.any { it.value.active } ||
+                    (smartspaceMediaData.isActive &&
+                        (smartspaceMediaData.isValid() || reactivatedKey != null))
+            }
+            .distinctUntilChanged()
+            .stateIn(applicationScope, SharingStarted.WhileSubscribed(), false)
+
+    /** Are there any media entries we should display, including the recommendations? */
+    val hasAnyMediaOrRecommendation: StateFlow<Boolean> =
+        combine(
+                mediaFilterRepository.selectedUserEntries,
+                mediaFilterRepository.smartspaceMediaData
+            ) { entries, smartspaceMediaData ->
+                entries.isNotEmpty() ||
+                    (if (mediaFlags.isPersistentSsCardEnabled()) {
+                        smartspaceMediaData.isValid()
+                    } else {
+                        smartspaceMediaData.isActive && smartspaceMediaData.isValid()
+                    })
+            }
+            .distinctUntilChanged()
+            .stateIn(applicationScope, SharingStarted.WhileSubscribed(), false)
+
+    /** Are there any media notifications active, excluding the recommendations? */
+    val hasActiveMedia: StateFlow<Boolean> =
+        mediaFilterRepository.selectedUserEntries
+            .mapLatest { entries -> entries.any { it.value.active } }
+            .distinctUntilChanged()
+            .stateIn(applicationScope, SharingStarted.WhileSubscribed(), false)
+
+    /** Are there any media notifications, excluding the recommendations? */
+    val hasAnyMedia: StateFlow<Boolean> =
+        mediaFilterRepository.selectedUserEntries
+            .mapLatest { entries -> entries.isNotEmpty() }
+            .distinctUntilChanged()
+            .stateIn(applicationScope, SharingStarted.WhileSubscribed(), false)
+
+    override fun start() {
+        if (!mediaFlags.isMediaControlsRefactorEnabled()) {
+            return
+        }
+
+        // Initialize the internal processing pipeline. The listeners at the front of the pipeline
+        // are set as internal listeners so that they receive events. From there, events are
+        // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter,
+        // so it is responsible for dispatching events to external listeners. To achieve this,
+        // external listeners that are registered with [MediaDataManager.addListener] are actually
+        // registered as listeners to mediaDataFilter.
+        addInternalListener(mediaTimeoutListener)
+        addInternalListener(mediaResumeListener)
+        addInternalListener(mediaSessionBasedFilter)
+        mediaSessionBasedFilter.addListener(mediaDeviceManager)
+        mediaSessionBasedFilter.addListener(mediaDataCombineLatest)
+        mediaDeviceManager.addListener(mediaDataCombineLatest)
+        mediaDataCombineLatest.addListener(mediaDataFilter)
+
+        // Set up links back into the pipeline for listeners that need to send events upstream.
+        mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean ->
+            setInactive(key, timedOut)
+        }
+        mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
+            mediaDataProcessor.updateState(key, state)
+        }
+        mediaTimeoutListener.sessionCallback = { key: String ->
+            mediaDataProcessor.onSessionDestroyed(key)
+        }
+        mediaResumeListener.setManager(this)
+        mediaDataFilter.mediaDataManager = this
+    }
+
+    override fun addListener(listener: MediaDataManager.Listener) {
+        mediaDataFilter.addListener(listener)
+    }
+
+    override fun removeListener(listener: MediaDataManager.Listener) {
+        mediaDataFilter.removeListener(listener)
+    }
+
+    override fun setInactive(key: String, timedOut: Boolean, forceUpdate: Boolean) {
+        mediaDataProcessor.setInactive(key, timedOut, forceUpdate)
+    }
+
+    override fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
+        mediaDataProcessor.onNotificationAdded(key, sbn)
+    }
+
+    override fun destroy() {
+        mediaSessionBasedFilter.removeListener(mediaDeviceManager)
+        mediaSessionBasedFilter.removeListener(mediaDataCombineLatest)
+        mediaDeviceManager.removeListener(mediaDataCombineLatest)
+        mediaDataCombineLatest.removeListener(mediaDataFilter)
+        mediaDataProcessor.destroy()
+    }
+
+    override fun setResumeAction(key: String, action: Runnable?) {
+        mediaDataProcessor.setResumeAction(key, action)
+    }
+
+    override fun addResumptionControls(
+        userId: Int,
+        desc: MediaDescription,
+        action: Runnable,
+        token: MediaSession.Token,
+        appName: String,
+        appIntent: PendingIntent,
+        packageName: String
+    ) {
+        mediaDataProcessor.addResumptionControls(
+            userId,
+            desc,
+            action,
+            token,
+            appName,
+            appIntent,
+            packageName
+        )
+    }
+
+    override fun dismissMediaData(key: String, delay: Long): Boolean {
+        return mediaDataProcessor.dismissMediaData(key, delay)
+    }
+
+    override fun dismissSmartspaceRecommendation(key: String, delay: Long) {
+        return mediaDataProcessor.dismissSmartspaceRecommendation(key, delay)
+    }
+
+    override fun setRecommendationInactive(key: String) {
+        mediaDataProcessor.setRecommendationInactive(key)
+    }
+
+    override fun onNotificationRemoved(key: String) {
+        mediaDataProcessor.onNotificationRemoved(key)
+    }
+
+    override fun setMediaResumptionEnabled(isEnabled: Boolean) {
+        mediaDataProcessor.setMediaResumptionEnabled(isEnabled)
+    }
+
+    override fun onSwipeToDismiss() {
+        mediaDataFilter.onSwipeToDismiss()
+    }
+
+    override fun hasActiveMediaOrRecommendation() = hasActiveMediaOrRecommendation.value
+
+    override fun hasAnyMediaOrRecommendation() = hasAnyMediaOrRecommendation.value
+
+    override fun hasActiveMedia() = hasActiveMedia.value
+
+    override fun hasAnyMedia() = hasAnyMedia.value
+
+    override fun isRecommendationActive() = mediaDataRepository.smartspaceMediaData.value.isActive
+
+    /** Add a listener for internal events. */
+    private fun addInternalListener(listener: MediaDataManager.Listener) =
+        mediaDataProcessor.addInternalListener(listener)
+
+    override fun dump(pw: PrintWriter, args: Array<out String>) {
+        mediaDeviceManager.dump(pw)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/MediaData.kt
index 4fa7cb5..11a5629 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/MediaData.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/MediaData.kt
@@ -20,48 +20,49 @@
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.Icon
 import android.media.session.MediaSession
+import android.os.Process
 import com.android.internal.logging.InstanceId
 import com.android.systemui.res.R
 
 /** State of a media view. */
 data class MediaData(
-    val userId: Int,
+    val userId: Int = -1,
     val initialized: Boolean = false,
     /** App name that will be displayed on the player. */
-    val app: String?,
+    val app: String? = null,
     /** App icon shown on player. */
-    val appIcon: Icon?,
+    val appIcon: Icon? = null,
     /** Artist name. */
-    val artist: CharSequence?,
+    val artist: CharSequence? = null,
     /** Song name. */
-    val song: CharSequence?,
+    val song: CharSequence? = null,
     /** Album artwork. */
-    val artwork: Icon?,
+    val artwork: Icon? = null,
     /** List of generic action buttons for the media player, based on notification actions */
-    val actions: List<MediaAction>,
+    val actions: List<MediaAction> = emptyList(),
     /** Same as above, but shown on smaller versions of the player, like in QQS or keyguard. */
-    val actionsToShowInCompact: List<Int>,
+    val actionsToShowInCompact: List<Int> = emptyList(),
     /**
      * Semantic actions buttons, based on the PlaybackState of the media session. If present, these
      * actions will be preferred in the UI over [actions]
      */
     val semanticActions: MediaButton? = null,
     /** Package name of the app that's posting the media. */
-    val packageName: String,
+    val packageName: String = "INVALID",
     /** Unique media session identifier. */
-    val token: MediaSession.Token?,
+    val token: MediaSession.Token? = null,
     /** Action to perform when the player is tapped. This is unrelated to {@link #actions}. */
-    val clickIntent: PendingIntent?,
+    val clickIntent: PendingIntent? = null,
     /** Where the media is playing: phone, headphones, ear buds, remote session. */
-    val device: MediaDeviceData?,
+    val device: MediaDeviceData? = null,
     /**
      * When active, a player will be displayed on keyguard and quick-quick settings. This is
      * unrelated to the stream being playing or not, a player will not be active if timed out, or in
      * resumption mode.
      */
-    var active: Boolean,
+    var active: Boolean = true,
     /** Action that should be performed to restart a non active session. */
-    var resumeAction: Runnable?,
+    var resumeAction: Runnable? = null,
     /** Playback location: one of PLAYBACK_LOCAL, PLAYBACK_CAST_LOCAL, or PLAYBACK_CAST_REMOTE */
     var playbackLocation: Int = PLAYBACK_LOCAL,
     /**
@@ -88,10 +89,10 @@
     var createdTimestampMillis: Long = 0L,
 
     /** Instance ID for logging purposes */
-    val instanceId: InstanceId,
+    val instanceId: InstanceId = InstanceId.fakeInstanceId(-1),
 
     /** The UID of the app, used for logging */
-    val appUid: Int,
+    val appUid: Int = Process.INVALID_UID,
 
     /** Whether explicit indicator exists */
     val isExplicit: Boolean = false,
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/SmartspaceMediaData.kt b/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/SmartspaceMediaData.kt
index 52c605f..b446585 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/SmartspaceMediaData.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/shared/model/SmartspaceMediaData.kt
@@ -30,23 +30,23 @@
 /** State of a Smartspace media recommendations view. */
 data class SmartspaceMediaData(
     /** Unique id of a Smartspace media target. */
-    val targetId: String,
+    val targetId: String = "INVALID",
     /** Indicates if the status is active. */
-    val isActive: Boolean,
+    val isActive: Boolean = false,
     /** Package name of the media recommendations' provider-app. */
-    val packageName: String,
+    val packageName: String = "INVALID",
     /** Action to perform when the card is tapped. Also contains the target's extra info. */
-    val cardAction: SmartspaceAction?,
+    val cardAction: SmartspaceAction? = null,
     /** List of media recommendations. */
-    val recommendations: List<SmartspaceAction>,
+    val recommendations: List<SmartspaceAction> = emptyList(),
     /** Intent for the user's initiated dismissal. */
-    val dismissIntent: Intent?,
+    val dismissIntent: Intent? = null,
     /** The timestamp in milliseconds that the card was generated */
-    val headphoneConnectionTimeMillis: Long,
+    val headphoneConnectionTimeMillis: Long = 0L,
     /** Instance ID for [MediaUiEventLogger] */
-    val instanceId: InstanceId,
+    val instanceId: InstanceId? = null,
     /** The timestamp in milliseconds indicating when the card should be removed */
-    val expiryTimeMs: Long,
+    val expiryTimeMs: Long = 0L,
 ) {
     /**
      * Indicates if all the data is valid.
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/controller/MediaCarouselController.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/controller/MediaCarouselController.kt
index b721236..655e6a5 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/controller/MediaCarouselController.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/controller/MediaCarouselController.kt
@@ -1163,7 +1163,7 @@
         // Only log media resume card when Smartspace data is available
         if (
             !mediaControlKey.isSsMediaRec &&
-                !mediaManager.smartspaceMediaData.isActive &&
+                !mediaManager.isRecommendationActive() &&
                 MediaPlayerData.smartspaceMediaData == null
         ) {
             return
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt
index f8c816c..2c25fe2 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt
@@ -161,7 +161,7 @@
         logger.log(event)
     }
 
-    fun logRecommendationAdded(packageName: String, instanceId: InstanceId) {
+    fun logRecommendationAdded(packageName: String, instanceId: InstanceId?) {
         logger.logWithInstanceId(
             MediaUiEvent.MEDIA_RECOMMENDATION_ADDED,
             0,
@@ -170,7 +170,7 @@
         )
     }
 
-    fun logRecommendationRemoved(packageName: String, instanceId: InstanceId) {
+    fun logRecommendationRemoved(packageName: String, instanceId: InstanceId?) {
         logger.logWithInstanceId(
             MediaUiEvent.MEDIA_RECOMMENDATION_REMOVED,
             0,
diff --git a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java
index d84e5dd..0fa3605 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java
+++ b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java
@@ -19,6 +19,7 @@
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.log.LogBuffer;
 import com.android.systemui.log.LogBufferFactory;
+import com.android.systemui.media.controls.domain.MediaDomainModule;
 import com.android.systemui.media.controls.domain.pipeline.MediaDataManager;
 import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager;
 import com.android.systemui.media.controls.ui.controller.MediaHostStatesManager;
@@ -38,7 +39,11 @@
 import javax.inject.Named;
 
 /** Dagger module for the media package. */
-@Module(subcomponents = {
+@Module(
+        includes = {
+            MediaDomainModule.class
+        },
+        subcomponents = {
         MediaComplicationComponent.class,
 })
 public interface MediaModule {
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java b/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java
index dfe41eb..d49a513 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java
@@ -243,7 +243,7 @@
                 Settings.Secure.getUriFor(Settings.Secure.ASSIST_LONG_PRESS_HOME_ENABLED),
                 false, mAssistContentObserver, UserHandle.USER_ALL);
         mContentResolver.registerContentObserver(
-                Settings.Secure.getUriFor(Secure.SEARCH_LONG_PRESS_HOME_ENABLED),
+                Settings.Secure.getUriFor(Secure.SEARCH_ALL_ENTRYPOINTS_ENABLED),
                 false, mAssistContentObserver, UserHandle.USER_ALL);
         mContentResolver.registerContentObserver(
                 Settings.Secure.getUriFor(Settings.Secure.ASSIST_TOUCH_GESTURE_ENABLED),
@@ -443,10 +443,10 @@
         boolean overrideLongPressHome = mAssistManagerLazy.get()
                 .shouldOverrideAssist(AssistManager.INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS);
         boolean longPressDefault = mContext.getResources().getBoolean(overrideLongPressHome
-                ? com.android.internal.R.bool.config_searchLongPressHomeEnabledDefault
+                ? com.android.internal.R.bool.config_searchAllEntrypointsEnabledDefault
                 : com.android.internal.R.bool.config_assistLongPressHomeEnabledDefault);
         mLongPressHomeEnabled = Settings.Secure.getIntForUser(mContentResolver,
-                overrideLongPressHome ? Secure.SEARCH_LONG_PRESS_HOME_ENABLED
+                overrideLongPressHome ? Secure.SEARCH_ALL_ENTRYPOINTS_ENABLED
                         : Settings.Secure.ASSIST_LONG_PRESS_HOME_ENABLED, longPressDefault ? 1 : 0,
                 mUserTracker.getUserId()) != 0;
 
diff --git a/packages/SystemUI/src/com/android/systemui/qs/PagedTileLayout.java b/packages/SystemUI/src/com/android/systemui/qs/PagedTileLayout.java
index 5d2aeef..b34b370 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/PagedTileLayout.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/PagedTileLayout.java
@@ -432,6 +432,9 @@
         for (int i = 0; i < NP; i++) {
             mPages.get(i).removeAllViews();
         }
+        if (mPageIndicator != null) {
+            mPageIndicator.setNumPages(numPages);
+        }
         if (NP == numPages) {
             return;
         }
@@ -443,7 +446,6 @@
             mLogger.d("Removing page");
             mPages.remove(mPages.size() - 1);
         }
-        mPageIndicator.setNumPages(mPages.size());
         setAdapter(mAdapter);
         mAdapter.notifyDataSetChanged();
         if (mPageToRestore != NO_PAGE) {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/RecordIssueTile.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/RecordIssueTile.kt
index d82b175..b418a17 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/RecordIssueTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/RecordIssueTile.kt
@@ -44,6 +44,7 @@
 import com.android.systemui.qs.pipeline.domain.interactor.PanelInteractor
 import com.android.systemui.qs.tileimpl.QSTileImpl
 import com.android.systemui.recordissue.IssueRecordingService
+import com.android.systemui.recordissue.IssueRecordingState
 import com.android.systemui.recordissue.RecordIssueDialogDelegate
 import com.android.systemui.res.R
 import com.android.systemui.screenrecord.RecordingService
@@ -69,6 +70,7 @@
     private val dialogTransitionAnimator: DialogTransitionAnimator,
     private val panelInteractor: PanelInteractor,
     private val userContextProvider: UserContextProvider,
+    private val issueRecordingState: IssueRecordingState,
     private val delegateFactory: RecordIssueDialogDelegate.Factory,
 ) :
     QSTileImpl<QSTile.BooleanState>(
@@ -83,7 +85,16 @@
         qsLogger
     ) {
 
-    @VisibleForTesting var isRecording: Boolean = false
+    private val onRecordingChangeListener = Runnable { refreshState() }
+
+    override fun handleSetListening(listening: Boolean) {
+        super.handleSetListening(listening)
+        if (listening) {
+            issueRecordingState.addListener(onRecordingChangeListener)
+        } else {
+            issueRecordingState.removeListener(onRecordingChangeListener)
+        }
+    }
 
     override fun getTileLabel(): CharSequence = mContext.getString(R.string.qs_record_issue_label)
 
@@ -103,13 +114,11 @@
 
     @VisibleForTesting
     public override fun handleClick(view: View?) {
-        if (isRecording) {
-            isRecording = false
+        if (issueRecordingState.isRecording) {
             stopIssueRecordingService()
         } else {
             mUiHandler.post { showPrompt(view) }
         }
-        refreshState()
     }
 
     private fun startIssueRecordingService(screenRecord: Boolean, winscopeTracing: Boolean) =
@@ -138,11 +147,9 @@
         val dialog: AlertDialog =
             delegateFactory
                 .create {
-                    isRecording = true
                     startIssueRecordingService(it.screenRecord, it.winscopeTracing)
                     dialogTransitionAnimator.disableAllCurrentDialogsExitAnimations()
                     panelInteractor.collapsePanels()
-                    refreshState()
                 }
                 .createDialog()
         val dismissAction =
@@ -168,7 +175,7 @@
     @VisibleForTesting
     public override fun handleUpdateState(qsTileState: QSTile.BooleanState, arg: Any?) {
         qsTileState.apply {
-            if (isRecording) {
+            if (issueRecordingState.isRecording) {
                 value = true
                 state = Tile.STATE_ACTIVE
                 forceExpandIcon = false
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapper.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapper.kt
index 2b8c335..c0fc52e 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapper.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/alarm/domain/AlarmTileMapper.kt
@@ -83,6 +83,7 @@
                 }
             }
 
+            sideViewIcon = QSTileState.SideViewIcon.Chevron
             contentDescription = label
             supportedActions = setOf(QSTileState.UserAction.CLICK)
         }
diff --git a/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingService.kt b/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingService.kt
index 7009816..5e4919d 100644
--- a/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingService.kt
+++ b/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingService.kt
@@ -59,6 +59,7 @@
     keyguardDismissUtil: KeyguardDismissUtil,
     private val dialogTransitionAnimator: DialogTransitionAnimator,
     private val panelInteractor: PanelInteractor,
+    private val issueRecordingState: IssueRecordingState,
 ) :
     RecordingService(
         controller,
@@ -90,6 +91,7 @@
                     DEFAULT_MAX_TRACE_SIZE,
                     DEFAULT_MAX_TRACE_DURATION_IN_MINUTES
                 )
+                issueRecordingState.isRecording = true
                 if (!intent.getBooleanExtra(EXTRA_SCREEN_RECORD, false)) {
                     // If we don't want to record the screen, the ACTION_SHOW_START_NOTIF action
                     // will circumvent the RecordingService's screen recording start code.
@@ -103,6 +105,7 @@
                 // this line should be removed.
                 getSystemService(LauncherApps::class.java)?.saveViewCaptureData()
                 TraceUtils.traceStop(contentResolver)
+                issueRecordingState.isRecording = false
             }
             ACTION_SHARE -> {
                 shareRecording(intent)
diff --git a/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt b/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt
new file mode 100644
index 0000000..394c5c2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.recordissue
+
+import com.android.systemui.dagger.SysUISingleton
+import java.util.concurrent.CopyOnWriteArrayList
+import javax.inject.Inject
+
+@SysUISingleton
+class IssueRecordingState @Inject constructor() {
+
+    private val listeners = CopyOnWriteArrayList<Runnable>()
+
+    var isRecording = false
+        set(value) {
+            field = value
+            listeners.forEach(Runnable::run)
+        }
+
+    fun addListener(listener: Runnable) {
+        listeners.add(listener)
+    }
+
+    fun removeListener(listener: Runnable) {
+        listeners.remove(listener)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueDialogDelegate.kt b/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueDialogDelegate.kt
index 7313a49..832fc3f 100644
--- a/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueDialogDelegate.kt
+++ b/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueDialogDelegate.kt
@@ -17,6 +17,7 @@
 package com.android.systemui.recordissue
 
 import android.annotation.SuppressLint
+import android.app.AlertDialog
 import android.content.Context
 import android.content.res.ColorStateList
 import android.graphics.Color
@@ -74,7 +75,6 @@
 
     @SuppressLint("UseSwitchCompatOrMaterialCode") private lateinit var screenRecordSwitch: Switch
     private lateinit var issueTypeButton: Button
-    private var hasSelectedIssueType: Boolean = false
 
     @MainThread
     override fun beforeCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) {
@@ -86,15 +86,13 @@
             setPositiveButton(
                 R.string.qs_record_issue_start,
                 { _, _ ->
-                    if (hasSelectedIssueType) {
-                        onStarted.accept(
-                            IssueRecordingConfig(
-                                screenRecordSwitch.isChecked,
-                                true /* TODO: Base this on issueType selected */
-                            )
+                    onStarted.accept(
+                        IssueRecordingConfig(
+                            screenRecordSwitch.isChecked,
+                            true /* TODO: Base this on issueType selected */
                         )
-                        dismiss()
-                    }
+                    )
+                    dismiss()
                 },
                 false
             )
@@ -115,8 +113,12 @@
                     bgExecutor.execute { onScreenRecordSwitchClicked() }
                 }
             }
+            val startButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
             issueTypeButton = requireViewById(R.id.issue_type_button)
-            issueTypeButton.setOnClickListener { onIssueTypeClicked(context) }
+            issueTypeButton.setOnClickListener {
+                onIssueTypeClicked(context) { startButton.isEnabled = true }
+            }
+            startButton.isEnabled = false
         }
     }
 
@@ -159,7 +161,7 @@
     }
 
     @MainThread
-    private fun onIssueTypeClicked(context: Context) {
+    private fun onIssueTypeClicked(context: Context, onIssueTypeSelected: Runnable) {
         val selectedCategory = issueTypeButton.text.toString()
         val popupMenu = PopupMenu(context, issueTypeButton)
 
@@ -174,11 +176,11 @@
         popupMenu.apply {
             setOnMenuItemClickListener {
                 issueTypeButton.text = it.title
+                onIssueTypeSelected.run()
                 true
             }
             setForceShowIcon(true)
             show()
         }
-        hasSelectedIssueType = true
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt
new file mode 100644
index 0000000..abdbd68
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.screenshot
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.UserHandle
+import androidx.appcompat.content.res.AppCompatResources
+import com.android.systemui.res.R
+import javax.inject.Inject
+
+/**
+ * Provides static actions for screenshots. This class can be overridden by a vendor-specific SysUI
+ * implementation.
+ */
+interface ScreenshotActionsProvider {
+    data class ScreenshotAction(
+        val icon: Drawable?,
+        val text: String?,
+        val overrideTransition: Boolean,
+        val retrieveIntent: (Uri) -> Intent
+    )
+
+    fun getPreviewAction(context: Context, uri: Uri, user: UserHandle): Intent
+    fun getActions(context: Context, user: UserHandle): List<ScreenshotAction>
+}
+
+class DefaultScreenshotActionsProvider @Inject constructor() : ScreenshotActionsProvider {
+    override fun getPreviewAction(context: Context, uri: Uri, user: UserHandle): Intent {
+        return ActionIntentCreator.createEdit(uri, context)
+    }
+
+    override fun getActions(
+        context: Context,
+        user: UserHandle
+    ): List<ScreenshotActionsProvider.ScreenshotAction> {
+        val editAction =
+            ScreenshotActionsProvider.ScreenshotAction(
+                AppCompatResources.getDrawable(context, R.drawable.ic_screenshot_edit),
+                context.resources.getString(R.string.screenshot_edit_label),
+                true
+            ) { uri ->
+                ActionIntentCreator.createEdit(uri, context)
+            }
+        val shareAction =
+            ScreenshotActionsProvider.ScreenshotAction(
+                AppCompatResources.getDrawable(context, R.drawable.ic_screenshot_share),
+                context.resources.getString(R.string.screenshot_share_label),
+                false
+            ) { uri ->
+                ActionIntentCreator.createShare(uri)
+            }
+        return listOf(editAction, shareAction)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt
new file mode 100644
index 0000000..9354fd2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt
@@ -0,0 +1,226 @@
+/*
+ * 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.screenshot
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Rect
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.ScrollCaptureResponse
+import android.view.View
+import android.view.ViewTreeObserver
+import android.view.WindowInsets
+import android.window.OnBackInvokedCallback
+import android.window.OnBackInvokedDispatcher
+import com.android.internal.logging.UiEventLogger
+import com.android.systemui.log.DebugLogger.debugLog
+import com.android.systemui.res.R
+import com.android.systemui.screenshot.LogConfig.DEBUG_ACTIONS
+import com.android.systemui.screenshot.LogConfig.DEBUG_DISMISS
+import com.android.systemui.screenshot.LogConfig.DEBUG_INPUT
+import com.android.systemui.screenshot.LogConfig.DEBUG_WINDOW
+import com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_DISMISSED_OTHER
+import com.android.systemui.screenshot.scroll.ScrollCaptureController
+import com.android.systemui.screenshot.ui.ScreenshotAnimationController
+import com.android.systemui.screenshot.ui.ScreenshotShelfView
+import com.android.systemui.screenshot.ui.binder.ScreenshotShelfViewBinder
+import com.android.systemui.screenshot.ui.viewmodel.ActionButtonViewModel
+import com.android.systemui.screenshot.ui.viewmodel.ScreenshotViewModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+/** Controls the screenshot view and viewModel. */
+class ScreenshotShelfViewProxy
+@AssistedInject
+constructor(
+    private val logger: UiEventLogger,
+    private val viewModel: ScreenshotViewModel,
+    private val staticActionsProvider: ScreenshotActionsProvider,
+    @Assisted private val context: Context,
+    @Assisted private val displayId: Int
+) : ScreenshotViewProxy {
+    override val view: ScreenshotShelfView =
+        LayoutInflater.from(context).inflate(R.layout.screenshot_shelf, null) as ScreenshotShelfView
+    override val screenshotPreview: View
+    override var packageName: String = ""
+    override var callbacks: ScreenshotView.ScreenshotViewCallback? = null
+    override var screenshot: ScreenshotData? = null
+        set(value) {
+            viewModel.setScreenshotBitmap(value?.bitmap)
+            field = value
+        }
+
+    override val isAttachedToWindow
+        get() = view.isAttachedToWindow
+    override var isDismissing = false
+    override var isPendingSharedTransition = false
+
+    private val animationController = ScreenshotAnimationController(view)
+
+    init {
+        ScreenshotShelfViewBinder.bind(view, viewModel, LayoutInflater.from(context))
+        addPredictiveBackListener { requestDismissal(SCREENSHOT_DISMISSED_OTHER) }
+        setOnKeyListener { requestDismissal(SCREENSHOT_DISMISSED_OTHER) }
+        debugLog(DEBUG_WINDOW) { "adding OnComputeInternalInsetsListener" }
+        screenshotPreview = view.screenshotPreview
+    }
+
+    override fun reset() {
+        animationController.cancel()
+        isPendingSharedTransition = false
+        viewModel.setScreenshotBitmap(null)
+        viewModel.setActions(listOf())
+    }
+    override fun updateInsets(insets: WindowInsets) {}
+    override fun updateOrientation(insets: WindowInsets) {}
+
+    override fun createScreenshotDropInAnimation(screenRect: Rect, showFlash: Boolean): Animator {
+        return animationController.getEntranceAnimation()
+    }
+
+    override fun addQuickShareChip(quickShareAction: Notification.Action) {}
+
+    override fun setChipIntents(imageData: ScreenshotController.SavedImageData) {
+        val staticActions =
+            staticActionsProvider.getActions(context, imageData.owner).map {
+                ActionButtonViewModel(it.icon, it.text) {
+                    val intent = it.retrieveIntent(imageData.uri)
+                    debugLog(DEBUG_ACTIONS) { "Action tapped: $intent" }
+                    isPendingSharedTransition = true
+                    callbacks?.onAction(intent, imageData.owner, it.overrideTransition)
+                }
+            }
+
+        viewModel.setActions(staticActions)
+    }
+
+    override fun requestDismissal(event: ScreenshotEvent) {
+        debugLog(DEBUG_DISMISS) { "screenshot dismissal requested: $event" }
+
+        // If we're already animating out, don't restart the animation
+        if (isDismissing) {
+            debugLog(DEBUG_DISMISS) { "Already dismissing, ignoring duplicate command $event" }
+            return
+        }
+        logger.log(event, 0, packageName)
+        val animator = animationController.getExitAnimation()
+        animator.addListener(
+            object : AnimatorListenerAdapter() {
+                override fun onAnimationStart(animator: Animator) {
+                    isDismissing = true
+                }
+                override fun onAnimationEnd(animator: Animator) {
+                    isDismissing = false
+                    callbacks?.onDismiss()
+                }
+            }
+        )
+        animator.start()
+    }
+
+    override fun showScrollChip(packageName: String, onClick: Runnable) {}
+
+    override fun hideScrollChip() {}
+
+    override fun prepareScrollingTransition(
+        response: ScrollCaptureResponse,
+        screenBitmap: Bitmap,
+        newScreenshot: Bitmap,
+        screenshotTakenInPortrait: Boolean,
+        onTransitionPrepared: Runnable,
+    ) {}
+
+    override fun startLongScreenshotTransition(
+        transitionDestination: Rect,
+        onTransitionEnd: Runnable,
+        longScreenshot: ScrollCaptureController.LongScreenshot
+    ) {}
+
+    override fun restoreNonScrollingUi() {}
+
+    override fun stopInputListening() {}
+
+    override fun requestFocus() {
+        view.requestFocus()
+    }
+
+    override fun announceForAccessibility(string: String) = view.announceForAccessibility(string)
+
+    override fun prepareEntranceAnimation(runnable: Runnable) {
+        view.viewTreeObserver.addOnPreDrawListener(
+            object : ViewTreeObserver.OnPreDrawListener {
+                override fun onPreDraw(): Boolean {
+                    debugLog(DEBUG_WINDOW) { "onPreDraw: startAnimation" }
+                    view.viewTreeObserver.removeOnPreDrawListener(this)
+                    runnable.run()
+                    return true
+                }
+            }
+        )
+    }
+
+    private fun addPredictiveBackListener(onDismissRequested: (ScreenshotEvent) -> Unit) {
+        val onBackInvokedCallback = OnBackInvokedCallback {
+            debugLog(DEBUG_INPUT) { "Predictive Back callback dispatched" }
+            onDismissRequested.invoke(SCREENSHOT_DISMISSED_OTHER)
+        }
+        view.addOnAttachStateChangeListener(
+            object : View.OnAttachStateChangeListener {
+                override fun onViewAttachedToWindow(v: View) {
+                    debugLog(DEBUG_INPUT) { "Registering Predictive Back callback" }
+                    view
+                        .findOnBackInvokedDispatcher()
+                        ?.registerOnBackInvokedCallback(
+                            OnBackInvokedDispatcher.PRIORITY_DEFAULT,
+                            onBackInvokedCallback
+                        )
+                }
+
+                override fun onViewDetachedFromWindow(view: View) {
+                    debugLog(DEBUG_INPUT) { "Unregistering Predictive Back callback" }
+                    view
+                        .findOnBackInvokedDispatcher()
+                        ?.unregisterOnBackInvokedCallback(onBackInvokedCallback)
+                }
+            }
+        )
+    }
+    private fun setOnKeyListener(onDismissRequested: (ScreenshotEvent) -> Unit) {
+        view.setOnKeyListener(
+            object : View.OnKeyListener {
+                override fun onKey(view: View, keyCode: Int, event: KeyEvent): Boolean {
+                    if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_ESCAPE) {
+                        debugLog(DEBUG_INPUT) { "onKeyEvent: $keyCode" }
+                        onDismissRequested.invoke(SCREENSHOT_DISMISSED_OTHER)
+                        return true
+                    }
+                    return false
+                }
+            }
+        )
+    }
+
+    @AssistedFactory
+    interface Factory : ScreenshotViewProxy.Factory {
+        override fun getProxy(context: Context, displayId: Int): ScreenshotShelfViewProxy
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java b/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java
index cdb9abb..9118ee1 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java
@@ -16,16 +16,23 @@
 
 package com.android.systemui.screenshot.dagger;
 
-import android.app.Service;
+import static com.android.systemui.Flags.screenshotShelfUi;
 
+import android.app.Service;
+import android.view.accessibility.AccessibilityManager;
+
+import com.android.systemui.dagger.SysUISingleton;
+import com.android.systemui.screenshot.DefaultScreenshotActionsProvider;
 import com.android.systemui.screenshot.ImageCapture;
 import com.android.systemui.screenshot.ImageCaptureImpl;
 import com.android.systemui.screenshot.LegacyScreenshotViewProxy;
 import com.android.systemui.screenshot.RequestProcessor;
+import com.android.systemui.screenshot.ScreenshotActionsProvider;
 import com.android.systemui.screenshot.ScreenshotPolicy;
 import com.android.systemui.screenshot.ScreenshotPolicyImpl;
 import com.android.systemui.screenshot.ScreenshotProxyService;
 import com.android.systemui.screenshot.ScreenshotRequestProcessor;
+import com.android.systemui.screenshot.ScreenshotShelfViewProxy;
 import com.android.systemui.screenshot.ScreenshotSoundController;
 import com.android.systemui.screenshot.ScreenshotSoundControllerImpl;
 import com.android.systemui.screenshot.ScreenshotSoundProvider;
@@ -34,6 +41,7 @@
 import com.android.systemui.screenshot.TakeScreenshotService;
 import com.android.systemui.screenshot.appclips.AppClipsScreenshotHelperService;
 import com.android.systemui.screenshot.appclips.AppClipsService;
+import com.android.systemui.screenshot.ui.viewmodel.ScreenshotViewModel;
 
 import dagger.Binds;
 import dagger.Module;
@@ -85,9 +93,25 @@
     abstract ScreenshotSoundController bindScreenshotSoundController(
             ScreenshotSoundControllerImpl screenshotSoundProviderImpl);
 
+    @Binds
+    abstract ScreenshotActionsProvider bindScreenshotActionsProvider(
+            DefaultScreenshotActionsProvider defaultScreenshotActionsProvider);
+
+    @Provides
+    @SysUISingleton
+    static ScreenshotViewModel providesScreenshotViewModel(
+            AccessibilityManager accessibilityManager) {
+        return new ScreenshotViewModel(accessibilityManager);
+    }
+
     @Provides
     static ScreenshotViewProxy.Factory providesScreenshotViewProxyFactory(
+            ScreenshotShelfViewProxy.Factory shelfScreenshotViewProxyFactory,
             LegacyScreenshotViewProxy.Factory legacyScreenshotViewProxyFactory) {
-        return legacyScreenshotViewProxyFactory;
+        if (screenshotShelfUi()) {
+            return shelfScreenshotViewProxyFactory;
+        } else {
+            return legacyScreenshotViewProxyFactory;
+        }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/ScreenshotAnimationController.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/ScreenshotAnimationController.kt
new file mode 100644
index 0000000..2c17873
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/ScreenshotAnimationController.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.screenshot.ui
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ValueAnimator
+import android.view.View
+
+class ScreenshotAnimationController(private val view: View) {
+    private var animator: Animator? = null
+
+    fun getEntranceAnimation(): Animator {
+        val animator = ValueAnimator.ofFloat(0f, 1f)
+        animator.addUpdateListener { view.alpha = it.animatedFraction }
+        animator.addListener(
+            object : AnimatorListenerAdapter() {
+                override fun onAnimationStart(animator: Animator) {
+                    view.alpha = 0f
+                }
+                override fun onAnimationEnd(animator: Animator) {
+                    view.alpha = 1f
+                }
+            }
+        )
+        this.animator = animator
+        return animator
+    }
+
+    fun getExitAnimation(): Animator {
+        val animator = ValueAnimator.ofFloat(1f, 0f)
+        animator.addUpdateListener { view.alpha = it.animatedValue as Float }
+        animator.addListener(
+            object : AnimatorListenerAdapter() {
+                override fun onAnimationStart(animator: Animator) {
+                    view.alpha = 1f
+                }
+                override fun onAnimationEnd(animator: Animator) {
+                    view.alpha = 0f
+                }
+            }
+        )
+        this.animator = animator
+        return animator
+    }
+
+    fun cancel() {
+        animator?.cancel()
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/ScreenshotShelfView.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/ScreenshotShelfView.kt
new file mode 100644
index 0000000..747ad4f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/ScreenshotShelfView.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.screenshot.ui
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.ImageView
+import androidx.constraintlayout.widget.ConstraintLayout
+import com.android.systemui.res.R
+
+class ScreenshotShelfView(context: Context, attrs: AttributeSet? = null) :
+    ConstraintLayout(context, attrs) {
+    lateinit var screenshotPreview: ImageView
+
+    override fun onFinishInflate() {
+        super.onFinishInflate()
+        screenshotPreview = requireViewById(R.id.screenshot_preview)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ActionButtonViewBinder.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ActionButtonViewBinder.kt
new file mode 100644
index 0000000..a5825b5
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ActionButtonViewBinder.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.screenshot.ui.binder
+
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.android.systemui.res.R
+import com.android.systemui.screenshot.ui.viewmodel.ActionButtonViewModel
+
+object ActionButtonViewBinder {
+    /** Binds the given view to the given view-model */
+    fun bind(view: View, viewModel: ActionButtonViewModel) {
+        val iconView = view.requireViewById<ImageView>(R.id.overlay_action_chip_icon)
+        val textView = view.requireViewById<TextView>(R.id.overlay_action_chip_text)
+        iconView.setImageDrawable(viewModel.icon)
+        textView.text = viewModel.name
+        setMargins(iconView, textView, viewModel.name?.isNotEmpty() ?: false)
+        if (viewModel.onClicked != null) {
+            view.setOnClickListener { viewModel.onClicked.invoke() }
+        } else {
+            view.setOnClickListener(null)
+        }
+        view.visibility = View.VISIBLE
+        view.alpha = 1f
+    }
+
+    private fun setMargins(iconView: View, textView: View, hasText: Boolean) {
+        val iconParams = iconView.layoutParams as LinearLayout.LayoutParams
+        val textParams = textView.layoutParams as LinearLayout.LayoutParams
+        if (hasText) {
+            iconParams.marginStart = iconView.dpToPx(R.dimen.overlay_action_chip_padding_start)
+            iconParams.marginEnd = iconView.dpToPx(R.dimen.overlay_action_chip_spacing)
+            textParams.marginStart = 0
+            textParams.marginEnd = textView.dpToPx(R.dimen.overlay_action_chip_padding_end)
+        } else {
+            val paddingHorizontal =
+                iconView.dpToPx(R.dimen.overlay_action_chip_icon_only_padding_horizontal)
+            iconParams.marginStart = paddingHorizontal
+            iconParams.marginEnd = paddingHorizontal
+        }
+        iconView.layoutParams = iconParams
+        textView.layoutParams = textParams
+    }
+
+    private fun View.dpToPx(dimenId: Int): Int {
+        return this.resources.getDimensionPixelSize(dimenId)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt
new file mode 100644
index 0000000..3bcd52c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.screenshot.ui.binder
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.res.R
+import com.android.systemui.screenshot.ui.viewmodel.ScreenshotViewModel
+import com.android.systemui.util.children
+import kotlinx.coroutines.launch
+
+object ScreenshotShelfViewBinder {
+    fun bind(
+        view: ViewGroup,
+        viewModel: ScreenshotViewModel,
+        layoutInflater: LayoutInflater,
+    ) {
+        val previewView: ImageView = view.requireViewById(R.id.screenshot_preview)
+        val previewBorder = view.requireViewById<View>(R.id.screenshot_preview_border)
+        previewView.clipToOutline = true
+        val actionsContainer: LinearLayout = view.requireViewById(R.id.screenshot_actions)
+        view.requireViewById<View>(R.id.screenshot_dismiss_button).visibility =
+            if (viewModel.showDismissButton) View.VISIBLE else View.GONE
+
+        view.repeatWhenAttached {
+            lifecycleScope.launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    launch {
+                        viewModel.preview.collect { bitmap ->
+                            if (bitmap != null) {
+                                previewView.setImageBitmap(bitmap)
+                                previewView.visibility = View.VISIBLE
+                                previewBorder.visibility = View.VISIBLE
+                            } else {
+                                previewView.visibility = View.GONE
+                                previewBorder.visibility = View.GONE
+                            }
+                        }
+                    }
+                    launch {
+                        viewModel.actions.collect { actions ->
+                            if (actions.isNotEmpty()) {
+                                view
+                                    .requireViewById<View>(R.id.actions_container_background)
+                                    .visibility = View.VISIBLE
+                            }
+                            val viewPool = actionsContainer.children.toList()
+                            actionsContainer.removeAllViews()
+                            val actionButtons =
+                                List(actions.size) {
+                                    viewPool.getOrElse(it) {
+                                        layoutInflater.inflate(
+                                            R.layout.overlay_action_chip,
+                                            actionsContainer,
+                                            false
+                                        )
+                                    }
+                                }
+                            actionButtons.zip(actions).forEach {
+                                actionsContainer.addView(it.first)
+                                ActionButtonViewBinder.bind(it.first, it.second)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/viewmodel/ActionButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/viewmodel/ActionButtonViewModel.kt
new file mode 100644
index 0000000..6ee9705
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/viewmodel/ActionButtonViewModel.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.screenshot.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+
+data class ActionButtonViewModel(
+    val icon: Drawable?,
+    val name: String?,
+    val onClicked: (() -> Unit)?
+)
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/viewmodel/ScreenshotViewModel.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/viewmodel/ScreenshotViewModel.kt
new file mode 100644
index 0000000..3a652d9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/viewmodel/ScreenshotViewModel.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.screenshot.ui.viewmodel
+
+import android.graphics.Bitmap
+import android.view.accessibility.AccessibilityManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class ScreenshotViewModel(private val accessibilityManager: AccessibilityManager) {
+    private val _preview = MutableStateFlow<Bitmap?>(null)
+    val preview: StateFlow<Bitmap?> = _preview
+    private val _actions = MutableStateFlow(emptyList<ActionButtonViewModel>())
+    val actions: StateFlow<List<ActionButtonViewModel>> = _actions
+    val showDismissButton: Boolean
+        get() = accessibilityManager.isEnabled
+
+    fun setScreenshotBitmap(bitmap: Bitmap?) {
+        _preview.value = bitmap
+    }
+
+    fun setActions(actions: List<ActionButtonViewModel>) {
+        _actions.value = actions
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java b/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java
index da8c1be..d6858ca 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/KeyboardShortcutListSearch.java
@@ -631,34 +631,41 @@
         //    Enter Split screen with current app to RHS: Meta + Ctrl + Right arrow
         //    Enter Split screen with current app to LHS: Meta + Ctrl + Left arrow
         //    Switch from Split screen to full screen: Meta + Ctrl + Up arrow
-        String[] shortcutLabels = {
-                context.getString(R.string.system_multitasking_rhs),
-                context.getString(R.string.system_multitasking_lhs),
-                context.getString(R.string.system_multitasking_full_screen),
-        };
-        int[] keyCodes = {
-                KeyEvent.KEYCODE_DPAD_RIGHT,
-                KeyEvent.KEYCODE_DPAD_LEFT,
-                KeyEvent.KEYCODE_DPAD_UP,
-        };
-
-        for (int i = 0; i < shortcutLabels.length; i++) {
-            List<ShortcutKeyGroup> shortcutKeyGroups = Arrays.asList(new ShortcutKeyGroup(
-                    new KeyboardShortcutInfo(
-                            shortcutLabels[i],
-                            keyCodes[i],
-                            KeyEvent.META_META_ON | KeyEvent.META_CTRL_ON),
-                    null));
-            ShortcutMultiMappingInfo shortcutMultiMappingInfo =
-                    new ShortcutMultiMappingInfo(
-                            shortcutLabels[i],
-                            null,
-                            shortcutKeyGroups);
-            systemMultitaskingGroup.addItem(shortcutMultiMappingInfo);
-        }
+        //    Change split screen focus to RHS: Meta + Alt + Right arrow
+        //    Change split screen focus to LHS: Meta + Alt + Left arrow
+        systemMultitaskingGroup.addItem(
+                getMultitaskingShortcut(context.getString(R.string.system_multitasking_rhs),
+                        KeyEvent.KEYCODE_DPAD_RIGHT,
+                        KeyEvent.META_META_ON | KeyEvent.META_CTRL_ON));
+        systemMultitaskingGroup.addItem(
+                getMultitaskingShortcut(context.getString(R.string.system_multitasking_lhs),
+                        KeyEvent.KEYCODE_DPAD_LEFT,
+                        KeyEvent.META_META_ON | KeyEvent.META_CTRL_ON));
+        systemMultitaskingGroup.addItem(
+                getMultitaskingShortcut(context.getString(R.string.system_multitasking_full_screen),
+                        KeyEvent.KEYCODE_DPAD_UP,
+                        KeyEvent.META_META_ON | KeyEvent.META_CTRL_ON));
+        systemMultitaskingGroup.addItem(
+                getMultitaskingShortcut(
+                        context.getString(R.string.system_multitasking_splitscreen_focus_rhs),
+                        KeyEvent.KEYCODE_DPAD_RIGHT,
+                        KeyEvent.META_META_ON | KeyEvent.META_ALT_ON));
+        systemMultitaskingGroup.addItem(
+                getMultitaskingShortcut(
+                        context.getString(R.string.system_multitasking_splitscreen_focus_lhs),
+                        KeyEvent.KEYCODE_DPAD_LEFT,
+                        KeyEvent.META_META_ON | KeyEvent.META_ALT_ON));
         return systemMultitaskingGroup;
     }
 
+    private static ShortcutMultiMappingInfo getMultitaskingShortcut(String shortcutLabel,
+            int keycode, int modifiers) {
+        List<ShortcutKeyGroup> shortcutKeyGroups = Arrays.asList(
+                new ShortcutKeyGroup(new KeyboardShortcutInfo(shortcutLabel, keycode, modifiers),
+                        null));
+        return new ShortcutMultiMappingInfo(shortcutLabel, null, shortcutKeyGroups);
+    }
+
     private static KeyboardShortcutMultiMappingGroup getMultiMappingInputShortcuts(
             Context context) {
         List<ShortcutMultiMappingInfo> shortcutMultiMappingInfoList = Arrays.asList(
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java
index 5171a5c..9a82ecf 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java
@@ -863,7 +863,7 @@
         boolean isAppearing = row.isDrawingAppearAnimation() && !row.isInShelf();
         iconState.hidden = isAppearing
                 || (view instanceof ExpandableNotificationRow
-                && ((ExpandableNotificationRow) view).isLowPriority()
+                && ((ExpandableNotificationRow) view).isMinimized()
                 && mShelfIcons.areIconsOverflowing())
                 || (transitionAmount == 0.0f && !iconState.isAnimating(icon))
                 || row.isAboveShelf()
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/MediaCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/MediaCoordinator.java
index dcfccd8..0bbde21 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/MediaCoordinator.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/MediaCoordinator.java
@@ -16,7 +16,7 @@
 
 package com.android.systemui.statusbar.notification.collection.coordinator;
 
-import static com.android.systemui.media.controls.domain.pipeline.MediaDataManagerKt.isMediaNotification;
+import static com.android.systemui.media.controls.domain.pipeline.MediaDataManager.isMediaNotification;
 
 import android.os.RemoteException;
 import android.service.notification.StatusBarNotification;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java
index dfb0f9b..7a7b184 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java
@@ -363,7 +363,7 @@
 
     NotifInflater.Params getInflaterParams(NotifUiAdjustment adjustment, String reason) {
         return new NotifInflater.Params(
-                /* isLowPriority = */ adjustment.isMinimized(),
+                /* isMinimized = */ adjustment.isMinimized(),
                 /* reason = */ reason,
                 /* showSnooze = */ adjustment.isSnoozeEnabled(),
                 /* isChildInGroup = */ adjustment.isChildInGroup(),
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt
index 7b8a062..ff72888 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt
@@ -56,7 +56,7 @@
 
     /** A class holding parameters used when inflating the notification row */
     class Params(
-        val isLowPriority: Boolean,
+        val isMinimized: Boolean,
         val reason: String,
         val showSnooze: Boolean,
         val isChildInGroup: Boolean = false,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java
index 4bbe035..4a895c0 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java
@@ -243,7 +243,7 @@
             @Nullable NotificationRowContentBinder.InflationCallback inflationCallback) {
         final boolean useIncreasedCollapsedHeight =
                 mMessagingUtil.isImportantMessaging(entry.getSbn(), entry.getImportance());
-        final boolean isLowPriority = inflaterParams.isLowPriority();
+        final boolean isMinimized = inflaterParams.isMinimized();
 
         // Set show snooze action
         row.setShowSnooze(inflaterParams.getShowSnooze());
@@ -252,7 +252,7 @@
         params.requireContentViews(FLAG_CONTENT_VIEW_CONTRACTED);
         params.requireContentViews(FLAG_CONTENT_VIEW_EXPANDED);
         params.setUseIncreasedCollapsedHeight(useIncreasedCollapsedHeight);
-        params.setUseLowPriority(isLowPriority);
+        params.setUseMinimized(isMinimized);
 
         if (screenshareNotificationHiding()
                 ? inflaterParams.getNeedsRedaction()
@@ -275,7 +275,7 @@
         if (AsyncGroupHeaderViewInflation.isEnabled()) {
             if (inflaterParams.isGroupSummary()) {
                 params.requireContentViews(FLAG_GROUP_SUMMARY_HEADER);
-                if (isLowPriority) {
+                if (isMinimized) {
                     params.requireContentViews(FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER);
                 }
             } else {
@@ -288,7 +288,7 @@
         mRowContentBindStage.requestRebind(entry, en -> {
             mLogger.logRebindComplete(entry);
             row.setUsesIncreasedCollapsedHeight(useIncreasedCollapsedHeight);
-            row.setIsLowPriority(isLowPriority);
+            row.setIsMinimized(isMinimized);
             if (inflationCallback != null) {
                 inflationCallback.onAsyncInflationFinished(en);
             }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
index c05c3c3..b8b4a03 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
@@ -327,7 +327,7 @@
     private OnClickListener mExpandClickListener = new OnClickListener() {
         @Override
         public void onClick(View v) {
-            if (!shouldShowPublic() && (!mIsLowPriority || isExpanded())
+            if (!shouldShowPublic() && (!mIsMinimized || isExpanded())
                     && mGroupMembershipManager.isGroupSummary(mEntry)) {
                 mGroupExpansionChanging = true;
                 final boolean wasExpanded = mGroupExpansionManager.isGroupExpanded(mEntry);
@@ -382,7 +382,7 @@
     private boolean mAboveShelf;
     private OnUserInteractionCallback mOnUserInteractionCallback;
     private NotificationGutsManager mNotificationGutsManager;
-    private boolean mIsLowPriority;
+    private boolean mIsMinimized;
     private boolean mUseIncreasedCollapsedHeight;
     private boolean mUseIncreasedHeadsUpHeight;
     private float mTranslationWhenRemoved;
@@ -467,7 +467,8 @@
             if (viewWrapper != null) {
                 setIconAnimationRunningForChild(running, viewWrapper.getIcon());
             }
-            NotificationViewWrapper lowPriWrapper = mChildrenContainer.getLowPriorityViewWrapper();
+            NotificationViewWrapper lowPriWrapper = mChildrenContainer
+                    .getMinimizedGroupHeaderWrapper();
             if (lowPriWrapper != null) {
                 setIconAnimationRunningForChild(running, lowPriWrapper.getIcon());
             }
@@ -680,7 +681,7 @@
         if (color != Notification.COLOR_INVALID) {
             return color;
         } else {
-            return mEntry.getContrastedColor(mContext, mIsLowPriority && !isExpanded(),
+            return mEntry.getContrastedColor(mContext, mIsMinimized && !isExpanded(),
                     getBackgroundColorWithoutTint());
         }
     }
@@ -1545,7 +1546,7 @@
      * Set the low-priority group notification header view
      * @param headerView header view to set
      */
-    public void setLowPriorityGroupHeader(NotificationHeaderView headerView) {
+    public void setMinimizedGroupHeader(NotificationHeaderView headerView) {
         NotificationChildrenContainer childrenContainer = getChildrenContainerNonNull();
         childrenContainer.setLowPriorityGroupHeader(
                 /* headerViewLowPriority= */ headerView,
@@ -1664,16 +1665,19 @@
         }
     }
 
-    public void setIsLowPriority(boolean isLowPriority) {
-        mIsLowPriority = isLowPriority;
-        mPrivateLayout.setIsLowPriority(isLowPriority);
+    /**
+     * Set if the row is minimized.
+     */
+    public void setIsMinimized(boolean isMinimized) {
+        mIsMinimized = isMinimized;
+        mPrivateLayout.setIsLowPriority(isMinimized);
         if (mChildrenContainer != null) {
-            mChildrenContainer.setIsLowPriority(isLowPriority);
+            mChildrenContainer.setIsMinimized(isMinimized);
         }
     }
 
-    public boolean isLowPriority() {
-        return mIsLowPriority;
+    public boolean isMinimized() {
+        return mIsMinimized;
     }
 
     public void setUsesIncreasedCollapsedHeight(boolean use) {
@@ -2050,7 +2054,7 @@
         mChildrenContainerStub = findViewById(R.id.child_container_stub);
         mChildrenContainerStub.setOnInflateListener((stub, inflated) -> {
             mChildrenContainer = (NotificationChildrenContainer) inflated;
-            mChildrenContainer.setIsLowPriority(mIsLowPriority);
+            mChildrenContainer.setIsMinimized(mIsMinimized);
             mChildrenContainer.setContainingNotification(ExpandableNotificationRow.this);
             mChildrenContainer.onNotificationUpdated();
             mChildrenContainer.setLogger(mChildrenContainerLogger);
@@ -3435,7 +3439,7 @@
 
     private void onExpansionChanged(boolean userAction, boolean wasExpanded) {
         boolean nowExpanded = isExpanded();
-        if (mIsSummaryWithChildren && (!mIsLowPriority || wasExpanded)) {
+        if (mIsSummaryWithChildren && (!mIsMinimized || wasExpanded)) {
             nowExpanded = mGroupExpansionManager.isGroupExpanded(mEntry);
         }
         if (nowExpanded != wasExpanded) {
@@ -3492,7 +3496,7 @@
         if (!expandable) {
             if (mIsSummaryWithChildren) {
                 expandable = true;
-                if (!mIsLowPriority || isExpanded()) {
+                if (!mIsMinimized || isExpanded()) {
                     isExpanded = isGroupExpanded();
                 }
             } else {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java
index f835cca..ded635c 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java
@@ -150,7 +150,7 @@
                 entry,
                 mConversationProcessor,
                 row,
-                bindParams.isLowPriority,
+                bindParams.isMinimized,
                 bindParams.usesIncreasedHeight,
                 bindParams.usesIncreasedHeadsUpHeight,
                 callback,
@@ -178,7 +178,7 @@
             SmartReplyStateInflater smartRepliesInflater) {
         InflationProgress result = createRemoteViews(reInflateFlags,
                 builder,
-                bindParams.isLowPriority,
+                bindParams.isMinimized,
                 bindParams.usesIncreasedHeight,
                 bindParams.usesIncreasedHeadsUpHeight,
                 packageContext,
@@ -215,6 +215,7 @@
         apply(
                 mInflationExecutor,
                 inflateSynchronously,
+                bindParams.isMinimized,
                 result,
                 reInflateFlags,
                 mRemoteViewCache,
@@ -365,7 +366,7 @@
     }
 
     private static InflationProgress createRemoteViews(@InflationFlag int reInflateFlags,
-            Notification.Builder builder, boolean isLowPriority, boolean usesIncreasedHeight,
+            Notification.Builder builder, boolean isMinimized, boolean usesIncreasedHeight,
             boolean usesIncreasedHeadsUpHeight, Context packageContext,
             ExpandableNotificationRow row,
             NotifLayoutInflaterFactory.Provider notifLayoutInflaterFactoryProvider,
@@ -376,13 +377,13 @@
 
             if ((reInflateFlags & FLAG_CONTENT_VIEW_CONTRACTED) != 0) {
                 logger.logAsyncTaskProgress(entryForLogging, "creating contracted remote view");
-                result.newContentView = createContentView(builder, isLowPriority,
+                result.newContentView = createContentView(builder, isMinimized,
                         usesIncreasedHeight);
             }
 
             if ((reInflateFlags & FLAG_CONTENT_VIEW_EXPANDED) != 0) {
                 logger.logAsyncTaskProgress(entryForLogging, "creating expanded remote view");
-                result.newExpandedView = createExpandedView(builder, isLowPriority);
+                result.newExpandedView = createExpandedView(builder, isMinimized);
             }
 
             if ((reInflateFlags & FLAG_CONTENT_VIEW_HEADS_UP) != 0) {
@@ -393,7 +394,7 @@
 
             if ((reInflateFlags & FLAG_CONTENT_VIEW_PUBLIC) != 0) {
                 logger.logAsyncTaskProgress(entryForLogging, "creating public remote view");
-                result.newPublicView = builder.makePublicContentView(isLowPriority);
+                result.newPublicView = builder.makePublicContentView(isMinimized);
             }
 
             if (AsyncGroupHeaderViewInflation.isEnabled()) {
@@ -406,7 +407,7 @@
                 if ((reInflateFlags & FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER) != 0) {
                     logger.logAsyncTaskProgress(entryForLogging,
                             "creating low-priority group summary remote view");
-                    result.mNewLowPriorityGroupHeaderView =
+                    result.mNewMinimizedGroupHeaderView =
                             builder.makeLowPriorityContentView(true /* useRegularSubtext */);
                 }
             }
@@ -444,6 +445,7 @@
     private static CancellationSignal apply(
             Executor inflationExecutor,
             boolean inflateSynchronously,
+            boolean isMinimized,
             InflationProgress result,
             @InflationFlag int reInflateFlags,
             NotifRemoteViewCache remoteViewCache,
@@ -475,7 +477,8 @@
                 }
             };
             logger.logAsyncTaskProgress(entry, "applying contracted view");
-            applyRemoteView(inflationExecutor, inflateSynchronously, result, reInflateFlags, flag,
+            applyRemoteView(inflationExecutor, inflateSynchronously, isMinimized, result,
+                    reInflateFlags, flag,
                     remoteViewCache, entry, row, isNewView, remoteViewClickHandler, callback,
                     privateLayout, privateLayout.getContractedChild(),
                     privateLayout.getVisibleWrapper(
@@ -502,7 +505,8 @@
                     }
                 };
                 logger.logAsyncTaskProgress(entry, "applying expanded view");
-                applyRemoteView(inflationExecutor, inflateSynchronously, result, reInflateFlags,
+                applyRemoteView(inflationExecutor, inflateSynchronously, isMinimized, result,
+                        reInflateFlags,
                         flag, remoteViewCache, entry, row, isNewView, remoteViewClickHandler,
                         callback, privateLayout, privateLayout.getExpandedChild(),
                         privateLayout.getVisibleWrapper(VISIBLE_TYPE_EXPANDED), runningInflations,
@@ -529,7 +533,8 @@
                     }
                 };
                 logger.logAsyncTaskProgress(entry, "applying heads up view");
-                applyRemoteView(inflationExecutor, inflateSynchronously, result, reInflateFlags,
+                applyRemoteView(inflationExecutor, inflateSynchronously, isMinimized,
+                        result, reInflateFlags,
                         flag, remoteViewCache, entry, row, isNewView, remoteViewClickHandler,
                         callback, privateLayout, privateLayout.getHeadsUpChild(),
                         privateLayout.getVisibleWrapper(VISIBLE_TYPE_HEADSUP), runningInflations,
@@ -555,7 +560,8 @@
                 }
             };
             logger.logAsyncTaskProgress(entry, "applying public view");
-            applyRemoteView(inflationExecutor, inflateSynchronously, result, reInflateFlags, flag,
+            applyRemoteView(inflationExecutor, inflateSynchronously, isMinimized,
+                    result, reInflateFlags, flag,
                     remoteViewCache, entry, row, isNewView, remoteViewClickHandler, callback,
                     publicLayout, publicLayout.getContractedChild(),
                     publicLayout.getVisibleWrapper(NotificationContentView.VISIBLE_TYPE_CONTRACTED),
@@ -583,11 +589,12 @@
                     }
                 };
                 logger.logAsyncTaskProgress(entry, "applying group header view");
-                applyRemoteView(inflationExecutor, inflateSynchronously, result, reInflateFlags,
+                applyRemoteView(inflationExecutor, inflateSynchronously, isMinimized,
+                        result, reInflateFlags,
                         /* inflationId = */ FLAG_GROUP_SUMMARY_HEADER,
                         remoteViewCache, entry, row, isNewView, remoteViewClickHandler, callback,
                         /* parentLayout = */ childrenContainer,
-                        /* existingView = */ childrenContainer.getNotificationHeader(),
+                        /* existingView = */ childrenContainer.getGroupHeader(),
                         /* existingWrapper = */ childrenContainer.getNotificationHeaderWrapper(),
                         runningInflations, applyCallback, logger);
             }
@@ -595,7 +602,7 @@
             if ((reInflateFlags & FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER) != 0) {
                 boolean isNewView =
                         !canReapplyRemoteView(
-                                /* newView = */ result.mNewLowPriorityGroupHeaderView,
+                                /* newView = */ result.mNewMinimizedGroupHeaderView,
                                 /* oldView = */ remoteViewCache.getCachedView(
                                         entry, FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER));
                 ApplyCallback applyCallback = new ApplyCallback() {
@@ -603,29 +610,30 @@
                     public void setResultView(View v) {
                         logger.logAsyncTaskProgress(entry,
                                 "low-priority group header view applied");
-                        result.mInflatedLowPriorityGroupHeaderView = (NotificationHeaderView) v;
+                        result.mInflatedMinimizedGroupHeaderView = (NotificationHeaderView) v;
                     }
 
                     @Override
                     public RemoteViews getRemoteView() {
-                        return result.mNewLowPriorityGroupHeaderView;
+                        return result.mNewMinimizedGroupHeaderView;
                     }
                 };
                 logger.logAsyncTaskProgress(entry, "applying low priority group header view");
-                applyRemoteView(inflationExecutor, inflateSynchronously, result, reInflateFlags,
+                applyRemoteView(inflationExecutor, inflateSynchronously, isMinimized,
+                        result, reInflateFlags,
                         /* inflationId = */ FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER,
                         remoteViewCache, entry, row, isNewView, remoteViewClickHandler, callback,
                         /* parentLayout = */ childrenContainer,
-                        /* existingView = */ childrenContainer.getNotificationHeaderLowPriority(),
+                        /* existingView = */ childrenContainer.getMinimizedNotificationHeader(),
                         /* existingWrapper = */ childrenContainer
-                                .getLowPriorityViewWrapper(),
+                                .getMinimizedGroupHeaderWrapper(),
                         runningInflations, applyCallback, logger);
             }
         }
 
         // Let's try to finish, maybe nobody is even inflating anything
-        finishIfDone(result, reInflateFlags, remoteViewCache, runningInflations, callback, entry,
-                row, logger);
+        finishIfDone(result, isMinimized, reInflateFlags, remoteViewCache, runningInflations,
+                callback, entry, row, logger);
         CancellationSignal cancellationSignal = new CancellationSignal();
         cancellationSignal.setOnCancelListener(
                 () -> {
@@ -641,6 +649,7 @@
     static void applyRemoteView(
             Executor inflationExecutor,
             boolean inflateSynchronously,
+            boolean isMinimized,
             final InflationProgress result,
             final @InflationFlag int reInflateFlags,
             @InflationFlag int inflationId,
@@ -707,7 +716,8 @@
                     existingWrapper.onReinflated();
                 }
                 runningInflations.remove(inflationId);
-                finishIfDone(result, reInflateFlags, remoteViewCache, runningInflations,
+                finishIfDone(result, isMinimized,
+                        reInflateFlags, remoteViewCache, runningInflations,
                         callback, entry, row, logger);
             }
 
@@ -838,6 +848,7 @@
      * @return true if the inflation was finished
      */
     private static boolean finishIfDone(InflationProgress result,
+            boolean isMinimized,
             @InflationFlag int reInflateFlags, NotifRemoteViewCache remoteViewCache,
             HashMap<Integer, CancellationSignal> runningInflations,
             @Nullable InflationCallback endListener, NotificationEntry entry,
@@ -944,7 +955,9 @@
         if (AsyncGroupHeaderViewInflation.isEnabled()) {
             if ((reInflateFlags & FLAG_GROUP_SUMMARY_HEADER) != 0) {
                 if (result.mInflatedGroupHeaderView != null) {
-                    row.setIsLowPriority(false);
+                    // We need to set if the row is minimized before setting the group header to
+                    // make sure the setting of header view works correctly
+                    row.setIsMinimized(isMinimized);
                     row.setGroupHeader(/* headerView= */ result.mInflatedGroupHeaderView);
                     remoteViewCache.putCachedView(entry, FLAG_GROUP_SUMMARY_HEADER,
                             result.mNewGroupHeaderView);
@@ -957,13 +970,14 @@
             }
 
             if ((reInflateFlags & FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER) != 0) {
-                if (result.mInflatedLowPriorityGroupHeaderView != null) {
-                    // New view case, set row to low priority
-                    row.setIsLowPriority(true);
-                    row.setLowPriorityGroupHeader(
-                            /* headerView= */ result.mInflatedLowPriorityGroupHeaderView);
+                if (result.mInflatedMinimizedGroupHeaderView != null) {
+                    // We need to set if the row is minimized before setting the group header to
+                    // make sure the setting of header view works correctly
+                    row.setIsMinimized(isMinimized);
+                    row.setMinimizedGroupHeader(
+                            /* headerView= */ result.mInflatedMinimizedGroupHeaderView);
                     remoteViewCache.putCachedView(entry, FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER,
-                            result.mNewLowPriorityGroupHeaderView);
+                            result.mNewMinimizedGroupHeaderView);
                 } else if (remoteViewCache.hasCachedView(entry,
                         FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER)) {
                     // Re-inflation case. Only update if it's still cached (i.e. view has not
@@ -984,12 +998,12 @@
     }
 
     private static RemoteViews createExpandedView(Notification.Builder builder,
-            boolean isLowPriority) {
+            boolean isMinimized) {
         RemoteViews bigContentView = builder.createBigContentView();
         if (bigContentView != null) {
             return bigContentView;
         }
-        if (isLowPriority) {
+        if (isMinimized) {
             RemoteViews contentView = builder.createContentView();
             Notification.Builder.makeHeaderExpanded(contentView);
             return contentView;
@@ -998,8 +1012,8 @@
     }
 
     private static RemoteViews createContentView(Notification.Builder builder,
-            boolean isLowPriority, boolean useLarge) {
-        if (isLowPriority) {
+            boolean isMinimized, boolean useLarge) {
+        if (isMinimized) {
             return builder.makeLowPriorityContentView(false /* useRegularSubtext */);
         }
         return builder.createContentView(useLarge);
@@ -1038,7 +1052,7 @@
         private final NotificationEntry mEntry;
         private final Context mContext;
         private final boolean mInflateSynchronously;
-        private final boolean mIsLowPriority;
+        private final boolean mIsMinimized;
         private final boolean mUsesIncreasedHeight;
         private final InflationCallback mCallback;
         private final boolean mUsesIncreasedHeadsUpHeight;
@@ -1063,7 +1077,7 @@
                 NotificationEntry entry,
                 ConversationNotificationProcessor conversationProcessor,
                 ExpandableNotificationRow row,
-                boolean isLowPriority,
+                boolean isMinimized,
                 boolean usesIncreasedHeight,
                 boolean usesIncreasedHeadsUpHeight,
                 InflationCallback callback,
@@ -1080,7 +1094,7 @@
             mRemoteViewCache = cache;
             mSmartRepliesInflater = smartRepliesInflater;
             mContext = mRow.getContext();
-            mIsLowPriority = isLowPriority;
+            mIsMinimized = isMinimized;
             mUsesIncreasedHeight = usesIncreasedHeight;
             mUsesIncreasedHeadsUpHeight = usesIncreasedHeadsUpHeight;
             mRemoteViewClickHandler = remoteViewClickHandler;
@@ -1150,7 +1164,7 @@
                         mEntry, recoveredBuilder, mLogger);
             }
             InflationProgress inflationProgress = createRemoteViews(mReInflateFlags,
-                    recoveredBuilder, mIsLowPriority, mUsesIncreasedHeight,
+                    recoveredBuilder, mIsMinimized, mUsesIncreasedHeight,
                     mUsesIncreasedHeadsUpHeight, packageContext, mRow,
                     mNotifLayoutInflaterFactoryProvider, mLogger);
 
@@ -1209,6 +1223,7 @@
                 mCancellationSignal = apply(
                         mInflationExecutor,
                         mInflateSynchronously,
+                        mIsMinimized,
                         result,
                         mReInflateFlags,
                         mRemoteViewCache,
@@ -1295,7 +1310,7 @@
         private RemoteViews newExpandedView;
         private RemoteViews newPublicView;
         private RemoteViews mNewGroupHeaderView;
-        private RemoteViews mNewLowPriorityGroupHeaderView;
+        private RemoteViews mNewMinimizedGroupHeaderView;
 
         @VisibleForTesting
         Context packageContext;
@@ -1305,7 +1320,7 @@
         private View inflatedExpandedView;
         private View inflatedPublicView;
         private NotificationHeaderView mInflatedGroupHeaderView;
-        private NotificationHeaderView mInflatedLowPriorityGroupHeaderView;
+        private NotificationHeaderView mInflatedMinimizedGroupHeaderView;
         private CharSequence headsUpStatusBarText;
         private CharSequence headsUpStatusBarTextPublic;
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java
index 8a3e7e8..6f00d96 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java
@@ -1514,7 +1514,7 @@
         }
         ImageView bubbleButton = layout.findViewById(com.android.internal.R.id.bubble_button);
         View actionContainer = layout.findViewById(com.android.internal.R.id.actions_container);
-        LinearLayout actionListMarginTarget = layout.findViewById(
+        ViewGroup actionListMarginTarget = layout.findViewById(
                 com.android.internal.R.id.notification_action_list_margin_target);
         if (bubbleButton == null || actionContainer == null) {
             return;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java
index b0fd475..33339a7 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java
@@ -128,9 +128,9 @@
     class BindParams {
 
         /**
-         * Bind a low priority version of the content views.
+         * Bind a minimized version of the content views.
          */
-        public boolean isLowPriority;
+        public boolean isMinimized;
 
         /**
          * Use increased height when binding contracted view.
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java
index 1494c27..bae89fb 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java
@@ -26,7 +26,7 @@
  * Parameters for {@link RowContentBindStage}.
  */
 public final class RowContentBindParams {
-    private boolean mUseLowPriority;
+    private boolean mUseMinimized;
     private boolean mUseIncreasedHeight;
     private boolean mUseIncreasedHeadsUpHeight;
     private boolean mViewsNeedReinflation;
@@ -41,17 +41,20 @@
     private @InflationFlag int mDirtyContentViews = mContentViews;
 
     /**
-     * Set whether content should use a low priority version of its content views.
+     * Set whether content should use a minimized version of its content views.
      */
-    public void setUseLowPriority(boolean useLowPriority) {
-        if (mUseLowPriority != useLowPriority) {
+    public void setUseMinimized(boolean useMinimized) {
+        if (mUseMinimized != useMinimized) {
             mDirtyContentViews |= (FLAG_CONTENT_VIEW_CONTRACTED | FLAG_CONTENT_VIEW_EXPANDED);
         }
-        mUseLowPriority = useLowPriority;
+        mUseMinimized = useMinimized;
     }
 
-    public boolean useLowPriority() {
-        return mUseLowPriority;
+    /**
+     * @return Whether the row uses the minimized style.
+     */
+    public boolean useMinimized() {
+        return mUseMinimized;
     }
 
     /**
@@ -149,9 +152,9 @@
     @Override
     public String toString() {
         return String.format("RowContentBindParams[mContentViews=%x mDirtyContentViews=%x "
-                + "mUseLowPriority=%b mUseIncreasedHeight=%b "
+                + "mUseMinimized=%b mUseIncreasedHeight=%b "
                 + "mUseIncreasedHeadsUpHeight=%b mViewsNeedReinflation=%b]",
-                mContentViews, mDirtyContentViews, mUseLowPriority, mUseIncreasedHeight,
+                mContentViews, mDirtyContentViews, mUseMinimized, mUseIncreasedHeight,
                 mUseIncreasedHeadsUpHeight, mViewsNeedReinflation);
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java
index f4f8374..89fcda9 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java
@@ -73,7 +73,7 @@
         mBinder.unbindContent(entry, row, contentToUnbind);
 
         BindParams bindParams = new BindParams();
-        bindParams.isLowPriority = params.useLowPriority();
+        bindParams.isMinimized = params.useMinimized();
         bindParams.usesIncreasedHeight = params.useIncreasedHeight();
         bindParams.usesIncreasedHeadsUpHeight = params.useIncreasedHeadsUpHeight();
         boolean forceInflate = params.needsReinflation();
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationsHeadsUpRefactor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationsHeadsUpRefactor.kt
new file mode 100644
index 0000000..62641fe
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationsHeadsUpRefactor.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.shared
+
+import com.android.systemui.Flags
+import com.android.systemui.flags.FlagToken
+import com.android.systemui.flags.RefactorFlagUtils
+
+/** Helper for reading or using the notifications heads up refactor flag state. */
+@Suppress("NOTHING_TO_INLINE")
+object NotificationsHeadsUpRefactor {
+    /** The aconfig flag name */
+    const val FLAG_NAME = Flags.FLAG_NOTIFICATIONS_HEADS_UP_REFACTOR
+
+    /** A token used for dependency declaration */
+    val token: FlagToken
+        get() = FlagToken(FLAG_NAME, isEnabled)
+
+    /** Is the refactor enabled */
+    @JvmStatic
+    inline val isEnabled
+        get() = Flags.notificationsHeadsUpRefactor()
+
+    /**
+     * Called to ensure code is only run when the flag is enabled. This protects users from the
+     * unintended behaviors caused by accidentally running new logic, while also crashing on an eng
+     * build to ensure that the refactor author catches issues in testing.
+     */
+    @JvmStatic
+    inline fun isUnexpectedlyInLegacyMode() =
+        RefactorFlagUtils.isUnexpectedlyInLegacyMode(isEnabled, FLAG_NAME)
+
+    /**
+     * Called to ensure code is only run when the flag is disabled. This will throw an exception if
+     * the flag is enabled to ensure that the refactor author catches issues in testing.
+     */
+    @JvmStatic
+    inline fun assertInLegacyMode() = RefactorFlagUtils.assertInLegacyMode(isEnabled, FLAG_NAME)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java
index 28f874d..5dc37e0 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java
@@ -110,14 +110,14 @@
      */
     private boolean mEnableShadowOnChildNotifications;
 
-    private NotificationHeaderView mNotificationHeader;
-    private NotificationHeaderViewWrapper mNotificationHeaderWrapper;
-    private NotificationHeaderView mNotificationHeaderLowPriority;
-    private NotificationHeaderViewWrapper mNotificationHeaderWrapperLowPriority;
+    private NotificationHeaderView mGroupHeader;
+    private NotificationHeaderViewWrapper mGroupHeaderWrapper;
+    private NotificationHeaderView mMinimizedGroupHeader;
+    private NotificationHeaderViewWrapper mMinimizedGroupHeaderWrapper;
     private NotificationGroupingUtil mGroupingUtil;
     private ViewState mHeaderViewState;
     private int mClipBottomAmount;
-    private boolean mIsLowPriority;
+    private boolean mIsMinimized;
     private OnClickListener mHeaderClickListener;
     private ViewGroup mCurrentHeader;
     private boolean mIsConversation;
@@ -217,14 +217,14 @@
             int right = left + mOverflowNumber.getMeasuredWidth();
             mOverflowNumber.layout(left, 0, right, mOverflowNumber.getMeasuredHeight());
         }
-        if (mNotificationHeader != null) {
-            mNotificationHeader.layout(0, 0, mNotificationHeader.getMeasuredWidth(),
-                    mNotificationHeader.getMeasuredHeight());
+        if (mGroupHeader != null) {
+            mGroupHeader.layout(0, 0, mGroupHeader.getMeasuredWidth(),
+                    mGroupHeader.getMeasuredHeight());
         }
-        if (mNotificationHeaderLowPriority != null) {
-            mNotificationHeaderLowPriority.layout(0, 0,
-                    mNotificationHeaderLowPriority.getMeasuredWidth(),
-                    mNotificationHeaderLowPriority.getMeasuredHeight());
+        if (mMinimizedGroupHeader != null) {
+            mMinimizedGroupHeader.layout(0, 0,
+                    mMinimizedGroupHeader.getMeasuredWidth(),
+                    mMinimizedGroupHeader.getMeasuredHeight());
         }
     }
 
@@ -271,11 +271,11 @@
         }
 
         int headerHeightSpec = MeasureSpec.makeMeasureSpec(mHeaderHeight, MeasureSpec.EXACTLY);
-        if (mNotificationHeader != null) {
-            mNotificationHeader.measure(widthMeasureSpec, headerHeightSpec);
+        if (mGroupHeader != null) {
+            mGroupHeader.measure(widthMeasureSpec, headerHeightSpec);
         }
-        if (mNotificationHeaderLowPriority != null) {
-            mNotificationHeaderLowPriority.measure(widthMeasureSpec, headerHeightSpec);
+        if (mMinimizedGroupHeader != null) {
+            mMinimizedGroupHeader.measure(widthMeasureSpec, headerHeightSpec);
         }
 
         setMeasuredDimension(width, height);
@@ -308,11 +308,11 @@
      * appropriately.
      */
     public void setNotificationGroupWhen(long whenMillis) {
-        if (mNotificationHeaderWrapper != null) {
-            mNotificationHeaderWrapper.setNotificationWhen(whenMillis);
+        if (mGroupHeaderWrapper != null) {
+            mGroupHeaderWrapper.setNotificationWhen(whenMillis);
         }
-        if (mNotificationHeaderWrapperLowPriority != null) {
-            mNotificationHeaderWrapperLowPriority.setNotificationWhen(whenMillis);
+        if (mMinimizedGroupHeaderWrapper != null) {
+            mMinimizedGroupHeaderWrapper.setNotificationWhen(whenMillis);
         }
     }
 
@@ -410,28 +410,28 @@
         Trace.beginSection("recreateHeader#makeNotificationGroupHeader");
         RemoteViews header = builder.makeNotificationGroupHeader();
         Trace.endSection();
-        if (mNotificationHeader == null) {
+        if (mGroupHeader == null) {
             Trace.beginSection("recreateHeader#apply");
-            mNotificationHeader = (NotificationHeaderView) header.apply(getContext(), this);
+            mGroupHeader = (NotificationHeaderView) header.apply(getContext(), this);
             Trace.endSection();
-            mNotificationHeader.findViewById(com.android.internal.R.id.expand_button)
+            mGroupHeader.findViewById(com.android.internal.R.id.expand_button)
                     .setVisibility(VISIBLE);
-            mNotificationHeader.setOnClickListener(mHeaderClickListener);
-            mNotificationHeaderWrapper =
+            mGroupHeader.setOnClickListener(mHeaderClickListener);
+            mGroupHeaderWrapper =
                     (NotificationHeaderViewWrapper) NotificationViewWrapper.wrap(
                             getContext(),
-                            mNotificationHeader,
+                            mGroupHeader,
                             mContainingNotification);
-            mNotificationHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
-            addView(mNotificationHeader, 0);
+            mGroupHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
+            addView(mGroupHeader, 0);
             invalidate();
         } else {
             Trace.beginSection("recreateHeader#reapply");
-            header.reapply(getContext(), mNotificationHeader);
+            header.reapply(getContext(), mGroupHeader);
             Trace.endSection();
         }
-        mNotificationHeaderWrapper.setExpanded(mChildrenExpanded);
-        mNotificationHeaderWrapper.onContentUpdated(mContainingNotification);
+        mGroupHeaderWrapper.setExpanded(mChildrenExpanded);
+        mGroupHeaderWrapper.onContentUpdated(mContainingNotification);
         recreateLowPriorityHeader(builder, isConversation);
         updateHeaderVisibility(false /* animate */);
         updateChildrenAppearance();
@@ -439,21 +439,21 @@
     }
 
     private void removeGroupHeader() {
-        if (mNotificationHeader == null) {
+        if (mGroupHeader == null) {
             return;
         }
-        removeView(mNotificationHeader);
-        mNotificationHeader = null;
-        mNotificationHeaderWrapper = null;
+        removeView(mGroupHeader);
+        mGroupHeader = null;
+        mGroupHeaderWrapper = null;
     }
 
     private void removeLowPriorityGroupHeader() {
-        if (mNotificationHeaderLowPriority == null) {
+        if (mMinimizedGroupHeader == null) {
             return;
         }
-        removeView(mNotificationHeaderLowPriority);
-        mNotificationHeaderLowPriority = null;
-        mNotificationHeaderWrapperLowPriority = null;
+        removeView(mMinimizedGroupHeader);
+        mMinimizedGroupHeader = null;
+        mMinimizedGroupHeaderWrapper = null;
     }
 
     /**
@@ -474,21 +474,21 @@
             return;
         }
 
-        mNotificationHeader = headerView;
-        mNotificationHeader.findViewById(com.android.internal.R.id.expand_button)
+        mGroupHeader = headerView;
+        mGroupHeader.findViewById(com.android.internal.R.id.expand_button)
                 .setVisibility(VISIBLE);
-        mNotificationHeader.setOnClickListener(mHeaderClickListener);
-        mNotificationHeaderWrapper =
+        mGroupHeader.setOnClickListener(mHeaderClickListener);
+        mGroupHeaderWrapper =
                 (NotificationHeaderViewWrapper) NotificationViewWrapper.wrap(
                         getContext(),
-                        mNotificationHeader,
+                        mGroupHeader,
                         mContainingNotification);
-        mNotificationHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
-        addView(mNotificationHeader, 0);
+        mGroupHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
+        addView(mGroupHeader, 0);
         invalidate();
 
-        mNotificationHeaderWrapper.setExpanded(mChildrenExpanded);
-        mNotificationHeaderWrapper.onContentUpdated(mContainingNotification);
+        mGroupHeaderWrapper.setExpanded(mChildrenExpanded);
+        mGroupHeaderWrapper.onContentUpdated(mContainingNotification);
 
         updateHeaderVisibility(false /* animate */);
         updateChildrenAppearance();
@@ -511,20 +511,20 @@
             return;
         }
 
-        mNotificationHeaderLowPriority = headerViewLowPriority;
-        mNotificationHeaderLowPriority.findViewById(com.android.internal.R.id.expand_button)
+        mMinimizedGroupHeader = headerViewLowPriority;
+        mMinimizedGroupHeader.findViewById(com.android.internal.R.id.expand_button)
                 .setVisibility(VISIBLE);
-        mNotificationHeaderLowPriority.setOnClickListener(onClickListener);
-        mNotificationHeaderWrapperLowPriority =
+        mMinimizedGroupHeader.setOnClickListener(onClickListener);
+        mMinimizedGroupHeaderWrapper =
                 (NotificationHeaderViewWrapper) NotificationViewWrapper.wrap(
                         getContext(),
-                        mNotificationHeaderLowPriority,
+                        mMinimizedGroupHeader,
                         mContainingNotification);
-        mNotificationHeaderWrapperLowPriority.setOnRoundnessChangedListener(this::invalidate);
-        addView(mNotificationHeaderLowPriority, 0);
+        mMinimizedGroupHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
+        addView(mMinimizedGroupHeader, 0);
         invalidate();
 
-        mNotificationHeaderWrapperLowPriority.onContentUpdated(mContainingNotification);
+        mMinimizedGroupHeaderWrapper.onContentUpdated(mContainingNotification);
         updateHeaderVisibility(false /* animate */);
         updateChildrenAppearance();
     }
@@ -539,35 +539,35 @@
         AsyncGroupHeaderViewInflation.assertInLegacyMode();
         RemoteViews header;
         StatusBarNotification notification = mContainingNotification.getEntry().getSbn();
-        if (mIsLowPriority) {
+        if (mIsMinimized) {
             if (builder == null) {
                 builder = Notification.Builder.recoverBuilder(getContext(),
                         notification.getNotification());
             }
             header = builder.makeLowPriorityContentView(true /* useRegularSubtext */);
-            if (mNotificationHeaderLowPriority == null) {
-                mNotificationHeaderLowPriority = (NotificationHeaderView) header.apply(getContext(),
+            if (mMinimizedGroupHeader == null) {
+                mMinimizedGroupHeader = (NotificationHeaderView) header.apply(getContext(),
                         this);
-                mNotificationHeaderLowPriority.findViewById(com.android.internal.R.id.expand_button)
+                mMinimizedGroupHeader.findViewById(com.android.internal.R.id.expand_button)
                         .setVisibility(VISIBLE);
-                mNotificationHeaderLowPriority.setOnClickListener(mHeaderClickListener);
-                mNotificationHeaderWrapperLowPriority =
+                mMinimizedGroupHeader.setOnClickListener(mHeaderClickListener);
+                mMinimizedGroupHeaderWrapper =
                         (NotificationHeaderViewWrapper) NotificationViewWrapper.wrap(
                                 getContext(),
-                                mNotificationHeaderLowPriority,
+                                mMinimizedGroupHeader,
                                 mContainingNotification);
-                mNotificationHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
-                addView(mNotificationHeaderLowPriority, 0);
+                mGroupHeaderWrapper.setOnRoundnessChangedListener(this::invalidate);
+                addView(mMinimizedGroupHeader, 0);
                 invalidate();
             } else {
-                header.reapply(getContext(), mNotificationHeaderLowPriority);
+                header.reapply(getContext(), mMinimizedGroupHeader);
             }
-            mNotificationHeaderWrapperLowPriority.onContentUpdated(mContainingNotification);
-            resetHeaderVisibilityIfNeeded(mNotificationHeaderLowPriority, calculateDesiredHeader());
+            mMinimizedGroupHeaderWrapper.onContentUpdated(mContainingNotification);
+            resetHeaderVisibilityIfNeeded(mMinimizedGroupHeader, calculateDesiredHeader());
         } else {
-            removeView(mNotificationHeaderLowPriority);
-            mNotificationHeaderLowPriority = null;
-            mNotificationHeaderWrapperLowPriority = null;
+            removeView(mMinimizedGroupHeader);
+            mMinimizedGroupHeader = null;
+            mMinimizedGroupHeaderWrapper = null;
         }
     }
 
@@ -588,8 +588,8 @@
 
     public void updateGroupOverflow() {
         if (mShowGroupCountInExpander) {
-            setExpandButtonNumber(mNotificationHeaderWrapper);
-            setExpandButtonNumber(mNotificationHeaderWrapperLowPriority);
+            setExpandButtonNumber(mGroupHeaderWrapper);
+            setExpandButtonNumber(mMinimizedGroupHeaderWrapper);
             return;
         }
         int maxAllowedVisibleChildren = getMaxAllowedVisibleChildren(true /* likeCollapsed */);
@@ -641,9 +641,9 @@
      * @param alpha alpha value to apply to the content
      */
     public void setContentAlpha(float alpha) {
-        if (mNotificationHeader != null) {
-            for (int i = 0; i < mNotificationHeader.getChildCount(); i++) {
-                mNotificationHeader.getChildAt(i).setAlpha(alpha);
+        if (mGroupHeader != null) {
+            for (int i = 0; i < mGroupHeader.getChildCount(); i++) {
+                mGroupHeader.getChildAt(i).setAlpha(alpha);
             }
         }
         for (ExpandableNotificationRow child : getAttachedChildren()) {
@@ -683,7 +683,7 @@
             if (AsyncGroupHeaderViewInflation.isEnabled()) {
                 return mHeaderHeight;
             } else {
-                return mNotificationHeaderLowPriority.getHeight();
+                return mMinimizedGroupHeader.getHeight();
             }
         }
         int intrinsicHeight = mNotificationHeaderMargin + mCurrentHeaderTranslation;
@@ -837,15 +837,15 @@
                 mGroupOverFlowState.setAlpha(0.0f);
             }
         }
-        if (mNotificationHeader != null) {
+        if (mGroupHeader != null) {
             if (mHeaderViewState == null) {
                 mHeaderViewState = new ViewState();
             }
-            mHeaderViewState.initFrom(mNotificationHeader);
+            mHeaderViewState.initFrom(mGroupHeader);
 
             if (mContainingNotification.hasExpandingChild()) {
                 // Not modifying translationZ during expand animation.
-                mHeaderViewState.setZTranslation(mNotificationHeader.getTranslationZ());
+                mHeaderViewState.setZTranslation(mGroupHeader.getTranslationZ());
             } else if (childrenExpandedAndNotAnimating) {
                 mHeaderViewState.setZTranslation(parentState.getZTranslation());
             } else {
@@ -898,7 +898,7 @@
                 && !showingAsLowPriority()) {
             return NUMBER_OF_CHILDREN_WHEN_CHILDREN_EXPANDED;
         }
-        if (mIsLowPriority
+        if (mIsMinimized
                 || (!mContainingNotification.isOnKeyguard() && mContainingNotification.isExpanded())
                 || (mContainingNotification.isHeadsUpState()
                 && mContainingNotification.canShowHeadsUp())) {
@@ -946,7 +946,7 @@
             mNeverAppliedGroupState = false;
         }
         if (mHeaderViewState != null) {
-            mHeaderViewState.applyToView(mNotificationHeader);
+            mHeaderViewState.applyToView(mGroupHeader);
         }
         updateChildrenClipping();
     }
@@ -1006,8 +1006,8 @@
         }
 
         if (child instanceof NotificationHeaderView
-                && mNotificationHeaderWrapper.hasRoundedCorner()) {
-            float[] radii = mNotificationHeaderWrapper.getUpdatedRadii();
+                && mGroupHeaderWrapper.hasRoundedCorner()) {
+            float[] radii = mGroupHeaderWrapper.getUpdatedRadii();
             mHeaderPath.reset();
             mHeaderPath.addRoundRect(
                     child.getLeft(),
@@ -1085,8 +1085,8 @@
             }
             mGroupOverFlowState.animateTo(mOverflowNumber, properties);
         }
-        if (mNotificationHeader != null) {
-            mHeaderViewState.applyToView(mNotificationHeader);
+        if (mGroupHeader != null) {
+            mHeaderViewState.applyToView(mGroupHeader);
         }
         updateChildrenClipping();
     }
@@ -1109,8 +1109,8 @@
     public void setChildrenExpanded(boolean childrenExpanded) {
         mChildrenExpanded = childrenExpanded;
         updateExpansionStates();
-        if (mNotificationHeaderWrapper != null) {
-            mNotificationHeaderWrapper.setExpanded(childrenExpanded);
+        if (mGroupHeaderWrapper != null) {
+            mGroupHeaderWrapper.setExpanded(childrenExpanded);
         }
         final int count = mAttachedChildren.size();
         for (int childIdx = 0; childIdx < count; childIdx++) {
@@ -1130,11 +1130,11 @@
     }
 
     public NotificationViewWrapper getNotificationViewWrapper() {
-        return mNotificationHeaderWrapper;
+        return mGroupHeaderWrapper;
     }
 
-    public NotificationViewWrapper getLowPriorityViewWrapper() {
-        return mNotificationHeaderWrapperLowPriority;
+    public NotificationViewWrapper getMinimizedGroupHeaderWrapper() {
+        return mMinimizedGroupHeaderWrapper;
     }
 
     @VisibleForTesting
@@ -1142,12 +1142,12 @@
         return mCurrentHeader;
     }
 
-    public NotificationHeaderView getNotificationHeader() {
-        return mNotificationHeader;
+    public NotificationHeaderView getGroupHeader() {
+        return mGroupHeader;
     }
 
-    public NotificationHeaderView getNotificationHeaderLowPriority() {
-        return mNotificationHeaderLowPriority;
+    public NotificationHeaderView getMinimizedNotificationHeader() {
+        return mMinimizedGroupHeader;
     }
 
     private void updateHeaderVisibility(boolean animate) {
@@ -1171,7 +1171,7 @@
                 NotificationViewWrapper hiddenWrapper = getWrapperForView(currentHeader);
                 visibleWrapper.transformFrom(hiddenWrapper);
                 hiddenWrapper.transformTo(visibleWrapper, () -> updateHeaderVisibility(false));
-                startChildAlphaAnimations(desiredHeader == mNotificationHeader);
+                startChildAlphaAnimations(desiredHeader == mGroupHeader);
             } else {
                 animate = false;
             }
@@ -1192,8 +1192,8 @@
             }
         }
 
-        resetHeaderVisibilityIfNeeded(mNotificationHeader, desiredHeader);
-        resetHeaderVisibilityIfNeeded(mNotificationHeaderLowPriority, desiredHeader);
+        resetHeaderVisibilityIfNeeded(mGroupHeader, desiredHeader);
+        resetHeaderVisibilityIfNeeded(mMinimizedGroupHeader, desiredHeader);
 
         mCurrentHeader = desiredHeader;
     }
@@ -1215,9 +1215,9 @@
     private ViewGroup calculateDesiredHeader() {
         ViewGroup desiredHeader;
         if (showingAsLowPriority()) {
-            desiredHeader = mNotificationHeaderLowPriority;
+            desiredHeader = mMinimizedGroupHeader;
         } else {
-            desiredHeader = mNotificationHeader;
+            desiredHeader = mGroupHeader;
         }
         return desiredHeader;
     }
@@ -1244,20 +1244,20 @@
     private void updateHeaderTransformation() {
         if (mUserLocked && showingAsLowPriority()) {
             float fraction = getGroupExpandFraction();
-            mNotificationHeaderWrapper.transformFrom(mNotificationHeaderWrapperLowPriority,
+            mGroupHeaderWrapper.transformFrom(mMinimizedGroupHeaderWrapper,
                     fraction);
-            mNotificationHeader.setVisibility(VISIBLE);
-            mNotificationHeaderWrapperLowPriority.transformTo(mNotificationHeaderWrapper,
+            mGroupHeader.setVisibility(VISIBLE);
+            mMinimizedGroupHeaderWrapper.transformTo(mGroupHeaderWrapper,
                     fraction);
         }
 
     }
 
     private NotificationViewWrapper getWrapperForView(View visibleHeader) {
-        if (visibleHeader == mNotificationHeader) {
-            return mNotificationHeaderWrapper;
+        if (visibleHeader == mGroupHeader) {
+            return mGroupHeaderWrapper;
         }
-        return mNotificationHeaderWrapperLowPriority;
+        return mMinimizedGroupHeaderWrapper;
     }
 
     /**
@@ -1266,13 +1266,13 @@
      * @param expanded whether the group is expanded.
      */
     public void updateHeaderForExpansion(boolean expanded) {
-        if (mNotificationHeader != null) {
+        if (mGroupHeader != null) {
             if (expanded) {
                 ColorDrawable cd = new ColorDrawable();
                 cd.setColor(mContainingNotification.calculateBgColor());
-                mNotificationHeader.setHeaderBackgroundDrawable(cd);
+                mGroupHeader.setHeaderBackgroundDrawable(cd);
             } else {
-                mNotificationHeader.setHeaderBackgroundDrawable(null);
+                mGroupHeader.setHeaderBackgroundDrawable(null);
             }
         }
     }
@@ -1405,11 +1405,11 @@
             if (AsyncGroupHeaderViewInflation.isEnabled()) {
                 return mHeaderHeight;
             }
-            if (mNotificationHeaderLowPriority == null) {
+            if (mMinimizedGroupHeader == null) {
                 Log.e(TAG, "getMinHeight: low priority header is null", new Exception());
                 return 0;
             }
-            return mNotificationHeaderLowPriority.getHeight();
+            return mMinimizedGroupHeader.getHeight();
         }
         int minExpandHeight = mNotificationHeaderMargin + headerTranslation;
         int visibleChildren = 0;
@@ -1443,20 +1443,20 @@
     }
 
     public boolean showingAsLowPriority() {
-        return mIsLowPriority && !mContainingNotification.isExpanded();
+        return mIsMinimized && !mContainingNotification.isExpanded();
     }
 
     public void reInflateViews(OnClickListener listener, StatusBarNotification notification) {
         if (!AsyncGroupHeaderViewInflation.isEnabled()) {
             // When Async header inflation is enabled, we do not reinflate headers because they are
             // inflated from the background thread
-            if (mNotificationHeader != null) {
-                removeView(mNotificationHeader);
-                mNotificationHeader = null;
+            if (mGroupHeader != null) {
+                removeView(mGroupHeader);
+                mGroupHeader = null;
             }
-            if (mNotificationHeaderLowPriority != null) {
-                removeView(mNotificationHeaderLowPriority);
-                mNotificationHeaderLowPriority = null;
+            if (mMinimizedGroupHeader != null) {
+                removeView(mMinimizedGroupHeader);
+                mMinimizedGroupHeader = null;
             }
             recreateNotificationHeader(listener, mIsConversation);
         }
@@ -1489,8 +1489,8 @@
     }
 
     private void updateHeaderTouchability() {
-        if (mNotificationHeader != null) {
-            mNotificationHeader.setAcceptAllTouches(mChildrenExpanded || mUserLocked);
+        if (mGroupHeader != null) {
+            mGroupHeader.setAcceptAllTouches(mChildrenExpanded || mUserLocked);
         }
     }
 
@@ -1534,8 +1534,11 @@
         updateChildrenClipping();
     }
 
-    public void setIsLowPriority(boolean isLowPriority) {
-        mIsLowPriority = isLowPriority;
+    /**
+     * Set whether the children container is minimized.
+     */
+    public void setIsMinimized(boolean isMinimized) {
+        mIsMinimized = isMinimized;
         if (mContainingNotification != null) { /* we're not yet set up yet otherwise */
             if (!AsyncGroupHeaderViewInflation.isEnabled()) {
                 recreateLowPriorityHeader(null /* existingBuilder */, mIsConversation);
@@ -1552,13 +1555,13 @@
      */
     public NotificationViewWrapper getVisibleWrapper() {
         if (showingAsLowPriority()) {
-            return mNotificationHeaderWrapperLowPriority;
+            return mMinimizedGroupHeaderWrapper;
         }
-        return mNotificationHeaderWrapper;
+        return mGroupHeaderWrapper;
     }
 
     public void onExpansionChanged() {
-        if (mIsLowPriority) {
+        if (mIsMinimized) {
             if (mUserLocked) {
                 setUserLocked(mUserLocked);
             }
@@ -1574,15 +1577,15 @@
     @Override
     public void applyRoundnessAndInvalidate() {
         boolean last = true;
-        if (mNotificationHeaderWrapper != null) {
-            mNotificationHeaderWrapper.requestTopRoundness(
+        if (mGroupHeaderWrapper != null) {
+            mGroupHeaderWrapper.requestTopRoundness(
                     /* value = */ getTopRoundness(),
                     /* sourceType = */ FROM_PARENT,
                     /* animate = */ false
             );
         }
-        if (mNotificationHeaderWrapperLowPriority != null) {
-            mNotificationHeaderWrapperLowPriority.requestTopRoundness(
+        if (mMinimizedGroupHeaderWrapper != null) {
+            mMinimizedGroupHeaderWrapper.requestTopRoundness(
                     /* value = */ getTopRoundness(),
                     /* sourceType = */ FROM_PARENT,
                     /* animate = */ false
@@ -1612,31 +1615,31 @@
      * Shows the given feedback icon, or hides the icon if null.
      */
     public void setFeedbackIcon(@Nullable FeedbackIcon icon) {
-        if (mNotificationHeaderWrapper != null) {
-            mNotificationHeaderWrapper.setFeedbackIcon(icon);
+        if (mGroupHeaderWrapper != null) {
+            mGroupHeaderWrapper.setFeedbackIcon(icon);
         }
-        if (mNotificationHeaderWrapperLowPriority != null) {
-            mNotificationHeaderWrapperLowPriority.setFeedbackIcon(icon);
+        if (mMinimizedGroupHeaderWrapper != null) {
+            mMinimizedGroupHeaderWrapper.setFeedbackIcon(icon);
         }
     }
 
     public void setRecentlyAudiblyAlerted(boolean audiblyAlertedRecently) {
-        if (mNotificationHeaderWrapper != null) {
-            mNotificationHeaderWrapper.setRecentlyAudiblyAlerted(audiblyAlertedRecently);
+        if (mGroupHeaderWrapper != null) {
+            mGroupHeaderWrapper.setRecentlyAudiblyAlerted(audiblyAlertedRecently);
         }
-        if (mNotificationHeaderWrapperLowPriority != null) {
-            mNotificationHeaderWrapperLowPriority.setRecentlyAudiblyAlerted(audiblyAlertedRecently);
+        if (mMinimizedGroupHeaderWrapper != null) {
+            mMinimizedGroupHeaderWrapper.setRecentlyAudiblyAlerted(audiblyAlertedRecently);
         }
     }
 
     @Override
     public void setNotificationFaded(boolean faded) {
         mContainingNotificationIsFaded = faded;
-        if (mNotificationHeaderWrapper != null) {
-            mNotificationHeaderWrapper.setNotificationFaded(faded);
+        if (mGroupHeaderWrapper != null) {
+            mGroupHeaderWrapper.setNotificationFaded(faded);
         }
-        if (mNotificationHeaderWrapperLowPriority != null) {
-            mNotificationHeaderWrapperLowPriority.setNotificationFaded(faded);
+        if (mMinimizedGroupHeaderWrapper != null) {
+            mMinimizedGroupHeaderWrapper.setNotificationFaded(faded);
         }
         for (ExpandableNotificationRow child : mAttachedChildren) {
             child.setNotificationFaded(faded);
@@ -1654,7 +1657,7 @@
     }
 
     public NotificationHeaderViewWrapper getNotificationHeaderWrapper() {
-        return mNotificationHeaderWrapper;
+        return mGroupHeaderWrapper;
     }
 
     public void setLogger(NotificationChildrenContainerLogger logger) {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithm.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithm.java
index 9b1952b..b42c07d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithm.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithm.java
@@ -53,9 +53,7 @@
     public static final float START_FRACTION = 0.5f;
 
     private static final String TAG = "StackScrollAlgorithm";
-    private static final Boolean DEBUG = false;
     private static final SourceType STACK_SCROLL_ALGO = SourceType.from("StackScrollAlgorithm");
-
     private final ViewGroup mHostView;
     private float mPaddingBetweenElements;
     private float mGapHeight;
@@ -247,13 +245,11 @@
                 >= ambientState.getMaxHeadsUpTranslation();
     }
 
-    public static void log(String s) {
-        if (DEBUG) {
-            android.util.Log.i(TAG, s);
-        }
+    public static void debugLog(String s) {
+        android.util.Log.i(TAG, s);
     }
 
-    public static void logView(View view, String s) {
+    public static void debugLogView(View view, String s) {
         String viewString = "";
         if (view instanceof ExpandableNotificationRow row) {
             if (row.getEntry() == null) {
@@ -274,7 +270,7 @@
         } else {
             viewString = view.toString();
         }
-        log(viewString + " " + s);
+        debugLog(viewString + " " + s);
     }
 
     private void resetChildViewStates() {
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 9fffb66..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
@@ -19,7 +19,6 @@
 
 import com.android.systemui.dagger.SysUISingleton
 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.MutableStateFlow
 
@@ -29,10 +28,6 @@
     /** The bounds of the notification stack in the current scene. */
     val stackBounds = MutableStateFlow(StackBounds())
 
-    /** The whether the corners of the notification stack should be rounded */
-    // TODO: replace with the logic from QSController
-    val stackRounding = MutableStateFlow(StackRounding(roundTop = true, roundBottom = false))
-
     /**
      * 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/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt
index 5a56ca1..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
@@ -18,6 +18,8 @@
 package com.android.systemui.statusbar.notification.stack.domain.interactor
 
 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
@@ -25,6 +27,9 @@
 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
@@ -32,12 +37,30 @@
 @Inject
 constructor(
     private val repository: NotificationStackAppearanceRepository,
+    shadeInteractor: ShadeInteractor,
 ) {
     /** The bounds of the notification stack in the current scene. */
     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: StateFlow<StackRounding> = repository.stackRounding.asStateFlow()
+    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
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 189c5e0..0000000
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt
+++ /dev/null
@@ -1,100 +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.stackClipping.collect { (bounds, rounding) ->
-                        val viewLeft = controller.view.left
-                        val viewTop = controller.view.top
-                        val roundRadius = SCRIM_CORNER_RADIUS.dpToPx(context)
-                        controller.setRoundedClippingBounds(
-                            bounds.left.roundToInt() - viewLeft,
-                            bounds.top.roundToInt() - viewTop,
-                            bounds.right.roundToInt() - viewLeft,
-                            bounds.bottom.roundToInt() - viewTop,
-                            if (rounding.roundTop) roundRadius else 0,
-                            if (rounding.roundBottom) roundRadius 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 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/NotificationsPlaceholderViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt
index ed44f20..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
@@ -28,7 +28,6 @@
 import com.android.systemui.statusbar.notification.stack.shared.model.StackRounding
 import javax.inject.Inject
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.StateFlow
 
 /**
  * ViewModel used by the Notification placeholders inside the scene container to update the
@@ -73,7 +72,7 @@
     }
 
     /** Corner rounding of the stack */
-    val stackRounding: StateFlow<StackRounding> = interactor.stackRounding
+    val stackRounding: Flow<StackRounding> = interactor.stackRounding
 
     /**
      * 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/ui/viewmodel/SharedNotificationContainerViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/SharedNotificationContainerViewModel.kt
index a38840b..ab6c148 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/SharedNotificationContainerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/SharedNotificationContainerViewModel.kt
@@ -386,7 +386,7 @@
         // All transition view models are mututally exclusive, and safe to merge
         val alphaTransitions =
             merge(
-                alternateBouncerToGoneTransitionViewModel.lockscreenAlpha,
+                alternateBouncerToGoneTransitionViewModel.lockscreenAlpha(viewState),
                 aodToLockscreenTransitionViewModel.notificationAlpha,
                 aodToOccludedTransitionViewModel.lockscreenAlpha(viewState),
                 dozingToLockscreenTransitionViewModel.lockscreenAlpha,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
index d32e88b..375939d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
@@ -3147,7 +3147,14 @@
                 public void onDozeAmountChanged(float linear, float eased) {
                     if (!lightRevealMigration()
                             && !(mLightRevealScrim.getRevealEffect() instanceof CircleReveal)) {
-                        mLightRevealScrim.setRevealAmount(1f - linear);
+                        if (DeviceEntryUdfpsRefactor.isEnabled()) {
+                            // If wakeAndUnlocking, this is handled in AuthRippleInteractor
+                            if (!mBiometricUnlockController.isWakeAndUnlock()) {
+                                mLightRevealScrim.setRevealAmount(1f - linear);
+                            }
+                        } else {
+                            mLightRevealScrim.setRevealAmount(1f - linear);
+                        }
                     }
                 }
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/HeadsUpManagerPhone.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/HeadsUpManagerPhone.java
index 24be3db..86bb844 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/HeadsUpManagerPhone.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/HeadsUpManagerPhone.java
@@ -41,6 +41,7 @@
 import com.android.systemui.statusbar.notification.collection.provider.VisualStabilityProvider;
 import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager;
 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
+import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor;
 import com.android.systemui.statusbar.policy.AccessibilityManagerWrapper;
 import com.android.systemui.statusbar.policy.AnimationStateHandler;
 import com.android.systemui.statusbar.policy.AvalancheController;
@@ -94,6 +95,7 @@
 
         @Override
         public HeadsUpEntryPhone acquire() {
+            NotificationsHeadsUpRefactor.assertInLegacyMode();
             if (!mPoolObjects.isEmpty()) {
                 return mPoolObjects.pop();
             }
@@ -102,6 +104,7 @@
 
         @Override
         public boolean release(@NonNull HeadsUpEntryPhone instance) {
+            NotificationsHeadsUpRefactor.assertInLegacyMode();
             mPoolObjects.push(instance);
             return true;
         }
@@ -371,15 +374,24 @@
     ///////////////////////////////////////////////////////////////////////////////////////////////
     //  HeadsUpManager utility (protected) methods overrides:
 
+    @NonNull
     @Override
-    protected HeadsUpEntry createHeadsUpEntry() {
-        return mEntryPool.acquire();
+    protected HeadsUpEntry createHeadsUpEntry(NotificationEntry entry) {
+        if (NotificationsHeadsUpRefactor.isEnabled()) {
+            return new HeadsUpEntryPhone(entry);
+        } else {
+            HeadsUpEntryPhone headsUpEntry = mEntryPool.acquire();
+            headsUpEntry.setEntry(entry);
+            return headsUpEntry;
+        }
     }
 
     @Override
     protected void onEntryRemoved(HeadsUpEntry headsUpEntry) {
         super.onEntryRemoved(headsUpEntry);
-        mEntryPool.release((HeadsUpEntryPhone) headsUpEntry);
+        if (!NotificationsHeadsUpRefactor.isEnabled()) {
+            mEntryPool.release((HeadsUpEntryPhone) headsUpEntry);
+        }
     }
 
     @Override
@@ -439,14 +451,22 @@
          */
         private boolean extended;
 
-
         @Override
         public boolean isSticky() {
             return super.isSticky() || mGutsShownPinned;
         }
 
-        public void setEntry(@NonNull final NotificationEntry entry) {
-            Runnable removeHeadsUpRunnable = () -> {
+        public HeadsUpEntryPhone() {
+            super();
+        }
+
+        public HeadsUpEntryPhone(NotificationEntry entry) {
+            super(entry);
+        }
+
+        @Override
+        protected Runnable createRemoveRunnable(NotificationEntry entry) {
+            return  () -> {
                 if (!mVisualStabilityProvider.isReorderingAllowed()
                         // We don't want to allow reordering while pulsing, but headsup need to
                         // time out anyway
@@ -460,8 +480,6 @@
                     removeEntry(entry.getKey());
                 }
             };
-
-            setEntry(entry, removeHeadsUpRunnable);
         }
 
         @Override
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/BaseHeadsUpManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/BaseHeadsUpManager.java
index 50de3cb..6f7e046 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/BaseHeadsUpManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/BaseHeadsUpManager.java
@@ -39,6 +39,7 @@
 import com.android.systemui.res.R;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag;
+import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor;
 import com.android.systemui.util.ListenerSet;
 import com.android.systemui.util.concurrency.DelayableExecutor;
 import com.android.systemui.util.settings.GlobalSettings;
@@ -162,11 +163,7 @@
      */
     @Override
     public void showNotification(@NonNull NotificationEntry entry) {
-        HeadsUpEntry headsUpEntry = createHeadsUpEntry();
-
-        // Attach NotificationEntry for AvalancheController to log key and
-        // record mPostTime for AvalancheController sorting
-        headsUpEntry.setEntry(entry);
+        HeadsUpEntry headsUpEntry = createHeadsUpEntry(entry);
 
         Runnable runnable = () -> {
             // TODO(b/315362456) log outside runnable too
@@ -375,7 +372,7 @@
     }
 
     /**
-     * Remove a notification and reset the entry.
+     * Remove a notification from the alerting entries.
      * @param key key of notification to remove
      */
     protected final void removeEntry(@NonNull String key) {
@@ -395,7 +392,11 @@
             mHeadsUpEntryMap.remove(key);
             onEntryRemoved(headsUpEntry);
             entry.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
-            headsUpEntry.reset();
+            if (NotificationsHeadsUpRefactor.isEnabled()) {
+                headsUpEntry.cancelAutoRemovalCallbacks("removeEntry");
+            } else {
+                headsUpEntry.reset();
+            }
         };
         mAvalancheController.delete(headsUpEntry, runnable, "removeEntry");
     }
@@ -657,8 +658,8 @@
     }
 
     @NonNull
-    protected HeadsUpEntry createHeadsUpEntry() {
-        return new HeadsUpEntry();
+    protected HeadsUpEntry createHeadsUpEntry(NotificationEntry entry) {
+        return new HeadsUpEntry(entry);
     }
 
     /**
@@ -694,11 +695,23 @@
 
         @Nullable private Runnable mCancelRemoveRunnable;
 
-        public void setEntry(@NonNull final NotificationEntry entry) {
-            setEntry(entry, () -> removeEntry(entry.getKey()));
+        public HeadsUpEntry() {
+            NotificationsHeadsUpRefactor.assertInLegacyMode();
         }
 
-        public void setEntry(@NonNull final NotificationEntry entry,
+        public HeadsUpEntry(NotificationEntry entry) {
+            // Attach NotificationEntry for AvalancheController to log key and
+            // record mPostTime for AvalancheController sorting
+            setEntry(entry, createRemoveRunnable(entry));
+        }
+
+        /** Attach a NotificationEntry. */
+        public void setEntry(@NonNull final NotificationEntry entry) {
+            NotificationsHeadsUpRefactor.assertInLegacyMode();
+            setEntry(entry, createRemoveRunnable(entry));
+        }
+
+        private void setEntry(@NonNull final NotificationEntry entry,
                 @Nullable Runnable removeRunnable) {
             mEntry = entry;
             mRemoveRunnable = removeRunnable;
@@ -847,6 +860,7 @@
         }
 
         public void reset() {
+            NotificationsHeadsUpRefactor.assertInLegacyMode();
             cancelAutoRemovalCallbacks("reset()");
             mEntry = null;
             mRemoveRunnable = null;
@@ -919,6 +933,11 @@
             }
         }
 
+        /** Creates a runnable to remove this notification from the alerting entries. */
+        protected Runnable createRemoveRunnable(NotificationEntry entry) {
+            return () -> removeEntry(entry.getKey());
+        }
+
         /**
          * Calculate what the post time of a notification is at some current time.
          * @return the post time
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/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 d49442c..3242c28 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
@@ -116,6 +116,7 @@
             isEnabled = isEnabled,
             a11yStep = volumeRange.step,
             audioStreamModel = this,
+            isMutable = audioVolumeInteractor.isMutable(audioStream),
         )
     }
 
@@ -160,6 +161,7 @@
         override val disabledMessage: String?,
         override val isEnabled: Boolean,
         override val a11yStep: Int,
+        override val isMutable: Boolean,
         val audioStreamModel: AudioStreamModel,
     ) : SliderState
 
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 0f240b3..73c8bbf 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
@@ -90,6 +90,8 @@
     ) : SliderState {
         override val disabledMessage: String?
             get() = null
+        override val isMutable: Boolean
+            get() = false
     }
 
     @AssistedFactory
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 3dca272..8eb0b89 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,6 +36,7 @@
      */
     val a11yStep: Int
     val disabledMessage: String?
+    val isMutable: Boolean
 
     data object Empty : SliderState {
         override val value: Float = 0f
@@ -46,5 +47,6 @@
         override val disabledMessage: String? = null
         override val a11yStep: Int = 0
         override val isEnabled: Boolean = true
+        override val isMutable: Boolean = false
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/ui/activity/VolumePanelActivity.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/ui/activity/VolumePanelActivity.kt
index d430e65..c728fef 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/ui/activity/VolumePanelActivity.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/ui/activity/VolumePanelActivity.kt
@@ -42,7 +42,6 @@
     override fun onCreate(savedInstanceState: Bundle?) {
         enableEdgeToEdge()
         super.onCreate(savedInstanceState)
-
         volumePanelFlag.assertNewVolumePanel()
 
         setContent { VolumePanelRoot(viewModel = viewModel, onDismiss = ::finish) }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/DialogTransitionAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/DialogTransitionAnimatorTest.kt
index b73e4e6..9182e4101 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/animation/DialogTransitionAnimatorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/animation/DialogTransitionAnimatorTest.kt
@@ -36,6 +36,7 @@
 import org.mockito.Mockito.any
 import org.mockito.Mockito.verify
 import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
 
 @SmallTest
 @RunWith(AndroidTestingRunner::class)
@@ -44,8 +45,8 @@
     private lateinit var mDialogTransitionAnimator: DialogTransitionAnimator
     private val attachedViews = mutableSetOf<View>()
 
-    val interactionJankMonitor = Kosmos().interactionJankMonitor
-    @get:Rule val rule = MockitoJUnit.rule()
+    private val interactionJankMonitor = Kosmos().interactionJankMonitor
+    @get:Rule val rule: MockitoRule = MockitoJUnit.rule()
 
     @Before
     fun setUp() {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinderTest.kt
index 5dd37ae..66aa572 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinderTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/binder/KeyguardClockViewBinderTest.kt
@@ -131,7 +131,6 @@
         whenever(clock.smallClock).thenReturn(smallClock)
         whenever(largeClock.layout).thenReturn(largeClockFaceLayout)
         whenever(smallClock.layout).thenReturn(smallClockFaceLayout)
-        whenever(clockViewModel.clock).thenReturn(clock)
         currentClock.value = clock
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataFilterImplTest.kt
similarity index 98%
rename from packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterTest.kt
rename to packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataFilterImplTest.kt
index 59eb7bb..e56a253 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataFilterImplTest.kt
@@ -66,7 +66,7 @@
 @SmallTest
 @RunWith(AndroidTestingRunner::class)
 @TestableLooper.RunWithLooper
-class MediaDataFilterTest : SysuiTestCase() {
+class LegacyMediaDataFilterImplTest : SysuiTestCase() {
 
     @Mock private lateinit var listener: MediaDataManager.Listener
     @Mock private lateinit var userTracker: UserTracker
@@ -80,7 +80,7 @@
     @Mock private lateinit var mediaFlags: MediaFlags
     @Mock private lateinit var cardAction: SmartspaceAction
 
-    private lateinit var mediaDataFilter: MediaDataFilter
+    private lateinit var mediaDataFilter: LegacyMediaDataFilterImpl
     private lateinit var dataMain: MediaData
     private lateinit var dataGuest: MediaData
     private lateinit var dataPrivateProfile: MediaData
@@ -92,7 +92,7 @@
         MediaPlayerData.clear()
         whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false)
         mediaDataFilter =
-            MediaDataFilter(
+            LegacyMediaDataFilterImpl(
                 context,
                 userTracker,
                 broadcastSender,
@@ -370,7 +370,7 @@
         mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
         mediaDataFilter.onSwipeToDismiss()
 
-        verify(mediaDataManager).setTimedOut(eq(KEY), eq(true), eq(true))
+        verify(mediaDataManager).setInactive(eq(KEY), eq(true), eq(true))
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImplTest.kt
similarity index 98%
rename from packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManagerTest.kt
rename to packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImplTest.kt
index 61bfdb5..5a2d22d 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImplTest.kt
@@ -114,7 +114,7 @@
 @SmallTest
 @RunWithLooper(setAsMainLooper = true)
 @RunWith(AndroidTestingRunner::class)
-class MediaDataManagerTest : SysuiTestCase() {
+class LegacyMediaDataManagerImplTest : SysuiTestCase() {
 
     @JvmField @Rule val mockito = MockitoJUnit.rule()
     @Mock lateinit var mediaControllerFactory: MediaControllerFactory
@@ -133,7 +133,7 @@
     @Mock lateinit var mediaSessionBasedFilter: MediaSessionBasedFilter
     @Mock lateinit var mediaDeviceManager: MediaDeviceManager
     @Mock lateinit var mediaDataCombineLatest: MediaDataCombineLatest
-    @Mock lateinit var mediaDataFilter: MediaDataFilter
+    @Mock lateinit var mediaDataFilter: LegacyMediaDataFilterImpl
     @Mock lateinit var listener: MediaDataManager.Listener
     @Mock lateinit var pendingIntent: PendingIntent
     @Mock lateinit var activityStarter: ActivityStarter
@@ -146,7 +146,7 @@
     @Mock private lateinit var mediaSmartspaceBaseAction: SmartspaceAction
     @Mock private lateinit var mediaFlags: MediaFlags
     @Mock private lateinit var logger: MediaUiEventLogger
-    lateinit var mediaDataManager: MediaDataManager
+    lateinit var mediaDataManager: LegacyMediaDataManagerImpl
     lateinit var mediaNotification: StatusBarNotification
     lateinit var remoteCastNotification: StatusBarNotification
     @Captor lateinit var mediaDataCaptor: ArgumentCaptor<MediaData>
@@ -189,7 +189,7 @@
             1
         )
         mediaDataManager =
-            MediaDataManager(
+            LegacyMediaDataManagerImpl(
                 context = context,
                 backgroundExecutor = backgroundExecutor,
                 uiExecutor = uiExecutor,
@@ -304,13 +304,13 @@
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
 
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataManager.setInactive(KEY, timedOut = true)
         assertThat(data.active).isFalse()
         verify(logger).logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
     }
 
     @Test
-    fun testSetTimedOut_resume_dismissesMedia() {
+    fun testsetInactive_resume_dismissesMedia() {
         // WHEN resume controls are present, and time out
         val desc =
             MediaDescription.Builder().run {
@@ -339,7 +339,7 @@
                 eq(false)
             )
 
-        mediaDataManager.setTimedOut(PACKAGE_NAME, timedOut = true)
+        mediaDataManager.setInactive(PACKAGE_NAME, timedOut = true)
         verify(logger)
             .logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(mediaDataCaptor.value.instanceId))
 
@@ -1485,7 +1485,7 @@
         // WHEN the notification times out
         clock.advanceTime(100)
         val currentTime = clock.elapsedRealtime()
-        mediaDataManager.setTimedOut(KEY, true, true)
+        mediaDataManager.setInactive(KEY, true, true)
 
         // THEN the last active time is changed
         verify(listener)
@@ -1602,7 +1602,7 @@
                 eq(false)
             )
         assertThat(mediaDataCaptor.value.actionsToShowInCompact.size)
-            .isEqualTo(MediaDataManager.MAX_COMPACT_ACTIONS)
+            .isEqualTo(LegacyMediaDataManagerImpl.MAX_COMPACT_ACTIONS)
     }
 
     @Test
@@ -1615,7 +1615,7 @@
                 modifyNotification(context).also {
                     it.setSmallIcon(android.R.drawable.ic_media_pause)
                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
-                    for (i in 0..MediaDataManager.MAX_NOTIFICATION_ACTIONS) {
+                    for (i in 0..LegacyMediaDataManagerImpl.MAX_NOTIFICATION_ACTIONS) {
                         it.addAction(action)
                     }
                 }
@@ -1638,7 +1638,7 @@
                 eq(false)
             )
         assertThat(mediaDataCaptor.value.actions.size)
-            .isEqualTo(MediaDataManager.MAX_NOTIFICATION_ACTIONS)
+            .isEqualTo(LegacyMediaDataManagerImpl.MAX_NOTIFICATION_ACTIONS)
     }
 
     @Test
@@ -2040,7 +2040,7 @@
 
         // When a media control based on notification is added, times out, and then removed
         addNotificationAndLoad()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataManager.setInactive(KEY, timedOut = true)
         assertThat(mediaDataCaptor.value.active).isFalse()
         mediaDataManager.onNotificationRemoved(KEY)
 
@@ -2070,7 +2070,7 @@
 
         // When a media control based on notification is added and times out
         addNotificationAndLoad()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataManager.setInactive(KEY, timedOut = true)
         assertThat(mediaDataCaptor.value.active).isFalse()
 
         // and then the session is destroyed
@@ -2142,7 +2142,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataManager.setInactive(KEY, timedOut = true)
         mediaDataManager.onNotificationRemoved(KEY)
 
         // It remains as a regular player
@@ -2162,7 +2162,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataManager.setInactive(KEY, timedOut = true)
         sessionCallbackCaptor.value.invoke(KEY)
 
         // It is converted to a resume player
@@ -2249,7 +2249,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataManager.setInactive(KEY, timedOut = true)
         sessionCallbackCaptor.value.invoke(KEY)
 
         // It is fully removed.
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImplTest.kt
new file mode 100644
index 0000000..564bdc3
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImplTest.kt
@@ -0,0 +1,931 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import android.app.smartspace.SmartspaceAction
+import android.os.Bundle
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.internal.logging.InstanceId
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.broadcast.BroadcastSender
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.media.controls.MediaTestUtils
+import com.android.systemui.media.controls.data.repository.MediaFilterRepository
+import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_RESUME
+import com.android.systemui.media.controls.shared.model.MediaData
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
+import com.android.systemui.media.controls.ui.controller.MediaPlayerData
+import com.android.systemui.media.controls.util.MediaFlags
+import com.android.systemui.media.controls.util.MediaUiEventLogger
+import com.android.systemui.settings.UserTracker
+import com.android.systemui.statusbar.NotificationLockscreenUserManager
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.util.time.FakeSystemClock
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.Executor
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyLong
+import org.mockito.Mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+private const val KEY = "TEST_KEY"
+private const val KEY_ALT = "TEST_KEY_2"
+private const val USER_MAIN = 0
+private const val USER_GUEST = 10
+private const val PRIVATE_PROFILE = 12
+private const val PACKAGE = "PKG"
+private val INSTANCE_ID = InstanceId.fakeInstanceId(123)!!
+private const val APP_UID = 99
+private const val SMARTSPACE_KEY = "SMARTSPACE_KEY"
+private const val SMARTSPACE_PACKAGE = "SMARTSPACE_PKG"
+private val SMARTSPACE_INSTANCE_ID = InstanceId.fakeInstanceId(456)!!
+
+@ExperimentalCoroutinesApi
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper
+class MediaDataFilterImplTest : SysuiTestCase() {
+
+    @Mock private lateinit var listener: MediaDataManager.Listener
+    @Mock private lateinit var userTracker: UserTracker
+    @Mock private lateinit var broadcastSender: BroadcastSender
+    @Mock private lateinit var mediaDataManager: MediaDataManager
+    @Mock private lateinit var lockscreenUserManager: NotificationLockscreenUserManager
+    @Mock private lateinit var executor: Executor
+    @Mock private lateinit var smartspaceData: SmartspaceMediaData
+    @Mock private lateinit var smartspaceMediaRecommendationItem: SmartspaceAction
+    @Mock private lateinit var logger: MediaUiEventLogger
+    @Mock private lateinit var mediaFlags: MediaFlags
+    @Mock private lateinit var cardAction: SmartspaceAction
+
+    private lateinit var mediaDataFilter: MediaDataFilterImpl
+    private lateinit var mediaFilterRepository: MediaFilterRepository
+    private lateinit var testScope: TestScope
+    private lateinit var dataMain: MediaData
+    private lateinit var dataGuest: MediaData
+    private lateinit var dataPrivateProfile: MediaData
+    private val clock = FakeSystemClock()
+
+    @Before
+    fun setup() {
+        MockitoAnnotations.initMocks(this)
+        MediaPlayerData.clear()
+        whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false)
+        testScope = TestScope()
+        mediaFilterRepository = MediaFilterRepository()
+        mediaDataFilter =
+            MediaDataFilterImpl(
+                context,
+                userTracker,
+                broadcastSender,
+                lockscreenUserManager,
+                executor,
+                clock,
+                logger,
+                mediaFlags,
+                mediaFilterRepository,
+            )
+        mediaDataFilter.mediaDataManager = mediaDataManager
+        mediaDataFilter.addListener(listener)
+
+        // Start all tests as main user
+        setUser(USER_MAIN)
+
+        // Set up test media data
+        dataMain =
+            MediaTestUtils.emptyMediaData.copy(
+                userId = USER_MAIN,
+                packageName = PACKAGE,
+                instanceId = INSTANCE_ID,
+                appUid = APP_UID
+            )
+        dataGuest = dataMain.copy(userId = USER_GUEST)
+        dataPrivateProfile = dataMain.copy(userId = PRIVATE_PROFILE)
+
+        whenever(smartspaceData.targetId).thenReturn(SMARTSPACE_KEY)
+        whenever(smartspaceData.isActive).thenReturn(true)
+        whenever(smartspaceData.isValid()).thenReturn(true)
+        whenever(smartspaceData.packageName).thenReturn(SMARTSPACE_PACKAGE)
+        whenever(smartspaceData.recommendations)
+            .thenReturn(listOf(smartspaceMediaRecommendationItem))
+        whenever(smartspaceData.headphoneConnectionTimeMillis)
+            .thenReturn(clock.currentTimeMillis() - 100)
+        whenever(smartspaceData.instanceId).thenReturn(SMARTSPACE_INSTANCE_ID)
+        whenever(smartspaceData.cardAction).thenReturn(cardAction)
+    }
+
+    private fun setUser(id: Int) {
+        whenever(lockscreenUserManager.isCurrentProfile(anyInt())).thenReturn(false)
+        whenever(lockscreenUserManager.isProfileAvailable(anyInt())).thenReturn(false)
+        whenever(lockscreenUserManager.isCurrentProfile(eq(id))).thenReturn(true)
+        whenever(lockscreenUserManager.isProfileAvailable(eq(id))).thenReturn(true)
+        whenever(lockscreenUserManager.isProfileAvailable(eq(PRIVATE_PROFILE))).thenReturn(true)
+        mediaDataFilter.handleUserSwitched()
+    }
+
+    private fun setPrivateProfileUnavailable() {
+        whenever(lockscreenUserManager.isCurrentProfile(anyInt())).thenReturn(false)
+        whenever(lockscreenUserManager.isCurrentProfile(eq(USER_MAIN))).thenReturn(true)
+        whenever(lockscreenUserManager.isCurrentProfile(eq(PRIVATE_PROFILE))).thenReturn(true)
+        whenever(lockscreenUserManager.isProfileAvailable(eq(PRIVATE_PROFILE))).thenReturn(false)
+        mediaDataFilter.handleProfileChanged()
+    }
+
+    @Test
+    fun testOnDataLoadedForCurrentUser_callsListener() {
+        // GIVEN a media for main user
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
+
+        // THEN we should tell the listener
+        verify(listener)
+            .onMediaDataLoaded(eq(KEY), eq(null), eq(dataMain), eq(true), eq(0), eq(false))
+    }
+
+    @Test
+    fun testOnDataLoadedForGuest_doesNotCallListener() {
+        // GIVEN a media for guest user
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataGuest)
+
+        // THEN we should NOT tell the listener
+        verify(listener, never())
+            .onMediaDataLoaded(any(), any(), any(), anyBoolean(), anyInt(), anyBoolean())
+    }
+
+    @Test
+    fun testOnRemovedForCurrent_callsListener() {
+        // GIVEN a media was removed for main user
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
+        mediaDataFilter.onMediaDataRemoved(KEY)
+
+        // THEN we should tell the listener
+        verify(listener).onMediaDataRemoved(eq(KEY))
+    }
+
+    @Test
+    fun testOnRemovedForGuest_doesNotCallListener() {
+        // GIVEN a media was removed for guest user
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataGuest)
+        mediaDataFilter.onMediaDataRemoved(KEY)
+
+        // THEN we should NOT tell the listener
+        verify(listener, never()).onMediaDataRemoved(eq(KEY))
+    }
+
+    @Test
+    fun testOnUserSwitched_removesOldUserControls() {
+        // GIVEN that we have a media loaded for main user
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
+
+        // and we switch to guest user
+        setUser(USER_GUEST)
+
+        // THEN we should remove the main user's media
+        verify(listener).onMediaDataRemoved(eq(KEY))
+    }
+
+    @Test
+    fun testOnUserSwitched_addsNewUserControls() {
+        // GIVEN that we had some media for both users
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
+        mediaDataFilter.onMediaDataLoaded(KEY_ALT, null, dataGuest)
+        reset(listener)
+
+        // and we switch to guest user
+        setUser(USER_GUEST)
+
+        // THEN we should add back the guest user media
+        verify(listener)
+            .onMediaDataLoaded(eq(KEY_ALT), eq(null), eq(dataGuest), eq(true), eq(0), eq(false))
+
+        // but not the main user's
+        verify(listener, never())
+            .onMediaDataLoaded(eq(KEY), any(), eq(dataMain), anyBoolean(), anyInt(), anyBoolean())
+    }
+
+    @Test
+    fun testOnProfileChanged_profileUnavailable_loadControls() {
+        // GIVEN that we had some media for both profiles
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
+        mediaDataFilter.onMediaDataLoaded(KEY_ALT, null, dataPrivateProfile)
+        reset(listener)
+
+        // and we change profile status
+        setPrivateProfileUnavailable()
+
+        // THEN we should add the private profile media
+        verify(listener).onMediaDataRemoved(eq(KEY_ALT))
+    }
+
+    @Test
+    fun hasAnyMedia_mediaSet_returnsTrue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = dataMain)
+
+            assertThat(hasAnyMedia(selectedUserEntries)).isTrue()
+        }
+
+    @Test
+    fun hasAnyMedia_recommendationSet_returnsFalse() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            assertThat(hasAnyMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun hasAnyMediaOrRecommendation_mediaSet_returnsTrue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = dataMain)
+
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isTrue()
+        }
+
+    @Test
+    fun hasAnyMediaOrRecommendation_recommendationSet_returnsTrue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isTrue()
+        }
+
+    @Test
+    fun hasActiveMedia_inactiveMediaSet_returnsFalse() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+
+            val data = dataMain.copy(active = false)
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = data)
+
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun hasActiveMedia_activeMediaSet_returnsTrue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val data = dataMain.copy(active = true)
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = data)
+
+            assertThat(hasActiveMedia(selectedUserEntries)).isTrue()
+        }
+
+    @Test
+    fun hasActiveMediaOrRecommendation_inactiveMediaSet_returnsFalse() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            val data = dataMain.copy(active = false)
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = data)
+
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+        }
+
+    @Test
+    fun hasActiveMediaOrRecommendation_activeMediaSet_returnsTrue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            val data = dataMain.copy(active = true)
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = data)
+
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+        }
+
+    @Test
+    fun hasActiveMediaOrRecommendation_inactiveRecommendationSet_returnsFalse() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            whenever(smartspaceData.isActive).thenReturn(false)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+        }
+
+    @Test
+    fun hasActiveMediaOrRecommendation_invalidRecommendationSet_returnsFalse() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            whenever(smartspaceData.isValid()).thenReturn(false)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+        }
+
+    @Test
+    fun hasActiveMediaOrRecommendation_activeAndValidRecommendationSet_returnsTrue() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            whenever(smartspaceData.isActive).thenReturn(true)
+            whenever(smartspaceData.isValid()).thenReturn(true)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+        }
+
+    @Test
+    fun testHasAnyMediaOrRecommendation_onlyCurrentUser() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isFalse()
+
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = dataGuest)
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isFalse()
+            assertThat(hasAnyMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun testHasActiveMediaOrRecommendation_onlyCurrentUser() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            val data = dataGuest.copy(active = true)
+
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = data)
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasAnyMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun testOnNotificationRemoved_doesNotHaveMedia() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+
+            mediaDataFilter.onMediaDataLoaded(KEY, oldKey = null, data = dataMain)
+            mediaDataFilter.onMediaDataRemoved(KEY)
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isFalse()
+            assertThat(hasAnyMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun testOnSwipeToDismiss_setsTimedOut() {
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
+        mediaDataFilter.onSwipeToDismiss()
+
+        verify(mediaDataManager).setInactive(eq(KEY), eq(true), eq(true))
+    }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_noMedia_activeValidRec_prioritizesSmartspace() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            verify(listener)
+                .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(true))
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+            verify(logger).logRecommendationAdded(SMARTSPACE_PACKAGE, SMARTSPACE_INSTANCE_ID)
+            verify(logger, never()).logRecommendationActivated(any(), any(), any())
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_noMedia_inactiveRec_showsNothing() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+
+            whenever(smartspaceData.isActive).thenReturn(false)
+
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            verify(listener, never())
+                .onMediaDataLoaded(any(), any(), any(), anyBoolean(), anyInt(), anyBoolean())
+            verify(listener, never()).onSmartspaceMediaDataLoaded(any(), any(), anyBoolean())
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+            verify(logger, never()).logRecommendationAdded(any(), any())
+            verify(logger, never()).logRecommendationActivated(any(), any(), any())
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_noRecentMedia_activeValidRec_prioritizesSmartspace() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            val dataOld = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataOld)
+            clock.advanceTime(MediaDataFilterImpl.SMARTSPACE_MAX_AGE + 100)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            verify(listener)
+                .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(true))
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+            verify(logger).logRecommendationAdded(SMARTSPACE_PACKAGE, SMARTSPACE_INSTANCE_ID)
+            verify(logger, never()).logRecommendationActivated(any(), any(), any())
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_noRecentMedia_inactiveRec_showsNothing() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            whenever(smartspaceData.isActive).thenReturn(false)
+
+            val dataOld = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataOld)
+            clock.advanceTime(MediaDataFilterImpl.SMARTSPACE_MAX_AGE + 100)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            verify(listener, never()).onSmartspaceMediaDataLoaded(any(), any(), anyBoolean())
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+            verify(logger, never()).logRecommendationAdded(any(), any())
+            verify(logger, never()).logRecommendationActivated(any(), any(), any())
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_hasRecentMedia_inactiveRec_showsNothing() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+
+            whenever(smartspaceData.isActive).thenReturn(false)
+
+            // WHEN we have media that was recently played, but not currently active
+            val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+            verify(listener)
+                .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+            // AND we get a smartspace signal
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            // THEN we should tell listeners to treat the media as not active instead
+            verify(listener, never())
+                .onMediaDataLoaded(eq(KEY), eq(KEY), any(), anyBoolean(), anyInt(), anyBoolean())
+            verify(listener, never()).onSmartspaceMediaDataLoaded(any(), any(), anyBoolean())
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+            verify(logger, never()).logRecommendationAdded(any(), any())
+            verify(logger, never()).logRecommendationActivated(any(), any(), any())
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_hasRecentMedia_activeInvalidRec_usesMedia() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            whenever(smartspaceData.isValid()).thenReturn(false)
+
+            // WHEN we have media that was recently played, but not currently active
+            val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+            verify(listener)
+                .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+            // AND we get a smartspace signal
+            runCurrent()
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            // THEN we should tell listeners to treat the media as active instead
+            val dataCurrentAndActive = dataCurrent.copy(active = true)
+            verify(listener)
+                .onMediaDataLoaded(
+                    eq(KEY),
+                    eq(KEY),
+                    eq(dataCurrentAndActive),
+                    eq(true),
+                    eq(100),
+                    eq(true)
+                )
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+            // Smartspace update shouldn't be propagated for the empty rec list.
+            verify(listener, never()).onSmartspaceMediaDataLoaded(any(), any(), anyBoolean())
+            verify(logger, never()).logRecommendationAdded(any(), any())
+            verify(logger).logRecommendationActivated(eq(APP_UID), eq(PACKAGE), eq(INSTANCE_ID))
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataLoaded_hasRecentMedia_activeValidRec_usesBoth() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            // WHEN we have media that was recently played, but not currently active
+            val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+            verify(listener)
+                .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+            // AND we get a smartspace signal
+            runCurrent()
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            // THEN we should tell listeners to treat the media as active instead
+            val dataCurrentAndActive = dataCurrent.copy(active = true)
+            verify(listener)
+                .onMediaDataLoaded(
+                    eq(KEY),
+                    eq(KEY),
+                    eq(dataCurrentAndActive),
+                    eq(true),
+                    eq(100),
+                    eq(true)
+                )
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+            // Smartspace update should also be propagated but not prioritized.
+            verify(listener)
+                .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false))
+            verify(logger).logRecommendationAdded(SMARTSPACE_PACKAGE, SMARTSPACE_INSTANCE_ID)
+            verify(logger).logRecommendationActivated(eq(APP_UID), eq(PACKAGE), eq(INSTANCE_ID))
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataRemoved_usedSmartspace_clearsSmartspace() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+            mediaDataFilter.onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+
+            verify(listener).onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun testOnSmartspaceMediaDataRemoved_usedMediaAndSmartspace_clearsBoth() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+            verify(listener)
+                .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+            runCurrent()
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            val dataCurrentAndActive = dataCurrent.copy(active = true)
+            verify(listener)
+                .onMediaDataLoaded(
+                    eq(KEY),
+                    eq(KEY),
+                    eq(dataCurrentAndActive),
+                    eq(true),
+                    eq(100),
+                    eq(true)
+                )
+
+            mediaDataFilter.onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+
+            verify(listener).onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasActiveMedia(selectedUserEntries)).isFalse()
+        }
+
+    @Test
+    fun testOnSmartspaceLoaded_persistentEnabled_isInactive_notifiesListeners() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true)
+            whenever(smartspaceData.isActive).thenReturn(false)
+
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            verify(listener)
+                .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false))
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isTrue()
+        }
+
+    @Test
+    fun testOnSmartspaceLoaded_persistentEnabled_inactive_hasRecentMedia_staysInactive() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+
+            whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true)
+            whenever(smartspaceData.isActive).thenReturn(false)
+
+            // If there is media that was recently played but inactive
+            val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+            verify(listener)
+                .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+            // And an inactive recommendation is loaded
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            // Smartspace is loaded but the media stays inactive
+            verify(listener)
+                .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false))
+            verify(listener, never())
+                .onMediaDataLoaded(any(), any(), any(), anyBoolean(), anyInt(), anyBoolean())
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isFalse()
+            assertThat(hasAnyMediaOrRecommendation(selectedUserEntries, smartspaceMediaData))
+                .isTrue()
+        }
+
+    @Test
+    fun testOnSwipeToDismiss_persistentEnabled_recommendationSetInactive() {
+        whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true)
+
+        val data =
+            EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+                targetId = SMARTSPACE_KEY,
+                isActive = true,
+                packageName = SMARTSPACE_PACKAGE,
+                recommendations = listOf(smartspaceMediaRecommendationItem),
+            )
+        mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, data)
+        mediaDataFilter.onSwipeToDismiss()
+
+        verify(mediaDataManager).setRecommendationInactive(eq(SMARTSPACE_KEY))
+        verify(mediaDataManager, never())
+            .dismissSmartspaceRecommendation(eq(SMARTSPACE_KEY), anyLong())
+    }
+
+    @Test
+    fun testSmartspaceLoaded_shouldTriggerResume_doesTrigger() =
+        testScope.runTest {
+            val selectedUserEntries by collectLastValue(mediaFilterRepository.selectedUserEntries)
+            val smartspaceMediaData by collectLastValue(mediaFilterRepository.smartspaceMediaData)
+            val reactivatedKey by collectLastValue(mediaFilterRepository.reactivatedKey)
+            // WHEN we have media that was recently played, but not currently active
+            val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+            mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+            verify(listener)
+                .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+            // AND we get a smartspace signal with extra to trigger resume
+            runCurrent()
+            val extras = Bundle().apply { putBoolean(EXTRA_KEY_TRIGGER_RESUME, true) }
+            whenever(cardAction.extras).thenReturn(extras)
+            mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+            // THEN we should tell listeners to treat the media as active instead
+            val dataCurrentAndActive = dataCurrent.copy(active = true)
+            verify(listener)
+                .onMediaDataLoaded(
+                    eq(KEY),
+                    eq(KEY),
+                    eq(dataCurrentAndActive),
+                    eq(true),
+                    eq(100),
+                    eq(true)
+                )
+            assertThat(
+                    hasActiveMediaOrRecommendation(
+                        selectedUserEntries,
+                        smartspaceMediaData,
+                        reactivatedKey
+                    )
+                )
+                .isTrue()
+            // And send the smartspace data, but not prioritized
+            verify(listener)
+                .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false))
+        }
+
+    @Test
+    fun testSmartspaceLoaded_notShouldTriggerResume_doesNotTrigger() {
+        // WHEN we have media that was recently played, but not currently active
+        val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime())
+        mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+        verify(listener)
+            .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false))
+
+        // AND we get a smartspace signal with extra to not trigger resume
+        val extras = Bundle().apply { putBoolean(EXTRA_KEY_TRIGGER_RESUME, false) }
+        whenever(cardAction.extras).thenReturn(extras)
+        mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+        // THEN listeners are not updated to show media
+        verify(listener, never())
+            .onMediaDataLoaded(eq(KEY), eq(KEY), any(), eq(true), eq(100), eq(true))
+        // But the smartspace update is still propagated
+        verify(listener)
+            .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false))
+    }
+
+    private fun hasActiveMediaOrRecommendation(
+        entries: Map<String, MediaData>?,
+        smartspaceMediaData: SmartspaceMediaData?,
+        reactivatedKey: String?
+    ): Boolean {
+        if (entries == null || smartspaceMediaData == null) {
+            return false
+        }
+        return entries.any { it.value.active } ||
+            (smartspaceMediaData.isActive &&
+                (smartspaceMediaData.isValid() || reactivatedKey != null))
+    }
+
+    private fun hasActiveMedia(entries: Map<String, MediaData>?): Boolean {
+        return entries?.any { it.value.active } ?: false
+    }
+
+    private fun hasAnyMediaOrRecommendation(
+        entries: Map<String, MediaData>?,
+        smartspaceMediaData: SmartspaceMediaData?
+    ): Boolean {
+        if (entries == null || smartspaceMediaData == null) {
+            return false
+        }
+        return entries.isNotEmpty() ||
+            (if (mediaFlags.isPersistentSsCardEnabled()) {
+                smartspaceMediaData.isValid()
+            } else {
+                smartspaceMediaData.isActive && smartspaceMediaData.isValid()
+            })
+    }
+
+    private fun hasAnyMedia(entries: Map<String, MediaData>?): Boolean {
+        return entries?.isNotEmpty() ?: false
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessorTest.kt
similarity index 91%
copy from packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManagerTest.kt
copy to packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessorTest.kt
index 61bfdb5..5c275b4 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessorTest.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2022 The Android Open Source Project
+ * 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.
@@ -41,6 +41,7 @@
 import android.provider.Settings
 import android.service.notification.StatusBarNotification
 import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
 import android.testing.TestableLooper.RunWithLooper
 import androidx.media.utils.MediaConstants
 import androidx.test.filters.SmallTest
@@ -51,6 +52,9 @@
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.broadcast.BroadcastDispatcher
 import com.android.systemui.dump.DumpManager
+import com.android.systemui.media.controls.data.repository.MediaDataRepository
+import com.android.systemui.media.controls.data.repository.MediaFilterRepository
+import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor
 import com.android.systemui.media.controls.domain.resume.MediaResumeListener
 import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser
 import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_SOURCE
@@ -64,13 +68,19 @@
 import com.android.systemui.plugins.ActivityStarter
 import com.android.systemui.res.R
 import com.android.systemui.statusbar.SbnBuilder
-import com.android.systemui.tuner.TunerService
 import com.android.systemui.util.concurrency.FakeExecutor
 import com.android.systemui.util.mockito.any
 import com.android.systemui.util.mockito.capture
 import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.util.settings.FakeSettings
 import com.android.systemui.util.time.FakeSystemClock
+import com.android.systemui.utils.os.FakeHandler
 import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
 import org.junit.After
 import org.junit.Before
 import org.junit.Rule
@@ -87,7 +97,6 @@
 import org.mockito.Mockito.reset
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.verifyNoMoreInteractions
-import org.mockito.Mockito.`when` as whenever
 import org.mockito.MockitoSession
 import org.mockito.junit.MockitoJUnit
 import org.mockito.quality.Strictness
@@ -111,10 +120,11 @@
     return Mockito.anyObject<T>()
 }
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @SmallTest
 @RunWithLooper(setAsMainLooper = true)
 @RunWith(AndroidTestingRunner::class)
-class MediaDataManagerTest : SysuiTestCase() {
+class MediaDataProcessorTest : SysuiTestCase() {
 
     @JvmField @Rule val mockito = MockitoJUnit.rule()
     @Mock lateinit var mediaControllerFactory: MediaControllerFactory
@@ -122,9 +132,9 @@
     @Mock lateinit var transportControls: MediaController.TransportControls
     @Mock lateinit var playbackInfo: MediaController.PlaybackInfo
     lateinit var session: MediaSession
-    lateinit var metadataBuilder: MediaMetadata.Builder
+    private lateinit var metadataBuilder: MediaMetadata.Builder
     lateinit var backgroundExecutor: FakeExecutor
-    lateinit var foregroundExecutor: FakeExecutor
+    private lateinit var foregroundExecutor: FakeExecutor
     lateinit var uiExecutor: FakeExecutor
     @Mock lateinit var dumpManager: DumpManager
     @Mock lateinit var broadcastDispatcher: BroadcastDispatcher
@@ -133,32 +143,38 @@
     @Mock lateinit var mediaSessionBasedFilter: MediaSessionBasedFilter
     @Mock lateinit var mediaDeviceManager: MediaDeviceManager
     @Mock lateinit var mediaDataCombineLatest: MediaDataCombineLatest
-    @Mock lateinit var mediaDataFilter: MediaDataFilter
+    @Mock lateinit var mediaDataFilter: MediaDataFilterImpl
     @Mock lateinit var listener: MediaDataManager.Listener
     @Mock lateinit var pendingIntent: PendingIntent
     @Mock lateinit var activityStarter: ActivityStarter
     @Mock lateinit var smartspaceManager: SmartspaceManager
     @Mock lateinit var keyguardUpdateMonitor: KeyguardUpdateMonitor
-    lateinit var smartspaceMediaDataProvider: SmartspaceMediaDataProvider
+    private lateinit var smartspaceMediaDataProvider: SmartspaceMediaDataProvider
     @Mock lateinit var mediaSmartspaceTarget: SmartspaceTarget
     @Mock private lateinit var mediaRecommendationItem: SmartspaceAction
-    lateinit var validRecommendationList: List<SmartspaceAction>
+    private lateinit var validRecommendationList: List<SmartspaceAction>
     @Mock private lateinit var mediaSmartspaceBaseAction: SmartspaceAction
     @Mock private lateinit var mediaFlags: MediaFlags
     @Mock private lateinit var logger: MediaUiEventLogger
-    lateinit var mediaDataManager: MediaDataManager
-    lateinit var mediaNotification: StatusBarNotification
-    lateinit var remoteCastNotification: StatusBarNotification
+    private lateinit var mediaCarouselInteractor: MediaCarouselInteractor
+    private lateinit var mediaDataProcessor: MediaDataProcessor
+    private lateinit var mediaNotification: StatusBarNotification
+    private lateinit var remoteCastNotification: StatusBarNotification
     @Captor lateinit var mediaDataCaptor: ArgumentCaptor<MediaData>
     private val clock = FakeSystemClock()
-    @Mock private lateinit var tunerService: TunerService
-    @Captor lateinit var tunableCaptor: ArgumentCaptor<TunerService.Tunable>
     @Captor lateinit var stateCallbackCaptor: ArgumentCaptor<(String, PlaybackState) -> Unit>
     @Captor lateinit var sessionCallbackCaptor: ArgumentCaptor<(String) -> Unit>
     @Captor lateinit var smartSpaceConfigBuilderCaptor: ArgumentCaptor<SmartspaceConfig>
     @Mock private lateinit var ugm: IUriGrantsManager
     @Mock private lateinit var imageSource: ImageDecoder.Source
+    private lateinit var mediaDataRepository: MediaDataRepository
+    private lateinit var mediaFilterRepository: MediaFilterRepository
+    private lateinit var testScope: TestScope
+    private lateinit var testDispatcher: TestDispatcher
+    private lateinit var testableLooper: TestableLooper
+    private lateinit var fakeHandler: FakeHandler
 
+    private val settings = FakeSettings()
     private val instanceIdSequence = InstanceIdSequenceFake(1 shl 20)
 
     private val originalSmartspaceSetting =
@@ -172,6 +188,8 @@
 
     @Before
     fun setup() {
+        whenever(mediaFlags.isMediaControlsRefactorEnabled()).thenReturn(true)
+
         staticMockSession =
             ExtendedMockito.mockitoSession()
                 .mockStatic<UriGrantsManager>(UriGrantsManager::class.java)
@@ -182,43 +200,61 @@
         foregroundExecutor = FakeExecutor(clock)
         backgroundExecutor = FakeExecutor(clock)
         uiExecutor = FakeExecutor(clock)
+        testableLooper = TestableLooper.get(this)
+        fakeHandler = FakeHandler(testableLooper.looper)
         smartspaceMediaDataProvider = SmartspaceMediaDataProvider()
         Settings.Secure.putInt(
             context.contentResolver,
             Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
             1
         )
-        mediaDataManager =
-            MediaDataManager(
+        testDispatcher = UnconfinedTestDispatcher()
+        testScope = TestScope(testDispatcher)
+        mediaFilterRepository = MediaFilterRepository()
+        mediaDataRepository = MediaDataRepository(mediaFlags, dumpManager)
+        mediaDataProcessor =
+            MediaDataProcessor(
                 context = context,
+                applicationScope = testScope,
+                backgroundDispatcher = testDispatcher,
                 backgroundExecutor = backgroundExecutor,
                 uiExecutor = uiExecutor,
                 foregroundExecutor = foregroundExecutor,
+                handler = fakeHandler,
                 mediaControllerFactory = mediaControllerFactory,
                 broadcastDispatcher = broadcastDispatcher,
                 dumpManager = dumpManager,
+                activityStarter = activityStarter,
+                smartspaceMediaDataProvider = smartspaceMediaDataProvider,
+                useMediaResumption = true,
+                useQsMediaPlayer = true,
+                systemClock = clock,
+                secureSettings = settings,
+                mediaFlags = mediaFlags,
+                logger = logger,
+                smartspaceManager = smartspaceManager,
+                keyguardUpdateMonitor = keyguardUpdateMonitor,
+                mediaDataRepository = mediaDataRepository,
+            )
+        mediaDataProcessor.start()
+        mediaCarouselInteractor =
+            MediaCarouselInteractor(
+                applicationScope = testScope.backgroundScope,
+                mediaDataRepository = mediaDataRepository,
+                mediaDataProcessor = mediaDataProcessor,
                 mediaTimeoutListener = mediaTimeoutListener,
                 mediaResumeListener = mediaResumeListener,
                 mediaSessionBasedFilter = mediaSessionBasedFilter,
                 mediaDeviceManager = mediaDeviceManager,
                 mediaDataCombineLatest = mediaDataCombineLatest,
                 mediaDataFilter = mediaDataFilter,
-                activityStarter = activityStarter,
-                smartspaceMediaDataProvider = smartspaceMediaDataProvider,
-                useMediaResumption = true,
-                useQsMediaPlayer = true,
-                systemClock = clock,
-                tunerService = tunerService,
-                mediaFlags = mediaFlags,
-                logger = logger,
-                smartspaceManager = smartspaceManager,
-                keyguardUpdateMonitor = keyguardUpdateMonitor,
+                mediaFilterRepository = mediaFilterRepository,
+                mediaFlags = mediaFlags
             )
-        verify(tunerService)
-            .addTunable(capture(tunableCaptor), eq(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION))
+        mediaCarouselInteractor.start()
         verify(mediaTimeoutListener).stateCallback = capture(stateCallbackCaptor)
         verify(mediaTimeoutListener).sessionCallback = capture(sessionCallbackCaptor)
-        session = MediaSession(context, "MediaDataManagerTestSession")
+        session = MediaSession(context, "MediaDataProcessorTestSession")
         mediaNotification =
             SbnBuilder().run {
                 setPkg(PACKAGE_NAME)
@@ -290,7 +326,7 @@
     fun tearDown() {
         staticMockSession.finishMocking()
         session.release()
-        mediaDataManager.destroy()
+        mediaDataProcessor.destroy()
         Settings.Secure.putInt(
             context.contentResolver,
             Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
@@ -299,25 +335,25 @@
     }
 
     @Test
-    fun testSetTimedOut_active_deactivatesMedia() {
+    fun testsetInactive_active_deactivatesMedia() {
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
 
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataProcessor.setInactive(KEY, timedOut = true)
         assertThat(data.active).isFalse()
         verify(logger).logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
     }
 
     @Test
-    fun testSetTimedOut_resume_dismissesMedia() {
+    fun testsetInactive_resume_dismissesMedia() {
         // WHEN resume controls are present, and time out
         val desc =
             MediaDescription.Builder().run {
                 setTitle(SESSION_TITLE)
                 build()
             }
-        mediaDataManager.addResumptionControls(
+        mediaDataProcessor.addResumptionControls(
             USER_ID,
             desc,
             Runnable {},
@@ -339,7 +375,7 @@
                 eq(false)
             )
 
-        mediaDataManager.setTimedOut(PACKAGE_NAME, timedOut = true)
+        mediaDataProcessor.setInactive(PACKAGE_NAME, timedOut = true)
         verify(logger)
             .logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(mediaDataCaptor.value.instanceId))
 
@@ -351,7 +387,7 @@
 
     @Test
     fun testLoadsMetadataOnBackground() {
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
         assertThat(backgroundExecutor.numPending()).isEqualTo(1)
     }
 
@@ -367,8 +403,7 @@
                     .build()
             )
 
-        mediaDataManager.addListener(listener)
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
 
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
@@ -386,8 +421,7 @@
 
     @Test
     fun testOnMetaDataLoaded_withoutExplicitIndicator() {
-        mediaDataManager.addListener(listener)
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
 
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
@@ -418,8 +452,7 @@
     @Test
     fun testOnMetaDataLoaded_conservesActiveFlag() {
         whenever(mediaControllerFactory.create(anyObject())).thenReturn(controller)
-        mediaDataManager.addListener(listener)
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
         verify(listener)
@@ -464,7 +497,7 @@
                 build()
             }
 
-        mediaDataManager.onNotificationAdded(KEY, notif)
+        mediaDataProcessor.onNotificationAdded(KEY, notif)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
         verify(listener)
@@ -498,7 +531,7 @@
                 build()
             }
 
-        mediaDataManager.loadMediaDataInBg(KEY, rcn, null)
+        mediaDataProcessor.loadMediaDataInBg(KEY, rcn, null)
         // no crash even though the data structure is incorrect
     }
 
@@ -523,7 +556,7 @@
                 build()
             }
 
-        mediaDataManager.loadMediaDataInBg(KEY, rcn, null)
+        mediaDataProcessor.loadMediaDataInBg(KEY, rcn, null)
         // no crash even though the data structure is incorrect
     }
 
@@ -531,7 +564,7 @@
     fun testOnNotificationRemoved_callsListener() {
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
         verify(listener).onMediaDataRemoved(eq(KEY))
         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
     }
@@ -549,7 +582,7 @@
                     .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
                     .build()
             )
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
 
         // Then a media control is created with a placeholder title string
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
@@ -580,7 +613,7 @@
                     .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
                     .build()
             )
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
 
         // Then a media control is created with a placeholder title string
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
@@ -622,7 +655,7 @@
                 }
                 build()
             }
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
 
         // Then the media control is added using the notification's title
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
@@ -646,7 +679,7 @@
         val data = mediaDataCaptor.value
         val instanceId = data.instanceId
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(
+        mediaDataProcessor.onMediaDataLoaded(
             KEY,
             null,
             data.copy(song = SESSION_EMPTY_TITLE, resumeAction = Runnable {})
@@ -654,7 +687,7 @@
 
         // WHEN the notification is removed
         reset(listener)
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN active media is not converted to resume.
         verify(listener, never())
@@ -679,7 +712,7 @@
         val data = mediaDataCaptor.value
         val instanceId = data.instanceId
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(
+        mediaDataProcessor.onMediaDataLoaded(
             KEY,
             null,
             data.copy(song = SESSION_BLANK_TITLE, resumeAction = Runnable {})
@@ -687,7 +720,7 @@
 
         // WHEN the notification is removed
         reset(listener)
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN active media is not converted to resume.
         verify(listener, never())
@@ -711,9 +744,9 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
         // WHEN the notification is removed
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
         // THEN the media data indicates that it is for resumption
         verify(listener)
             .onMediaDataLoaded(
@@ -732,8 +765,8 @@
     @Test
     fun testOnNotificationRemoved_twoWithResumption() {
         // GIVEN that the manager has two notifications with resume actions
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
-        mediaDataManager.onNotificationAdded(KEY_2, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY_2, mediaNotification)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(2)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(2)
 
@@ -761,11 +794,11 @@
         val data2 = mediaDataCaptor.value
         assertThat(data2.resumption).isFalse()
 
-        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
-        mediaDataManager.onMediaDataLoaded(KEY_2, null, data2.copy(resumeAction = Runnable {}))
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+        mediaDataProcessor.onMediaDataLoaded(KEY_2, null, data2.copy(resumeAction = Runnable {}))
         reset(listener)
         // WHEN the first is removed
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
         // THEN the data is for resumption and the key is migrated to the package name
         verify(listener)
             .onMediaDataLoaded(
@@ -779,7 +812,7 @@
         assertThat(mediaDataCaptor.value.resumption).isTrue()
         verify(listener, never()).onMediaDataRemoved(eq(KEY))
         // WHEN the second is removed
-        mediaDataManager.onNotificationRemoved(KEY_2)
+        mediaDataProcessor.onNotificationRemoved(KEY_2)
         // THEN the data is for resumption and the second key is removed
         verify(listener)
             .onMediaDataLoaded(
@@ -803,7 +836,7 @@
         val data = mediaDataCaptor.value
         val dataRemoteWithResume =
             data.copy(resumeAction = Runnable {}, playbackLocation = MediaData.PLAYBACK_CAST_LOCAL)
-        mediaDataManager.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
         verify(logger)
             .logActiveMediaAdded(
                 anyInt(),
@@ -813,7 +846,7 @@
             )
 
         // WHEN the notification is removed
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN the media data is removed
         verify(listener).onMediaDataRemoved(eq(KEY))
@@ -832,10 +865,10 @@
         val data = mediaDataCaptor.value
         val dataRemoteWithResume =
             data.copy(resumeAction = Runnable {}, playbackLocation = MediaData.PLAYBACK_CAST_LOCAL)
-        mediaDataManager.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
 
         // WHEN the notification is removed
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN the media data is converted to a resume state
         verify(listener)
@@ -860,10 +893,10 @@
         val data = mediaDataCaptor.value
         assertThat(data.playbackLocation).isEqualTo(MediaData.PLAYBACK_CAST_REMOTE)
         val dataRemoteWithResume = data.copy(resumeAction = Runnable {})
-        mediaDataManager.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
 
         // WHEN the RCN is removed
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN the media data is removed
         verify(listener).onMediaDataRemoved(eq(KEY))
@@ -886,10 +919,10 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
 
         // When the notification is removed
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // Then it is converted to resumption
         verify(listener)
@@ -913,7 +946,7 @@
 
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         verify(listener, never()).onMediaDataRemoved(eq(KEY))
         verify(logger, never())
@@ -1076,7 +1109,7 @@
                 setTitle(SESSION_EMPTY_TITLE)
                 build()
             }
-        mediaDataManager.addResumptionControls(
+        mediaDataProcessor.addResumptionControls(
             USER_ID,
             desc,
             Runnable {},
@@ -1110,7 +1143,7 @@
                 setTitle(SESSION_BLANK_TITLE)
                 build()
             }
-        mediaDataManager.addResumptionControls(
+        mediaDataProcessor.addResumptionControls(
             USER_ID,
             desc,
             Runnable {},
@@ -1145,7 +1178,7 @@
         addResumeControlAndLoad(desc)
 
         val data = mediaDataCaptor.value
-        mediaDataManager.setMediaResumptionEnabled(false)
+        mediaDataProcessor.setMediaResumptionEnabled(false)
 
         // THEN the resume controls are dismissed
         verify(listener).onMediaDataRemoved(eq(PACKAGE_NAME))
@@ -1156,7 +1189,7 @@
     fun testDismissMedia_listenerCalled() {
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
-        val removed = mediaDataManager.dismissMediaData(KEY, 0L)
+        val removed = mediaDataProcessor.dismissMediaData(KEY, 0L)
         assertThat(removed).isTrue()
 
         foregroundExecutor.advanceClockToLast()
@@ -1168,7 +1201,7 @@
 
     @Test
     fun testDismissMedia_keyDoesNotExist_returnsFalse() {
-        val removed = mediaDataManager.dismissMediaData(KEY, 0L)
+        val removed = mediaDataProcessor.dismissMediaData(KEY, 0L)
         assertThat(removed).isFalse()
     }
 
@@ -1186,7 +1219,7 @@
                 }
                 build()
             }
-        mediaDataManager.onNotificationAdded(KEY, notif)
+        mediaDataProcessor.onNotificationAdded(KEY, notif)
 
         // THEN it still loads
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
@@ -1239,7 +1272,7 @@
             .onSmartspaceMediaDataLoaded(
                 eq(KEY_MEDIA_SMARTSPACE),
                 eq(
-                    EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+                    SmartspaceMediaData(
                         targetId = KEY_MEDIA_SMARTSPACE,
                         isActive = true,
                         dismissIntent = DISMISS_INTENT,
@@ -1271,7 +1304,7 @@
             .onSmartspaceMediaDataLoaded(
                 eq(KEY_MEDIA_SMARTSPACE),
                 eq(
-                    EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+                    SmartspaceMediaData(
                         targetId = KEY_MEDIA_SMARTSPACE,
                         isActive = true,
                         dismissIntent = null,
@@ -1404,7 +1437,7 @@
         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
         val instanceId = instanceIdSequence.lastInstanceId
 
-        mediaDataManager.setRecommendationInactive(KEY_MEDIA_SMARTSPACE)
+        mediaDataProcessor.setRecommendationInactive(KEY_MEDIA_SMARTSPACE)
         uiExecutor.advanceClockToLast()
         uiExecutor.runAllReady()
 
@@ -1431,12 +1464,7 @@
     @Test
     fun testOnSmartspaceMediaDataLoaded_settingDisabled_doesNothing() {
         // WHEN media recommendation setting is off
-        Settings.Secure.putInt(
-            context.contentResolver,
-            Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
-            0
-        )
-        tunableCaptor.value.onTuningChanged(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, "0")
+        settings.putInt(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 0)
 
         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
 
@@ -1453,12 +1481,7 @@
             .onSmartspaceMediaDataLoaded(eq(KEY_MEDIA_SMARTSPACE), anyObject(), anyBoolean())
 
         // WHEN the media recommendation setting is turned off
-        Settings.Secure.putInt(
-            context.contentResolver,
-            Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
-            0
-        )
-        tunableCaptor.value.onTuningChanged(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, "0")
+        settings.putInt(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 0)
 
         // THEN listeners are notified
         uiExecutor.advanceClockToLast()
@@ -1478,14 +1501,14 @@
     @Test
     fun testOnMediaDataTimedOut_updatesLastActiveTime() {
         // GIVEN that the manager has a notification
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
 
         // WHEN the notification times out
         clock.advanceTime(100)
         val currentTime = clock.elapsedRealtime()
-        mediaDataManager.setTimedOut(KEY, true, true)
+        mediaDataProcessor.setInactive(KEY, timedOut = true, forceUpdate = true)
 
         // THEN the last active time is changed
         verify(listener)
@@ -1507,12 +1530,12 @@
         val data = mediaDataCaptor.value
         val instanceId = data.instanceId
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
 
         // WHEN the notification is removed
         clock.advanceTime(100)
         val currentTime = clock.elapsedRealtime()
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN the last active time is changed
         verify(listener)
@@ -1539,7 +1562,7 @@
         val data = mediaDataCaptor.value
         val instanceId = data.instanceId
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(
+        mediaDataProcessor.onMediaDataLoaded(
             KEY,
             null,
             data.copy(resumeAction = Runnable {}, active = false)
@@ -1548,7 +1571,7 @@
         // WHEN the notification is removed
         clock.advanceTime(100)
         val currentTime = clock.elapsedRealtime()
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // THEN the last active time is not changed
         verify(listener)
@@ -1587,7 +1610,7 @@
             }
 
         // WHEN the notification is loaded
-        mediaDataManager.onNotificationAdded(KEY, notif)
+        mediaDataProcessor.onNotificationAdded(KEY, notif)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
 
@@ -1602,7 +1625,7 @@
                 eq(false)
             )
         assertThat(mediaDataCaptor.value.actionsToShowInCompact.size)
-            .isEqualTo(MediaDataManager.MAX_COMPACT_ACTIONS)
+            .isEqualTo(MediaDataProcessor.MAX_COMPACT_ACTIONS)
     }
 
     @Test
@@ -1615,7 +1638,7 @@
                 modifyNotification(context).also {
                     it.setSmallIcon(android.R.drawable.ic_media_pause)
                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
-                    for (i in 0..MediaDataManager.MAX_NOTIFICATION_ACTIONS) {
+                    for (i in 0..MediaDataProcessor.MAX_NOTIFICATION_ACTIONS) {
                         it.addAction(action)
                     }
                 }
@@ -1623,7 +1646,7 @@
             }
 
         // WHEN the notification is loaded
-        mediaDataManager.onNotificationAdded(KEY, notif)
+        mediaDataProcessor.onNotificationAdded(KEY, notif)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
 
@@ -1638,7 +1661,7 @@
                 eq(false)
             )
         assertThat(mediaDataCaptor.value.actions.size)
-            .isEqualTo(MediaDataManager.MAX_NOTIFICATION_ACTIONS)
+            .isEqualTo(MediaDataProcessor.MAX_NOTIFICATION_ACTIONS)
     }
 
     @Test
@@ -1657,7 +1680,7 @@
                 }
                 build()
             }
-        mediaDataManager.onNotificationAdded(KEY, notifWithAction)
+        mediaDataProcessor.onNotificationAdded(KEY, notifWithAction)
 
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
@@ -1850,7 +1873,7 @@
             )
 
         // update to remote cast
-        mediaDataManager.onNotificationAdded(KEY, remoteCastNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, remoteCastNotification)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
         verify(logger)
@@ -1901,7 +1924,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.resumption).isFalse()
-        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(token = null))
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(token = null))
 
         // And then get a state update
         val state = PlaybackState.Builder().build()
@@ -1949,7 +1972,7 @@
 
         // Add resumption controls in order to have semantic actions.
         // To make sure that they are not null after changing state.
-        mediaDataManager.addResumptionControls(
+        mediaDataProcessor.addResumptionControls(
             USER_ID,
             desc,
             Runnable {},
@@ -2040,9 +2063,9 @@
 
         // When a media control based on notification is added, times out, and then removed
         addNotificationAndLoad()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataProcessor.setInactive(KEY, timedOut = true)
         assertThat(mediaDataCaptor.value.active).isFalse()
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // It is converted to a resume player
         verify(listener)
@@ -2070,7 +2093,7 @@
 
         // When a media control based on notification is added and times out
         addNotificationAndLoad()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataProcessor.setInactive(KEY, timedOut = true)
         assertThat(mediaDataCaptor.value.active).isFalse()
 
         // and then the session is destroyed
@@ -2090,7 +2113,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // It is fully removed
         verify(listener).onMediaDataRemoved(eq(KEY))
@@ -2106,10 +2129,10 @@
         // When a media control that supports resumption is added
         addNotificationAndLoad()
         val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
-        mediaDataManager.onMediaDataLoaded(KEY, null, dataResumable)
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, dataResumable)
 
         // And then removed while still active
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // It is converted to a resume player
         verify(listener)
@@ -2142,8 +2165,8 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.setInactive(KEY, timedOut = true)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // It remains as a regular player
         verify(listener, never()).onMediaDataRemoved(eq(KEY))
@@ -2162,7 +2185,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataProcessor.setInactive(KEY, timedOut = true)
         sessionCallbackCaptor.value.invoke(KEY)
 
         // It is converted to a resume player
@@ -2214,7 +2237,7 @@
         // When a media control using session actions and that does allow resumption is added,
         addNotificationAndLoad()
         val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
-        mediaDataManager.onMediaDataLoaded(KEY, null, dataResumable)
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, dataResumable)
 
         // And then the session is destroyed without timing out first
         sessionCallbackCaptor.value.invoke(KEY)
@@ -2249,7 +2272,7 @@
         addNotificationAndLoad()
         val data = mediaDataCaptor.value
         assertThat(data.active).isTrue()
-        mediaDataManager.setTimedOut(KEY, timedOut = true)
+        mediaDataProcessor.setInactive(KEY, timedOut = true)
         sessionCallbackCaptor.value.invoke(KEY)
 
         // It is fully removed.
@@ -2293,7 +2316,7 @@
         // When a media control using session actions and that does allow resumption is added,
         addNotificationAndLoad()
         val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
-        mediaDataManager.onMediaDataLoaded(KEY, null, dataResumable)
+        mediaDataProcessor.onMediaDataLoaded(KEY, null, dataResumable)
 
         // And then the session is destroyed without timing out first
         sessionCallbackCaptor.value.invoke(KEY)
@@ -2324,9 +2347,9 @@
         whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
 
         // When a notiifcation is added and then removed before it is fully processed
-        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
         backgroundExecutor.runAllReady()
-        mediaDataManager.onNotificationRemoved(KEY)
+        mediaDataProcessor.onNotificationRemoved(KEY)
 
         // We still make sure to remove it
         verify(listener).onMediaDataRemoved(eq(KEY))
@@ -2399,7 +2422,7 @@
 
     /** Helper function to add the given notification and capture the resulting MediaData */
     private fun addNotificationAndLoad(sbn: StatusBarNotification) {
-        mediaDataManager.onNotificationAdded(KEY, sbn)
+        mediaDataProcessor.onNotificationAdded(KEY, sbn)
         assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
         assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
         verify(listener)
@@ -2426,7 +2449,7 @@
         desc: MediaDescription,
         packageName: String = PACKAGE_NAME
     ) {
-        mediaDataManager.addResumptionControls(
+        mediaDataProcessor.addResumptionControls(
             USER_ID,
             desc,
             Runnable {},
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerTest.kt
index 7f3d79f..a447e44 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerTest.kt
@@ -41,7 +41,6 @@
 import com.android.settingslib.media.MediaDevice
 import com.android.settingslib.media.PhoneMediaDevice
 import com.android.systemui.SysuiTestCase
-import com.android.systemui.dump.DumpManager
 import com.android.systemui.media.controls.MediaTestUtils
 import com.android.systemui.media.controls.shared.model.MediaData
 import com.android.systemui.media.controls.shared.model.MediaDeviceData
@@ -98,7 +97,6 @@
     @Mock private lateinit var muteAwaitManager: MediaMuteAwaitConnectionManager
     private lateinit var fakeFgExecutor: FakeExecutor
     private lateinit var fakeBgExecutor: FakeExecutor
-    @Mock private lateinit var dumpster: DumpManager
     @Mock private lateinit var listener: MediaDeviceManager.Listener
     @Mock private lateinit var device: MediaDevice
     @Mock private lateinit var icon: Drawable
@@ -133,7 +131,6 @@
                 { localBluetoothManager },
                 fakeFgExecutor,
                 fakeBgExecutor,
-                dumpster,
             )
         manager.addListener(listener)
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/controller/MediaCarouselControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/controller/MediaCarouselControllerTest.kt
index f755199..59e2696c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/controller/MediaCarouselControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/controller/MediaCarouselControllerTest.kt
@@ -41,7 +41,6 @@
 import com.android.systemui.media.controls.domain.pipeline.EMPTY_SMARTSPACE_MEDIA_DATA
 import com.android.systemui.media.controls.domain.pipeline.MediaDataManager
 import com.android.systemui.media.controls.shared.model.MediaData
-import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
 import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager.Companion.LOCATION_QS
 import com.android.systemui.media.controls.ui.view.MediaHostState
 import com.android.systemui.media.controls.ui.view.MediaScrollView
@@ -111,7 +110,6 @@
     @Mock lateinit var logger: MediaUiEventLogger
     @Mock lateinit var debugLogger: MediaCarouselControllerLogger
     @Mock lateinit var mediaViewController: MediaViewController
-    @Mock lateinit var smartspaceMediaData: SmartspaceMediaData
     @Mock lateinit var mediaCarousel: MediaScrollView
     @Mock lateinit var pageIndicator: PageIndicator
     @Mock lateinit var mediaFlags: MediaFlags
@@ -165,7 +163,6 @@
         verify(mediaHostStatesManager).addCallback(capture(hostStateCallback))
         whenever(mediaControlPanelFactory.get()).thenReturn(panel)
         whenever(panel.mediaViewController).thenReturn(mediaViewController)
-        whenever(mediaDataManager.smartspaceMediaData).thenReturn(smartspaceMediaData)
         whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false)
         MediaPlayerData.clear()
         verify(globalSettings)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/BackPanelControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/BackPanelControllerTest.kt
index aa54565..6e0919f 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/BackPanelControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/BackPanelControllerTest.kt
@@ -28,9 +28,10 @@
 import android.view.ViewConfiguration
 import android.view.WindowManager
 import androidx.test.filters.SmallTest
-import com.android.internal.jank.InteractionJankMonitor
 import com.android.internal.util.LatencyTracker
 import com.android.systemui.SysuiTestCase
+import com.android.systemui.jank.interactionJankMonitor
+import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.plugins.NavigationEdgeBackPlugin
 import com.android.systemui.statusbar.VibratorHelper
 import com.android.systemui.statusbar.policy.ConfigurationController
@@ -41,10 +42,8 @@
 import org.mockito.ArgumentMatchers.any
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mock
-import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.clearInvocations
 import org.mockito.Mockito.verify
-import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
 
 @SmallTest
@@ -62,16 +61,13 @@
     @Mock private lateinit var windowManager: WindowManager
     @Mock private lateinit var configurationController: ConfigurationController
     @Mock private lateinit var latencyTracker: LatencyTracker
-    @Mock private lateinit var interactionJankMonitor: InteractionJankMonitor
+    private val interactionJankMonitor = Kosmos().interactionJankMonitor
     @Mock private lateinit var layoutParams: WindowManager.LayoutParams
     @Mock private lateinit var backCallback: NavigationEdgeBackPlugin.BackCallback
 
     @Before
     fun setup() {
         MockitoAnnotations.initMocks(this)
-        `when`(interactionJankMonitor.begin(any(), anyInt())).thenReturn(true)
-        `when`(interactionJankMonitor.end(anyInt())).thenReturn(true)
-        `when`(interactionJankMonitor.cancel(anyInt())).thenReturn(true)
         mBackPanelController =
             BackPanelController(
                 context,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/RecordIssueTileTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/RecordIssueTileTest.kt
index 761c411..37654d5 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/RecordIssueTileTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/RecordIssueTileTest.kt
@@ -31,6 +31,7 @@
 import com.android.systemui.qs.QsEventLogger
 import com.android.systemui.qs.logging.QSLogger
 import com.android.systemui.qs.pipeline.domain.interactor.PanelInteractor
+import com.android.systemui.recordissue.IssueRecordingState
 import com.android.systemui.recordissue.RecordIssueDialogDelegate
 import com.android.systemui.res.R
 import com.android.systemui.settings.UserContextProvider
@@ -74,6 +75,7 @@
     @Mock private lateinit var dialog: SystemUIDialog
 
     private lateinit var testableLooper: TestableLooper
+    private val issueRecordingState = IssueRecordingState()
     private lateinit var tile: RecordIssueTile
 
     @Before
@@ -100,13 +102,14 @@
                 dialogLauncherAnimator,
                 panelInteractor,
                 userContextProvider,
+                issueRecordingState,
                 delegateFactory,
             )
     }
 
     @Test
     fun qsTileUi_shouldLookCorrect_whenInactive() {
-        tile.isRecording = false
+        issueRecordingState.isRecording = false
 
         val testState = tile.newTileState()
         tile.handleUpdateState(testState, null)
@@ -118,8 +121,7 @@
 
     @Test
     fun qsTileUi_shouldLookCorrect_whenRecording() {
-        tile.isRecording = true
-
+        issueRecordingState.isRecording = true
         val testState = tile.newTileState()
         tile.handleUpdateState(testState, null)
 
@@ -130,7 +132,7 @@
 
     @Test
     fun inActiveQsTile_switchesToActive_whenClicked() {
-        tile.isRecording = false
+        issueRecordingState.isRecording = false
 
         val testState = tile.newTileState()
         tile.handleUpdateState(testState, null)
@@ -140,7 +142,7 @@
 
     @Test
     fun activeQsTile_switchesToInActive_whenClicked() {
-        tile.isRecording = true
+        issueRecordingState.isRecording = true
 
         val testState = tile.newTileState()
         tile.handleUpdateState(testState, null)
@@ -150,7 +152,8 @@
 
     @Test
     fun showPrompt_shouldUseKeyguardDismissUtil_ToShowDialog() {
-        tile.isRecording = false
+        issueRecordingState.isRecording = false
+
         tile.handleClick(null)
         testableLooper.processAllMessages()
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/recordissue/RecordIssueDialogDelegateTest.kt b/packages/SystemUI/tests/src/com/android/systemui/recordissue/RecordIssueDialogDelegateTest.kt
index 2e8160b..1cfca68 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/recordissue/RecordIssueDialogDelegateTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/recordissue/RecordIssueDialogDelegateTest.kt
@@ -222,4 +222,9 @@
             )
         verify(factory, never()).create(any<ScreenCapturePermissionDialogDelegate>())
     }
+
+    @Test
+    fun startButton_isDisabled_beforeIssueTypeIsSelected() {
+        assertThat(dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled).isFalse()
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java
index 43fcdf3..c25b910 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerBaseTest.java
@@ -62,7 +62,6 @@
 
 import androidx.constraintlayout.widget.ConstraintSet;
 
-import com.android.internal.jank.InteractionJankMonitor;
 import com.android.internal.logging.MetricsLogger;
 import com.android.internal.logging.UiEventLogger;
 import com.android.internal.logging.testing.UiEventLoggerFake;
@@ -299,7 +298,6 @@
     @Mock protected RecordingController mRecordingController;
     @Mock protected LockscreenGestureLogger mLockscreenGestureLogger;
     @Mock protected DumpManager mDumpManager;
-    @Mock protected InteractionJankMonitor mInteractionJankMonitor;
     @Mock protected NotificationsQSContainerController mNotificationsQSContainerController;
     @Mock protected QsFrameTranslateController mQsFrameTranslateController;
     @Mock protected StatusBarWindowStateController mStatusBarWindowStateController;
@@ -441,7 +439,7 @@
         SystemClock systemClock = new FakeSystemClock();
         mStatusBarStateController = new StatusBarStateControllerImpl(
                 mUiEventLogger,
-                mInteractionJankMonitor,
+                mKosmos.getInteractionJankMonitor(),
                 mJavaAdapter,
                 () -> mShadeInteractor,
                 () -> mKosmos.getDeviceUnlockedInteractor(),
@@ -459,7 +457,7 @@
                 mDozeParameters,
                 mScreenOffAnimationController,
                 mKeyguardLogger,
-                mInteractionJankMonitor,
+                mKosmos.getInteractionJankMonitor(),
                 mKeyguardInteractor,
                 mDumpManager,
                 mPowerInteractor));
@@ -611,7 +609,7 @@
                         mock(HeadsUpManager.class),
                         new StatusBarStateControllerImpl(
                                 new UiEventLoggerFake(),
-                                mInteractionJankMonitor,
+                                mKosmos.getInteractionJankMonitor(),
                                 mJavaAdapter,
                                 () -> mShadeInteractor,
                                 () -> mKosmos.getDeviceUnlockedInteractor(),
@@ -651,10 +649,6 @@
                 .thenReturn(mKeyguardBottomArea);
         when(mNotificationRemoteInputManager.isRemoteInputActive())
                 .thenReturn(false);
-        when(mInteractionJankMonitor.begin(any(), anyInt()))
-                .thenReturn(true);
-        when(mInteractionJankMonitor.end(anyInt()))
-                .thenReturn(true);
         doAnswer(invocation -> {
             ((Runnable) invocation.getArgument(0)).run();
             return null;
@@ -820,7 +814,7 @@
                 mAccessibilityManager,
                 mLockscreenGestureLogger,
                 mMetricsLogger,
-                mInteractionJankMonitor,
+                mKosmos.getInteractionJankMonitor(),
                 mShadeLog,
                 mDumpManager,
                 mDeviceEntryFaceAuthInteractor,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java
index 419b0fd..118d27a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java
@@ -251,7 +251,7 @@
         mCollectionListener.onEntryInit(mEntry);
         mBeforeFilterListener.onBeforeFinalizeFilter(List.of(mEntry));
         verify(mNotifInflater).inflateViews(eq(mEntry), mParamsCaptor.capture(), any());
-        assertFalse(mParamsCaptor.getValue().isLowPriority());
+        assertFalse(mParamsCaptor.getValue().isMinimized());
         mNotifInflater.invokeInflateCallbackForEntry(mEntry);
 
         // WHEN notification moves to a min priority section
@@ -260,7 +260,7 @@
 
         // THEN we rebind it
         verify(mNotifInflater).rebindViews(eq(mEntry), mParamsCaptor.capture(), any());
-        assertTrue(mParamsCaptor.getValue().isLowPriority());
+        assertTrue(mParamsCaptor.getValue().isMinimized());
 
         // THEN we do not filter it because it's not the first inflation.
         assertFalse(mUninflatedFilter.shouldFilterOut(mEntry, 0));
@@ -273,7 +273,7 @@
         mCollectionListener.onEntryInit(mEntry);
         mBeforeFilterListener.onBeforeFinalizeFilter(List.of(mEntry));
         verify(mNotifInflater).inflateViews(eq(mEntry), mParamsCaptor.capture(), any());
-        assertTrue(mParamsCaptor.getValue().isLowPriority());
+        assertTrue(mParamsCaptor.getValue().isMinimized());
         mNotifInflater.invokeInflateCallbackForEntry(mEntry);
 
         // WHEN notification is moved under a parent
@@ -282,7 +282,7 @@
 
         // THEN we rebind it as not-minimized
         verify(mNotifInflater).rebindViews(eq(mEntry), mParamsCaptor.capture(), any());
-        assertFalse(mParamsCaptor.getValue().isLowPriority());
+        assertFalse(mParamsCaptor.getValue().isMinimized());
 
         // THEN we do not filter it because it's not the first inflation.
         assertFalse(mUninflatedFilter.shouldFilterOut(mEntry, 0));
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowTest.java
index b114e13..ee2eb80 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowTest.java
@@ -741,7 +741,7 @@
         when(mockViewWrapper.getIcon()).thenReturn(mockIcon);
 
         NotificationViewWrapper mockLowPriorityViewWrapper = mock(NotificationViewWrapper.class);
-        when(mockContainer.getLowPriorityViewWrapper()).thenReturn(mockLowPriorityViewWrapper);
+        when(mockContainer.getMinimizedGroupHeaderWrapper()).thenReturn(mockLowPriorityViewWrapper);
         CachingIconView mockLowPriorityIcon = mock(CachingIconView.class);
         when(mockLowPriorityViewWrapper.getIcon()).thenReturn(mockLowPriorityIcon);
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java
index a0d1075..8c22511 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java
@@ -231,6 +231,7 @@
         NotificationContentInflater.applyRemoteView(
                 AsyncTask.SERIAL_EXECUTOR,
                 false /* inflateSynchronously */,
+                /* isMinimized= */ false,
                 result,
                 FLAG_CONTENT_VIEW_EXPANDED,
                 0,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/RowContentBindStageTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/RowContentBindStageTest.java
index 76470db..1534c84 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/RowContentBindStageTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/RowContentBindStageTest.java
@@ -197,7 +197,7 @@
         params.clearDirtyContentViews();
 
         // WHEN low priority is set and stage executed.
-        params.setUseLowPriority(true);
+        params.setUseMinimized(true);
         mRowContentBindStage.executeStage(mEntry, mRow, (en) -> { });
 
         // THEN binder is called with use low priority and contracted/expanded are called to bind.
@@ -210,7 +210,7 @@
                 anyBoolean(),
                 any());
         BindParams usedParams = bindParamsCaptor.getValue();
-        assertTrue(usedParams.isLowPriority);
+        assertTrue(usedParams.isMinimized);
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainerTest.java
index 1f38a73..3b16f14 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainerTest.java
@@ -67,7 +67,7 @@
 
     @Test
     public void testGetMaxAllowedVisibleChildren_lowPriority() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         Assert.assertEquals(mChildrenContainer.getMaxAllowedVisibleChildren(),
                 NotificationChildrenContainer.NUMBER_OF_CHILDREN_WHEN_SYSTEM_EXPANDED);
     }
@@ -81,7 +81,7 @@
 
     @Test
     public void testGetMaxAllowedVisibleChildren_lowPriority_expandedChildren() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         mChildrenContainer.setChildrenExpanded(true);
         Assert.assertEquals(mChildrenContainer.getMaxAllowedVisibleChildren(),
                 NotificationChildrenContainer.NUMBER_OF_CHILDREN_WHEN_SYSTEM_EXPANDED);
@@ -89,7 +89,7 @@
 
     @Test
     public void testGetMaxAllowedVisibleChildren_lowPriority_userLocked() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         mChildrenContainer.setUserLocked(true);
         Assert.assertEquals(mChildrenContainer.getMaxAllowedVisibleChildren(),
                 NotificationChildrenContainer.NUMBER_OF_CHILDREN_WHEN_SYSTEM_EXPANDED);
@@ -118,7 +118,7 @@
 
     @Test
     public void testShowingAsLowPriority_lowPriority() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         Assert.assertTrue(mChildrenContainer.showingAsLowPriority());
     }
 
@@ -129,7 +129,7 @@
 
     @Test
     public void testShowingAsLowPriority_lowPriority_expanded() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         mGroup.setExpandable(true);
         mGroup.setUserExpanded(true, false);
         Assert.assertFalse(mChildrenContainer.showingAsLowPriority());
@@ -140,7 +140,7 @@
         mGroup.setUserLocked(true);
         mGroup.setExpandable(true);
         mGroup.setUserExpanded(true);
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         Assert.assertEquals(mChildrenContainer.getMaxAllowedVisibleChildren(),
                 NotificationChildrenContainer.NUMBER_OF_CHILDREN_WHEN_CHILDREN_EXPANDED);
     }
@@ -148,14 +148,14 @@
     @Test
     @DisableFlags(AsyncGroupHeaderViewInflation.FLAG_NAME)
     public void testLowPriorityHeaderCleared() {
-        mGroup.setIsLowPriority(true);
+        mGroup.setIsMinimized(true);
         NotificationHeaderView lowPriorityHeaderView =
-                mChildrenContainer.getLowPriorityViewWrapper().getNotificationHeader();
+                mChildrenContainer.getMinimizedGroupHeaderWrapper().getNotificationHeader();
         Assert.assertEquals(View.VISIBLE, lowPriorityHeaderView.getVisibility());
         Assert.assertSame(mChildrenContainer, lowPriorityHeaderView.getParent());
-        mGroup.setIsLowPriority(false);
+        mGroup.setIsMinimized(false);
         assertNull(lowPriorityHeaderView.getParent());
-        assertNull(mChildrenContainer.getLowPriorityViewWrapper());
+        assertNull(mChildrenContainer.getMinimizedGroupHeaderWrapper());
     }
 
     @Test
@@ -169,7 +169,7 @@
     @Test
     @EnableFlags(AsyncGroupHeaderViewInflation.FLAG_NAME)
     public void testSetLowPriorityWithAsyncInflation_noHeaderReInflation() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
         assertNull("We don't inflate header from the main thread with Async "
                 + "Inflation enabled", mChildrenContainer.getCurrentHeaderView());
     }
@@ -179,21 +179,21 @@
     public void setLowPriorityBeforeLowPriorityHeaderSet() {
 
         //Given: the children container does not have a low-priority header, and is not low-priority
-        assertNull(mChildrenContainer.getLowPriorityViewWrapper());
-        mGroup.setIsLowPriority(false);
+        assertNull(mChildrenContainer.getMinimizedGroupHeaderWrapper());
+        mGroup.setIsMinimized(false);
 
         //When: set the children container to be low-priority and set the low-priority header
-        mGroup.setIsLowPriority(true);
-        mGroup.setLowPriorityGroupHeader(createHeaderView(/* lowPriorityHeader= */ true));
+        mGroup.setIsMinimized(true);
+        mGroup.setMinimizedGroupHeader(createHeaderView(/* lowPriorityHeader= */ true));
 
         //Then: the low-priority group header should be visible
         NotificationHeaderView lowPriorityHeaderView =
-                mChildrenContainer.getLowPriorityViewWrapper().getNotificationHeader();
+                mChildrenContainer.getMinimizedGroupHeaderWrapper().getNotificationHeader();
         Assert.assertEquals(View.VISIBLE, lowPriorityHeaderView.getVisibility());
         Assert.assertSame(mChildrenContainer, lowPriorityHeaderView.getParent());
 
         //When: set the children container to be not low-priority and set the normal header
-        mGroup.setIsLowPriority(false);
+        mGroup.setIsMinimized(false);
         mGroup.setGroupHeader(createHeaderView(/* lowPriorityHeader= */ false));
 
         //Then: the low-priority group header should not be visible , normal header should be
@@ -211,9 +211,9 @@
     public void changeLowPriorityAfterHeaderSet() {
 
         //Given: the children container does not have headers, and is not low-priority
-        assertNull(mChildrenContainer.getLowPriorityViewWrapper());
+        assertNull(mChildrenContainer.getMinimizedGroupHeaderWrapper());
         assertNull(mChildrenContainer.getNotificationHeaderWrapper());
-        mGroup.setIsLowPriority(false);
+        mGroup.setIsMinimized(false);
 
         //When: set the set the normal header
         mGroup.setGroupHeader(createHeaderView(/* lowPriorityHeader= */ false));
@@ -225,14 +225,14 @@
         Assert.assertSame(mChildrenContainer, headerView.getParent());
 
         //When: set the set the row to be low priority, and set the low-priority header
-        mGroup.setIsLowPriority(true);
-        mGroup.setLowPriorityGroupHeader(createHeaderView(/* lowPriorityHeader= */ true));
+        mGroup.setIsMinimized(true);
+        mGroup.setMinimizedGroupHeader(createHeaderView(/* lowPriorityHeader= */ true));
 
         //Then: the header view should not be visible, the low-priority group header should be
         // visible
         Assert.assertEquals(View.INVISIBLE, headerView.getVisibility());
         NotificationHeaderView lowPriorityHeaderView =
-                mChildrenContainer.getLowPriorityViewWrapper().getNotificationHeader();
+                mChildrenContainer.getMinimizedGroupHeaderWrapper().getNotificationHeader();
         Assert.assertEquals(View.VISIBLE, lowPriorityHeaderView.getVisibility());
     }
 
@@ -263,7 +263,7 @@
     @Test
     @DisableFlags(AsyncGroupHeaderViewInflation.FLAG_NAME)
     public void applyRoundnessAndInvalidate_should_be_immediately_applied_on_headerLowPriority() {
-        mChildrenContainer.setIsLowPriority(true);
+        mChildrenContainer.setIsMinimized(true);
 
         NotificationHeaderViewWrapper header = mChildrenContainer.getNotificationHeaderWrapper();
         Assert.assertEquals(0f, header.getTopRoundness(), 0.001f);
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutControllerTest.java
index a4f88fb..10d2191 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutControllerTest.java
@@ -49,7 +49,6 @@
 
 import androidx.test.filters.SmallTest;
 
-import com.android.internal.jank.InteractionJankMonitor;
 import com.android.internal.logging.MetricsLogger;
 import com.android.internal.logging.UiEventLogger;
 import com.android.internal.logging.nano.MetricsProto;
@@ -63,6 +62,7 @@
 import com.android.systemui.keyguard.data.repository.KeyguardTransitionRepository;
 import com.android.systemui.keyguard.shared.model.KeyguardState;
 import com.android.systemui.keyguard.shared.model.TransitionStep;
+import com.android.systemui.kosmos.KosmosJavaAdapter;
 import com.android.systemui.media.controls.ui.controller.KeyguardMediaController;
 import com.android.systemui.plugins.ActivityStarter;
 import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin;
@@ -130,6 +130,7 @@
 @RunWith(AndroidTestingRunner.class)
 public class NotificationStackScrollLayoutControllerTest extends SysuiTestCase {
 
+    protected KosmosJavaAdapter mKosmos = new KosmosJavaAdapter(this);
     private final FakeFeatureFlags mFeatureFlags = new FakeFeatureFlags();
     @Mock private NotificationGutsManager mNotificationGutsManager;
     @Mock private NotificationsController mNotificationsController;
@@ -167,7 +168,6 @@
     @Mock private SceneContainerFlags mSceneContainerFlags;
     @Mock private Provider<WindowRootView> mWindowRootView;
     @Mock private NotificationStackAppearanceInteractor mNotificationStackAppearanceInteractor;
-    @Mock private InteractionJankMonitor mJankMonitor;
     private final StackStateLogger mStackLogger = new StackStateLogger(logcatLogBuffer(),
             logcatLogBuffer());
     private final NotificationStackScrollLogger mLogger = new NotificationStackScrollLogger(
@@ -1030,7 +1030,7 @@
                 mSceneContainerFlags,
                 mWindowRootView,
                 mNotificationStackAppearanceInteractor,
-                mJankMonitor,
+                mKosmos.getInteractionJankMonitor(),
                 mStackLogger,
                 mLogger,
                 mNotificationStackSizeCalculator,
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/keyguard/data/repository/FakeKeyguardTransitionRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt
index dcbd577..de6bfb2 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt
@@ -18,7 +18,6 @@
 package com.android.systemui.keyguard.data.repository
 
 import android.annotation.FloatRange
-import android.util.Log
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.keyguard.shared.model.KeyguardState
 import com.android.systemui.keyguard.shared.model.TransitionInfo
@@ -48,21 +47,8 @@
     override val transitions: SharedFlow<TransitionStep> = _transitions
 
     init {
-        _transitions.tryEmit(
-            TransitionStep(
-                transitionState = TransitionState.STARTED,
-                from = KeyguardState.OFF,
-                to = KeyguardState.LOCKSCREEN,
-            )
-        )
-
-        _transitions.tryEmit(
-            TransitionStep(
-                transitionState = TransitionState.FINISHED,
-                from = KeyguardState.OFF,
-                to = KeyguardState.LOCKSCREEN,
-            )
-        )
+        // Seed the fake repository with the same initial steps the actual repository uses.
+        KeyguardTransitionRepositoryImpl.initialTransitionSteps.forEach { _transitions.tryEmit(it) }
     }
 
     /**
@@ -207,16 +193,15 @@
     suspend fun sendTransitionSteps(
         steps: List<TransitionStep>,
         testScope: TestScope,
-        validateStep: Boolean = true
+        validateSteps: Boolean = true
     ) {
         steps.forEach {
-            sendTransitionStep(step = it, validateStep = validateStep)
+            sendTransitionStep(step = it, validateStep = validateSteps)
             testScope.testScheduler.runCurrent()
         }
     }
 
     override fun startTransition(info: TransitionInfo): UUID? {
-        Log.i("TEST", "Start transition: ", Exception())
         return if (info.animator == null) UUID.randomUUID() else null
     }
 
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModelKosmos.kt
index 73fd999..709f864 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/DeviceEntryIconViewModelKosmos.kt
@@ -25,6 +25,7 @@
 import com.android.systemui.keyguard.ui.transitions.DeviceEntryIconTransition
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testScope
 import com.android.systemui.scene.shared.flag.sceneContainerFlags
 import com.android.systemui.shade.domain.interactor.shadeInteractor
 import com.android.systemui.statusbar.phone.statusBarKeyguardViewManager
@@ -50,5 +51,6 @@
         keyguardViewController = { statusBarKeyguardViewManager },
         deviceEntryInteractor = deviceEntryInteractor,
         deviceEntrySourceInteractor = deviceEntrySourceInteractor,
+        scope = testScope.backgroundScope,
     )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelKosmos.kt
index 8566251..370afc3 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/ui/viewmodel/PrimaryBouncerToLockscreenTransitionViewModelKosmos.kt
@@ -18,7 +18,6 @@
 
 package com.android.systemui.keyguard.ui.viewmodel
 
-import com.android.systemui.deviceentry.domain.interactor.deviceEntryUdfpsInteractor
 import com.android.systemui.keyguard.ui.keyguardTransitionAnimationFlow
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.Kosmos.Fixture
@@ -26,7 +25,6 @@
 
 val Kosmos.primaryBouncerToLockscreenTransitionViewModel by Fixture {
     PrimaryBouncerToLockscreenTransitionViewModel(
-        deviceEntryUdfpsInteractor = deviceEntryUdfpsInteractor,
         animationFlow = keyguardTransitionAnimationFlow,
     )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/data/repository/MediaDataRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/data/repository/MediaDataRepositoryKosmos.kt
new file mode 100644
index 0000000..5c17cb9
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/data/repository/MediaDataRepositoryKosmos.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.media.controls.data.repository
+
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.media.controls.util.mediaFlags
+
+val Kosmos.mediaDataRepository by Fixture {
+    MediaDataRepository(mediaFlags = mediaFlags, dumpManager = dumpManager)
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryKosmos.kt
new file mode 100644
index 0000000..7ce810e
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryKosmos.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.media.controls.data.repository
+
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.mediaFilterRepository by Kosmos.Fixture { MediaFilterRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataCombineLatestKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataCombineLatestKosmos.kt
new file mode 100644
index 0000000..12a6325
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataCombineLatestKosmos.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.mediaDataCombineLatest by Kosmos.Fixture { MediaDataCombineLatest() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterKosmos.kt
new file mode 100644
index 0000000..d56222e
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterKosmos.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import android.content.applicationContext
+import com.android.systemui.broadcast.BroadcastSender
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.media.controls.data.repository.mediaFilterRepository
+import com.android.systemui.media.controls.util.mediaFlags
+import com.android.systemui.media.controls.util.mediaUiEventLogger
+import com.android.systemui.settings.userTracker
+import com.android.systemui.statusbar.notificationLockscreenUserManager
+import com.android.systemui.util.time.systemClock
+import com.android.systemui.util.wakelock.WakeLockFake
+
+val Kosmos.mediaDataFilter by
+    Kosmos.Fixture {
+        MediaDataFilterImpl(
+            context = applicationContext,
+            userTracker = userTracker,
+            broadcastSender =
+                BroadcastSender(
+                    applicationContext,
+                    WakeLockFake.Builder(applicationContext),
+                    fakeExecutor
+                ),
+            lockscreenUserManager = notificationLockscreenUserManager,
+            executor = fakeExecutor,
+            systemClock = systemClock,
+            logger = mediaUiEventLogger,
+            mediaFlags = mediaFlags,
+            mediaFilterRepository = mediaFilterRepository,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessorKosmos.kt
new file mode 100644
index 0000000..cc1ad1f
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessorKosmos.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import android.app.smartspace.SmartspaceManager
+import android.content.applicationContext
+import android.os.fakeExecutorHandler
+import com.android.keyguard.keyguardUpdateMonitor
+import com.android.systemui.broadcast.broadcastDispatcher
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.media.controls.data.repository.mediaDataRepository
+import com.android.systemui.media.controls.shared.model.SmartspaceMediaDataProvider
+import com.android.systemui.media.controls.util.mediaControllerFactory
+import com.android.systemui.media.controls.util.mediaFlags
+import com.android.systemui.media.controls.util.mediaUiEventLogger
+import com.android.systemui.plugins.activityStarter
+import com.android.systemui.util.Utils
+import com.android.systemui.util.settings.fakeSettings
+import com.android.systemui.util.time.systemClock
+
+val Kosmos.mediaDataProcessor by
+    Kosmos.Fixture {
+        MediaDataProcessor(
+            context = applicationContext,
+            applicationScope = applicationCoroutineScope,
+            backgroundDispatcher = testDispatcher,
+            backgroundExecutor = fakeExecutor,
+            uiExecutor = fakeExecutor,
+            foregroundExecutor = fakeExecutor,
+            handler = fakeExecutorHandler,
+            mediaControllerFactory = mediaControllerFactory,
+            broadcastDispatcher = broadcastDispatcher,
+            dumpManager = dumpManager,
+            activityStarter = activityStarter,
+            smartspaceMediaDataProvider = SmartspaceMediaDataProvider(),
+            useMediaResumption = Utils.useMediaResumption(applicationContext),
+            useQsMediaPlayer = Utils.useQsMediaPlayer(applicationContext),
+            systemClock = systemClock,
+            secureSettings = fakeSettings,
+            mediaFlags = mediaFlags,
+            logger = mediaUiEventLogger,
+            smartspaceManager = SmartspaceManager(applicationContext),
+            keyguardUpdateMonitor = keyguardUpdateMonitor,
+            mediaDataRepository = mediaDataRepository,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerKosmos.kt
new file mode 100644
index 0000000..b98f557
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDeviceManagerKosmos.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import android.content.applicationContext
+import android.media.MediaRouter2Manager
+import android.os.fakeExecutorHandler
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.media.controls.util.localMediaManagerFactory
+import com.android.systemui.media.controls.util.mediaControllerFactory
+import com.android.systemui.media.muteawait.mediaMuteAwaitConnectionManagerFactory
+import com.android.systemui.statusbar.policy.configurationController
+
+val Kosmos.mediaDeviceManager by
+    Kosmos.Fixture {
+        MediaDeviceManager(
+            context = applicationContext,
+            controllerFactory = mediaControllerFactory,
+            localMediaManagerFactory = localMediaManagerFactory,
+            mr2manager = { MediaRouter2Manager.getInstance(applicationContext) },
+            muteAwaitConnectionManagerFactory = mediaMuteAwaitConnectionManagerFactory,
+            configurationController = configurationController,
+            localBluetoothManager = {
+                LocalBluetoothManager.create(applicationContext, fakeExecutorHandler)
+            },
+            fgExecutor = fakeExecutor,
+            bgExecutor = fakeExecutor,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaResumeListenerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaResumeListenerKosmos.kt
new file mode 100644
index 0000000..2a3e84b
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaResumeListenerKosmos.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import android.content.applicationContext
+import com.android.systemui.broadcast.broadcastDispatcher
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.media.controls.domain.resume.MediaResumeListener
+import com.android.systemui.media.controls.domain.resume.resumeMediaBrowserFactory
+import com.android.systemui.media.controls.util.mediaFlags
+import com.android.systemui.settings.userTracker
+import com.android.systemui.tuner.TunerService
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.time.systemClock
+
+val Kosmos.mediaResumeListener by
+    Kosmos.Fixture {
+        MediaResumeListener(
+            context = applicationContext,
+            broadcastDispatcher = broadcastDispatcher,
+            userTracker = userTracker,
+            mainExecutor = fakeExecutor,
+            backgroundExecutor = fakeExecutor,
+            tunerService = mock<TunerService> {},
+            mediaBrowserFactory = resumeMediaBrowserFactory,
+            dumpManager = dumpManager,
+            systemClock = systemClock,
+            mediaFlags = mediaFlags,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaSessionBasedFilterKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaSessionBasedFilterKosmos.kt
new file mode 100644
index 0000000..9b02a5b
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaSessionBasedFilterKosmos.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.media.controls.domain.pipeline
+
+import android.content.applicationContext
+import android.media.session.MediaSessionManager
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.mediaSessionBasedFilter by
+    Kosmos.Fixture {
+        MediaSessionBasedFilter(
+            context = applicationContext,
+            sessionManager = MediaSessionManager(applicationContext),
+            foregroundExecutor = fakeExecutor,
+            backgroundExecutor = fakeExecutor,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaTimeoutListenerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaTimeoutListenerKosmos.kt
new file mode 100644
index 0000000..6ec6378
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaTimeoutListenerKosmos.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.media.controls.domain.pipeline
+
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.log.logcatLogBuffer
+import com.android.systemui.media.controls.util.mediaControllerFactory
+import com.android.systemui.media.controls.util.mediaFlags
+import com.android.systemui.plugins.statusbar.statusBarStateController
+import com.android.systemui.util.time.systemClock
+
+val Kosmos.mediaTimeoutListener by
+    Kosmos.Fixture {
+        MediaTimeoutListener(
+            mediaControllerFactory = mediaControllerFactory,
+            mainExecutor = fakeExecutor,
+            logger = MediaTimeoutLogger(logcatLogBuffer("MediaTimeoutLogBuffer")),
+            statusBarStateController = statusBarStateController,
+            systemClock = systemClock,
+            mediaFlags = mediaFlags,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractorKosmos.kt
new file mode 100644
index 0000000..e5e2aff
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractorKosmos.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.media.controls.domain.pipeline.interactor
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.media.controls.data.repository.mediaDataRepository
+import com.android.systemui.media.controls.data.repository.mediaFilterRepository
+import com.android.systemui.media.controls.domain.pipeline.mediaDataCombineLatest
+import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter
+import com.android.systemui.media.controls.domain.pipeline.mediaDataProcessor
+import com.android.systemui.media.controls.domain.pipeline.mediaDeviceManager
+import com.android.systemui.media.controls.domain.pipeline.mediaResumeListener
+import com.android.systemui.media.controls.domain.pipeline.mediaSessionBasedFilter
+import com.android.systemui.media.controls.domain.pipeline.mediaTimeoutListener
+import com.android.systemui.media.controls.util.mediaFlags
+
+val Kosmos.mediaCarouselInteractor by
+    Kosmos.Fixture {
+        MediaCarouselInteractor(
+            applicationScope = applicationCoroutineScope,
+            mediaDataRepository = mediaDataRepository,
+            mediaDataProcessor = mediaDataProcessor,
+            mediaTimeoutListener = mediaTimeoutListener,
+            mediaResumeListener = mediaResumeListener,
+            mediaSessionBasedFilter = mediaSessionBasedFilter,
+            mediaDeviceManager = mediaDeviceManager,
+            mediaDataCombineLatest = mediaDataCombineLatest,
+            mediaDataFilter = mediaDataFilter,
+            mediaFilterRepository = mediaFilterRepository,
+            mediaFlags = mediaFlags,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/resume/MediaBrowserFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/resume/MediaBrowserFactoryKosmos.kt
new file mode 100644
index 0000000..2621869
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/resume/MediaBrowserFactoryKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.media.controls.domain.resume
+
+import android.content.applicationContext
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.mediaBrowserFactory by Kosmos.Fixture { MediaBrowserFactory(applicationContext) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/resume/ResumeMediaBrowserFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/resume/ResumeMediaBrowserFactoryKosmos.kt
new file mode 100644
index 0000000..ed720bd
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/resume/ResumeMediaBrowserFactoryKosmos.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.media.controls.domain.resume
+
+import android.content.applicationContext
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.log.logcatLogBuffer
+
+val Kosmos.resumeMediaBrowserFactory by
+    Kosmos.Fixture {
+        ResumeMediaBrowserFactory(
+            applicationContext,
+            mediaBrowserFactory,
+            ResumeMediaBrowserLogger(logcatLogBuffer("ResumeMediaLogBuffer"))
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/LocalMediaManagerFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/LocalMediaManagerFactoryKosmos.kt
new file mode 100644
index 0000000..2e0c9b8
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/LocalMediaManagerFactoryKosmos.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.media.controls.util
+
+import android.content.applicationContext
+import android.os.fakeExecutorHandler
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.localMediaManagerFactory by
+    Kosmos.Fixture {
+        LocalMediaManagerFactory(
+            context = applicationContext,
+            localBluetoothManager =
+                LocalBluetoothManager.create(applicationContext, fakeExecutorHandler),
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaControllerFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaControllerFactoryKosmos.kt
new file mode 100644
index 0000000..1ce6e82
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaControllerFactoryKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.media.controls.util
+
+import android.content.applicationContext
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.mediaControllerFactory by Kosmos.Fixture { MediaControllerFactory(applicationContext) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaFlagsKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaFlagsKosmos.kt
new file mode 100644
index 0000000..6f652f2
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaFlagsKosmos.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.media.controls.util
+
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.scene.shared.flag.sceneContainerFlags
+
+val Kosmos.mediaFlags by
+    Kosmos.Fixture {
+        MediaFlags(featureFlags = featureFlagsClassic, sceneContainerFlags = sceneContainerFlags)
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaUiEventLoggerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaUiEventLoggerKosmos.kt
new file mode 100644
index 0000000..b01876d
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/MediaUiEventLoggerKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.media.controls.util
+
+import com.android.internal.logging.uiEventLogger
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.mediaUiEventLogger by Kosmos.Fixture { MediaUiEventLogger(uiEventLogger) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerFactoryKosmos.kt
new file mode 100644
index 0000000..b78bd58
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerFactoryKosmos.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.media.muteawait
+
+import android.content.applicationContext
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.log.logcatLogBuffer
+
+val Kosmos.mediaMuteAwaitConnectionManagerFactory by
+    Kosmos.Fixture {
+        MediaMuteAwaitConnectionManagerFactory(
+            context = applicationContext,
+            logger = MediaMuteAwaitLogger(logcatLogBuffer("MediaMuteAwaitLogBuffer")),
+            mainExecutor = fakeExecutor,
+        )
+    }
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/services/autofill/java/com/android/server/autofill/AutofillManagerService.java b/services/autofill/java/com/android/server/autofill/AutofillManagerService.java
index e4f1d3a..07fcb50 100644
--- a/services/autofill/java/com/android/server/autofill/AutofillManagerService.java
+++ b/services/autofill/java/com/android/server/autofill/AutofillManagerService.java
@@ -718,7 +718,9 @@
                         + ", mPccUseFallbackDetection=" + mPccUseFallbackDetection
                         + ", mPccProviderHints=" + mPccProviderHints
                         + ", mAutofillCredmanIntegrationEnabled="
-                        + mAutofillCredmanIntegrationEnabled);
+                        + mAutofillCredmanIntegrationEnabled
+                        + ", mIsFillFieldsFromCurrentSessionOnly="
+                        + mIsFillFieldsFromCurrentSessionOnly);
             }
         }
     }
diff --git a/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java b/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java
index e1291e5..14a331c 100644
--- a/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java
+++ b/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java
@@ -1672,9 +1672,10 @@
 
         @Override // from InlineSuggestionRenderCallbacksImpl
         public void onServiceDied(@NonNull RemoteInlineSuggestionRenderService service) {
-            // Don't do anything; eventually the system will bind to it again...
             Slog.w(TAG, "remote service died: " + service);
-            mRemoteInlineSuggestionRenderService = null;
+            synchronized (mLock) {
+                resetExtServiceLocked();
+            }
         }
     }
 
diff --git a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
index 8244d20..3ec6e47 100644
--- a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
+++ b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
@@ -23,6 +23,7 @@
 import static android.companion.virtual.VirtualDeviceParams.DEVICE_POLICY_DEFAULT;
 import static android.companion.virtual.VirtualDeviceParams.NAVIGATION_POLICY_DEFAULT_ALLOWED;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_ACTIVITY;
+import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_AUDIO;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CAMERA;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CLIPBOARD;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_RECENTS;
@@ -82,6 +83,8 @@
 import android.hardware.input.VirtualStylusMotionEvent;
 import android.hardware.input.VirtualTouchEvent;
 import android.hardware.input.VirtualTouchscreenConfig;
+import android.media.AudioManager;
+import android.media.audiopolicy.AudioMix;
 import android.os.Binder;
 import android.os.IBinder;
 import android.os.LocaleList;
@@ -1063,6 +1066,37 @@
     }
 
     @Override
+    public boolean hasCustomAudioInputSupport() throws RemoteException {
+        if (!Flags.vdmPublicApis()) {
+            return false;
+        }
+
+        if (!android.media.audiopolicy.Flags.audioMixTestApi()) {
+            return false;
+        }
+        if (!android.media.audiopolicy.Flags.recordAudioDeviceAwarePermission()) {
+            return false;
+        }
+
+        if (getDevicePolicy(POLICY_TYPE_AUDIO) == VirtualDeviceParams.DEVICE_POLICY_DEFAULT) {
+            return false;
+        }
+        final long token = Binder.clearCallingIdentity();
+        try {
+            AudioManager audioManager = mContext.getSystemService(AudioManager.class);
+            for (AudioMix mix : audioManager.getRegisteredPolicyMixes()) {
+                if (mix.matchesVirtualDeviceId(getDeviceId())
+                        && mix.getMixType() == AudioMix.MIX_TYPE_RECORDERS) {
+                    return true;
+                }
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+        return false;
+    }
+
+    @Override
     protected void dump(FileDescriptor fd, PrintWriter fout, String[] args) {
         String indent = "    ";
         fout.println("  VirtualDevice: ");
diff --git a/services/core/Android.bp b/services/core/Android.bp
index d1d7ee7..7f5867f 100644
--- a/services/core/Android.bp
+++ b/services/core/Android.bp
@@ -242,6 +242,7 @@
         "apache-commons-math",
         "backstage_power_flags_lib",
         "notification_flags_lib",
+        "power_hint_flags_lib",
         "biometrics_flags_lib",
         "am_flags_lib",
         "com_android_server_accessibility_flags_lib",
diff --git a/services/core/java/com/android/server/am/ActivityManagerShellCommand.java b/services/core/java/com/android/server/am/ActivityManagerShellCommand.java
index 4ebabdc..5a97e87 100644
--- a/services/core/java/com/android/server/am/ActivityManagerShellCommand.java
+++ b/services/core/java/com/android/server/am/ActivityManagerShellCommand.java
@@ -1164,8 +1164,7 @@
         synchronized (mInternal) {
             synchronized (mInternal.mProcLock) {
                 app.mOptRecord.setFreezeSticky(isSticky);
-                mInternal.mOomAdjuster.mCachedAppOptimizer.freezeAppAsyncInternalLSP(
-                        app, 0 /* delayMillis */, true /* force */, false /* immediate */);
+                mInternal.mOomAdjuster.mCachedAppOptimizer.forceFreezeAppAsyncLSP(app);
             }
         }
         return 0;
diff --git a/services/core/java/com/android/server/am/BroadcastSkipPolicy.java b/services/core/java/com/android/server/am/BroadcastSkipPolicy.java
index 66abb42..b8ef03f 100644
--- a/services/core/java/com/android/server/am/BroadcastSkipPolicy.java
+++ b/services/core/java/com/android/server/am/BroadcastSkipPolicy.java
@@ -19,6 +19,7 @@
 import static com.android.server.am.ActivityManagerDebugConfig.DEBUG_PERMISSIONS_REVIEW;
 import static com.android.server.am.ActivityManagerService.checkComponentPermission;
 import static com.android.server.am.BroadcastQueue.TAG;
+import static com.android.server.am.Flags.usePermissionManagerForBroadcastDeliveryCheck;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -27,6 +28,7 @@
 import android.app.AppOpsManager;
 import android.app.BroadcastOptions;
 import android.app.PendingIntent;
+import android.content.AttributionSource;
 import android.content.ComponentName;
 import android.content.IIntentSender;
 import android.content.Intent;
@@ -39,6 +41,7 @@
 import android.os.RemoteException;
 import android.os.UserHandle;
 import android.permission.IPermissionManager;
+import android.permission.PermissionManager;
 import android.util.Slog;
 
 import com.android.internal.util.ArrayUtils;
@@ -54,6 +57,9 @@
 public class BroadcastSkipPolicy {
     private final ActivityManagerService mService;
 
+    @Nullable
+    private PermissionManager mPermissionManager;
+
     public BroadcastSkipPolicy(@NonNull ActivityManagerService service) {
         mService = Objects.requireNonNull(service);
     }
@@ -283,14 +289,35 @@
 
         if (info.activityInfo.applicationInfo.uid != Process.SYSTEM_UID &&
                 r.requiredPermissions != null && r.requiredPermissions.length > 0) {
+            final AttributionSource attributionSource;
+            if (usePermissionManagerForBroadcastDeliveryCheck()) {
+                attributionSource =
+                        new AttributionSource.Builder(info.activityInfo.applicationInfo.uid)
+                                .setPackageName(info.activityInfo.packageName)
+                                .build();
+            } else {
+                attributionSource = null;
+            }
             for (int i = 0; i < r.requiredPermissions.length; i++) {
                 String requiredPermission = r.requiredPermissions[i];
                 try {
-                    perm = AppGlobals.getPackageManager().
-                            checkPermission(requiredPermission,
-                                    info.activityInfo.applicationInfo.packageName,
-                                    UserHandle
-                                    .getUserId(info.activityInfo.applicationInfo.uid));
+                    if (usePermissionManagerForBroadcastDeliveryCheck()) {
+                        final PermissionManager permissionManager = getPermissionManager();
+                        if (permissionManager != null) {
+                            perm = permissionManager.checkPermissionForDataDelivery(
+                                    requiredPermission, attributionSource, null /* message */);
+                        } else {
+                            // Assume permission denial if PermissionManager is not yet available.
+                            perm = PackageManager.PERMISSION_DENIED;
+                        }
+                    } else {
+                        perm = AppGlobals.getPackageManager()
+                                .checkPermission(
+                                        requiredPermission,
+                                        info.activityInfo.applicationInfo.packageName,
+                                        UserHandle
+                                                .getUserId(info.activityInfo.applicationInfo.uid));
+                    }
                 } catch (RemoteException e) {
                     perm = PackageManager.PERMISSION_DENIED;
                 }
@@ -302,11 +329,13 @@
                             + " due to sender " + r.callerPackage
                             + " (uid " + r.callingUid + ")";
                 }
-                int appOp = AppOpsManager.permissionToOpCode(requiredPermission);
-                if (appOp != AppOpsManager.OP_NONE && appOp != r.appOp) {
-                    if (!noteOpForManifestReceiver(appOp, r, info, component)) {
-                        return "Skipping delivery to " + info.activityInfo.packageName
-                                + " due to required appop " + appOp;
+                if (!usePermissionManagerForBroadcastDeliveryCheck()) {
+                    int appOp = AppOpsManager.permissionToOpCode(requiredPermission);
+                    if (appOp != AppOpsManager.OP_NONE && appOp != r.appOp) {
+                        if (!noteOpForManifestReceiver(appOp, r, info, component)) {
+                            return "Skipping delivery to " + info.activityInfo.packageName
+                                    + " due to required appop " + appOp;
+                        }
                     }
                 }
             }
@@ -694,4 +723,11 @@
 
         return false;
     }
+
+    private PermissionManager getPermissionManager() {
+        if (mPermissionManager == null) {
+            mPermissionManager = mService.mContext.getSystemService(PermissionManager.class);
+        }
+        return mPermissionManager;
+    }
 }
diff --git a/services/core/java/com/android/server/am/CachedAppOptimizer.java b/services/core/java/com/android/server/am/CachedAppOptimizer.java
index 0cf5575..6e20f6c 100644
--- a/services/core/java/com/android/server/am/CachedAppOptimizer.java
+++ b/services/core/java/com/android/server/am/CachedAppOptimizer.java
@@ -1414,8 +1414,13 @@
     }
 
     @GuardedBy({"mAm", "mProcLock"})
+    void forceFreezeAppAsyncLSP(ProcessRecord app) {
+        freezeAppAsyncInternalLSP(app, 0 /* delayMillis */, true /* force */);
+    }
+
+    @GuardedBy({"mAm", "mProcLock"})
     private void freezeAppAsyncLSP(ProcessRecord app, @UptimeMillisLong long delayMillis) {
-        freezeAppAsyncInternalLSP(app, delayMillis, false, false);
+        freezeAppAsyncInternalLSP(app, delayMillis, false /* force */);
     }
 
     @GuardedBy({"mAm", "mProcLock"})
@@ -1427,17 +1432,18 @@
     // and remove this method.
     @GuardedBy({"mAm", "mProcLock"})
     void freezeAppAsyncImmediateLSP(ProcessRecord app) {
-        freezeAppAsyncInternalLSP(app, 0, false, true);
+        freezeAppAsyncInternalLSP(app, 0 /* delayMillis */, false /* force */);
     }
 
-    // TODO: Update this method to be private and have the existing clients call different methods.
-    // This "internal" method should not be directly triggered by clients outside this class.
     @GuardedBy({"mAm", "mProcLock"})
-    void freezeAppAsyncInternalLSP(ProcessRecord app, @UptimeMillisLong long delayMillis,
-            boolean force, boolean immediate) {
+    private void freezeAppAsyncInternalLSP(ProcessRecord app, @UptimeMillisLong long delayMillis,
+            boolean force) {
         final ProcessCachedOptimizerRecord opt = app.mOptRecord;
         if (opt.isPendingFreeze()) {
-            if (immediate) {
+            if (delayMillis == 0) {
+                // Caller is requesting to freeze the process without delay, so remove
+                // any already posted messages which would have been handled with a delay and
+                // post a new message without a delay.
                 mFreezeHandler.removeMessages(SET_FROZEN_PROCESS_MSG, app);
                 mFreezeHandler.sendMessage(mFreezeHandler.obtainMessage(
                         SET_FROZEN_PROCESS_MSG, DO_FREEZE, 0, app));
diff --git a/services/core/java/com/android/server/am/flags.aconfig b/services/core/java/com/android/server/am/flags.aconfig
index 0209944..fd847f1 100644
--- a/services/core/java/com/android/server/am/flags.aconfig
+++ b/services/core/java/com/android/server/am/flags.aconfig
@@ -86,3 +86,11 @@
         purpose: PURPOSE_BUGFIX
     }
 }
+
+flag {
+    namespace: "backstage_power"
+    name: "use_permission_manager_for_broadcast_delivery_check"
+    description: "Use PermissionManager API for broadcast delivery permission checks."
+    bug: "315468967"
+    is_fixed_read_only: true
+}
diff --git a/services/core/java/com/android/server/audio/AudioService.java b/services/core/java/com/android/server/audio/AudioService.java
index e8c05c6..de000bf 100644
--- a/services/core/java/com/android/server/audio/AudioService.java
+++ b/services/core/java/com/android/server/audio/AudioService.java
@@ -73,6 +73,7 @@
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothHeadset;
 import android.bluetooth.BluetoothProfile;
+import android.content.AttributionSource;
 import android.content.BroadcastReceiver;
 import android.content.ComponentName;
 import android.content.ContentResolver;
@@ -12207,7 +12208,9 @@
     //==========================================================================================
     public String registerAudioPolicy(AudioPolicyConfig policyConfig, IAudioPolicyCallback pcb,
             boolean hasFocusListener, boolean isFocusPolicy, boolean isTestFocusPolicy,
-            boolean isVolumeController, IMediaProjection projection) {
+            boolean isVolumeController, IMediaProjection projection,
+            AttributionSource attributionSource) {
+        Objects.requireNonNull(attributionSource);
         AudioSystem.setDynamicPolicyCallback(mDynPolicyCallback);
 
         if (!isPolicyRegisterAllowed(policyConfig,
@@ -12228,7 +12231,8 @@
             }
             try {
                 AudioPolicyProxy app = new AudioPolicyProxy(policyConfig, pcb, hasFocusListener,
-                        isFocusPolicy, isTestFocusPolicy, isVolumeController, projection);
+                        isFocusPolicy, isTestFocusPolicy, isVolumeController, projection,
+                        attributionSource);
                 pcb.asBinder().linkToDeath(app, 0/*flags*/);
 
                 // logging after registration so we have the registration id
@@ -13200,6 +13204,7 @@
     public class AudioPolicyProxy extends AudioPolicyConfig implements IBinder.DeathRecipient {
         private static final String TAG = "AudioPolicyProxy";
         final IAudioPolicyCallback mPolicyCallback;
+        final AttributionSource mAttributionSource;
         final boolean mHasFocusListener;
         final boolean mIsVolumeController;
         final HashMap<Integer, AudioDeviceArray> mUidDeviceAffinities =
@@ -13239,10 +13244,12 @@
 
         AudioPolicyProxy(AudioPolicyConfig config, IAudioPolicyCallback token,
                 boolean hasFocusListener, boolean isFocusPolicy, boolean isTestFocusPolicy,
-                boolean isVolumeController, IMediaProjection projection) {
+                boolean isVolumeController, IMediaProjection projection,
+                AttributionSource attributionSource) {
             super(config);
             setRegistration(new String(config.hashCode() + ":ap:" + mAudioPolicyCounter++));
             mPolicyCallback = token;
+            mAttributionSource = attributionSource;
             mHasFocusListener = hasFocusListener;
             mIsVolumeController = isVolumeController;
             mProjection = projection;
@@ -13370,6 +13377,7 @@
                 if (android.media.audiopolicy.Flags.audioMixOwnership()) {
                     for (AudioMix mix : mixes) {
                         setMixRegistration(mix);
+                        mix.setVirtualDeviceId(mAttributionSource.getDeviceId());
                     }
 
                     int result = mAudioSystem.registerPolicyMixes(mixes, true);
@@ -13393,6 +13401,9 @@
         @AudioSystem.AudioSystemError int connectMixes() {
             final long identity = Binder.clearCallingIdentity();
             try {
+                for (AudioMix mix : mMixes) {
+                    mix.setVirtualDeviceId(mAttributionSource.getDeviceId());
+                }
                 return mAudioSystem.registerPolicyMixes(mMixes, true);
             } finally {
                 Binder.restoreCallingIdentity(identity);
@@ -13406,6 +13417,9 @@
             Objects.requireNonNull(mixesToUpdate);
             Objects.requireNonNull(updatedMixingRules);
 
+            for (AudioMix mix : mixesToUpdate) {
+                mix.setVirtualDeviceId(mAttributionSource.getDeviceId());
+            }
             if (mixesToUpdate.length != updatedMixingRules.length) {
                 Log.e(TAG, "Provided list of audio mixes to update and corresponding mixing rules "
                         + "have mismatching length (mixesToUpdate.length = " + mixesToUpdate.length
diff --git a/services/core/java/com/android/server/connectivity/Vpn.java b/services/core/java/com/android/server/connectivity/Vpn.java
index b7ece2ea..5905b7d 100644
--- a/services/core/java/com/android/server/connectivity/Vpn.java
+++ b/services/core/java/com/android/server/connectivity/Vpn.java
@@ -366,7 +366,6 @@
 
     private PendingIntent mStatusIntent;
     private volatile boolean mEnableTeardown = true;
-    private final INetworkManagementService mNms;
     private final INetd mNetd;
     @VisibleForTesting
     @GuardedBy("this")
@@ -626,7 +625,6 @@
         mSubscriptionManager = mContext.getSystemService(SubscriptionManager.class);
 
         mDeps = deps;
-        mNms = netService;
         mNetd = netd;
         mUserId = userId;
         mLooper = looper;
diff --git a/services/core/java/com/android/server/input/InputManagerService.java b/services/core/java/com/android/server/input/InputManagerService.java
index 05b1cb69..468b902 100644
--- a/services/core/java/com/android/server/input/InputManagerService.java
+++ b/services/core/java/com/android/server/input/InputManagerService.java
@@ -2604,6 +2604,19 @@
         mBatteryController.notifyStylusGestureStarted(deviceId, eventTime);
     }
 
+    // Native callback.
+    @SuppressWarnings("unused")
+    private int getPackageUid(String pkg) {
+        if (TextUtils.isEmpty(pkg)) {
+            return Process.INVALID_UID;
+        }
+        try {
+            return mContext.getPackageManager().getPackageUid(pkg, 0 /*flags*/);
+        } catch (PackageManager.NameNotFoundException e) {
+            return Process.INVALID_UID;
+        }
+    }
+
     /**
      * Flatten a map into a string list, with value positioned directly next to the
      * key.
diff --git a/services/core/java/com/android/server/inputmethod/HandwritingModeController.java b/services/core/java/com/android/server/inputmethod/HandwritingModeController.java
index 23fe5cc..dbdac41 100644
--- a/services/core/java/com/android/server/inputmethod/HandwritingModeController.java
+++ b/services/core/java/com/android/server/inputmethod/HandwritingModeController.java
@@ -16,6 +16,8 @@
 
 package com.android.server.inputmethod;
 
+import static com.android.text.flags.Flags.handwritingEndOfLineTap;
+
 import android.Manifest;
 import android.annotation.AnyThread;
 import android.annotation.NonNull;
@@ -30,6 +32,7 @@
 import android.os.Handler;
 import android.os.IBinder;
 import android.os.Looper;
+import android.os.SystemClock;
 import android.text.TextUtils;
 import android.util.Slog;
 import android.view.BatchedInputEventReceiver;
@@ -66,6 +69,7 @@
     // Use getHandwritingBufferSize() and not this value directly.
     private static final int LONG_EVENT_BUFFER_SIZE = EVENT_BUFFER_SIZE * 20;
     private static final long HANDWRITING_DELEGATION_IDLE_TIMEOUT_MS = 3000;
+    private static final long AFTER_STYLUS_UP_ALLOW_PERIOD_MS = 200L;
 
     private final Context mContext;
     // This must be the looper for the UiThread.
@@ -78,6 +82,7 @@
     private InputEventReceiver mHandwritingEventReceiver;
     private Runnable mInkWindowInitRunnable;
     private boolean mRecordingGesture;
+    private boolean mRecordingGestureAfterStylusUp;
     private int mCurrentDisplayId;
     // when set, package names are used for handwriting delegation.
     private @Nullable String mDelegatePackageName;
@@ -155,6 +160,15 @@
     }
 
     boolean isStylusGestureOngoing() {
+        if (mRecordingGestureAfterStylusUp && !mHandwritingBuffer.isEmpty()) {
+            // If it is less than AFTER_STYLUS_UP_ALLOW_PERIOD_MS after the stylus up event, return
+            // true so that handwriting can start.
+            MotionEvent lastEvent = mHandwritingBuffer.get(mHandwritingBuffer.size() - 1);
+            if (lastEvent.getActionMasked() == MotionEvent.ACTION_UP) {
+                return SystemClock.uptimeMillis() - lastEvent.getEventTime()
+                        < AFTER_STYLUS_UP_ALLOW_PERIOD_MS;
+            }
+        }
         return mRecordingGesture;
     }
 
@@ -277,7 +291,7 @@
             Slog.e(TAG, "Cannot start handwriting session: Invalid request id: " + requestId);
             return null;
         }
-        if (!mRecordingGesture || mHandwritingBuffer.isEmpty()) {
+        if (!isStylusGestureOngoing()) {
             Slog.e(TAG, "Cannot start handwriting session: No stylus gesture is being recorded.");
             return null;
         }
@@ -300,6 +314,7 @@
         mHandwritingEventReceiver.dispose();
         mHandwritingEventReceiver = null;
         mRecordingGesture = false;
+        mRecordingGestureAfterStylusUp = false;
 
         if (mHandwritingSurface.isIntercepting()) {
             throw new IllegalStateException(
@@ -362,6 +377,7 @@
             clearPendingHandwritingDelegation();
         }
         mRecordingGesture = false;
+        mRecordingGestureAfterStylusUp = false;
     }
 
     private boolean onInputEvent(InputEvent ev) {
@@ -412,15 +428,20 @@
         if ((TextUtils.isEmpty(mDelegatePackageName) || mDelegationConnectionlessFlow)
                 && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL)) {
             mRecordingGesture = false;
-            mHandwritingBuffer.clear();
-            return;
+            if (handwritingEndOfLineTap() && action == MotionEvent.ACTION_UP) {
+                mRecordingGestureAfterStylusUp = true;
+            } else {
+                mHandwritingBuffer.clear();
+                return;
+            }
         }
 
         if (action == MotionEvent.ACTION_DOWN) {
+            clearBufferIfRecordingAfterStylusUp();
             mRecordingGesture = true;
         }
 
-        if (!mRecordingGesture) {
+        if (!mRecordingGesture && !mRecordingGestureAfterStylusUp) {
             return;
         }
 
@@ -430,12 +451,20 @@
                         + " The rest of the gesture will not be recorded.");
             }
             mRecordingGesture = false;
+            clearBufferIfRecordingAfterStylusUp();
             return;
         }
 
         mHandwritingBuffer.add(MotionEvent.obtain(event));
     }
 
+    private void clearBufferIfRecordingAfterStylusUp() {
+        if (mRecordingGestureAfterStylusUp) {
+            mHandwritingBuffer.clear();
+            mRecordingGestureAfterStylusUp = false;
+        }
+    }
+
     static final class HandwritingSession {
         private final int mRequestId;
         private final InputChannel mHandwritingChannel;
diff --git a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
index d0a83a6..cfd64c4 100644
--- a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
+++ b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
@@ -1248,7 +1248,15 @@
             mService.publishLocalService();
             IInputMethodManager.Stub service;
             if (Flags.useZeroJankProxy()) {
-                service = new ZeroJankProxy(mService.mHandler::post, mService);
+                service =
+                        new ZeroJankProxy(
+                                mService.mHandler::post,
+                                mService,
+                                () -> {
+                                    synchronized (ImfLock.class) {
+                                        return mService.isInputShown();
+                                    }
+                                });
             } else {
                 service = mService;
             }
diff --git a/services/core/java/com/android/server/inputmethod/ZeroJankProxy.java b/services/core/java/com/android/server/inputmethod/ZeroJankProxy.java
index 396192e..31ce630 100644
--- a/services/core/java/com/android/server/inputmethod/ZeroJankProxy.java
+++ b/services/core/java/com/android/server/inputmethod/ZeroJankProxy.java
@@ -46,7 +46,6 @@
 import android.os.RemoteException;
 import android.os.ResultReceiver;
 import android.os.ShellCallback;
-import android.util.ExceptionUtils;
 import android.util.Slog;
 import android.view.WindowManager;
 import android.view.inputmethod.CursorAnchorInfo;
@@ -77,6 +76,7 @@
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
+import java.util.function.BooleanSupplier;
 
 /**
  * A proxy that processes all {@link IInputMethodManager} calls asynchronously.
@@ -86,10 +86,12 @@
 
     private final IInputMethodManager mInner;
     private final Executor mExecutor;
+    private final BooleanSupplier mIsInputShown;
 
-    ZeroJankProxy(Executor executor, IInputMethodManager inner) {
+    ZeroJankProxy(Executor executor, IInputMethodManager inner, BooleanSupplier isInputShown) {
         mInner = inner;
         mExecutor = executor;
+        mIsInputShown = isInputShown;
     }
 
     private void offload(ThrowingRunnable r) {
@@ -163,8 +165,19 @@
             int lastClickTooType, ResultReceiver resultReceiver,
             @SoftInputShowHideReason int reason)
             throws RemoteException {
-        offload(() -> mInner.showSoftInput(client, windowToken, statsToken, flags, lastClickTooType,
-                resultReceiver, reason));
+        offload(
+                () -> {
+                    if (!mInner.showSoftInput(
+                            client,
+                            windowToken,
+                            statsToken,
+                            flags,
+                            lastClickTooType,
+                            resultReceiver,
+                            reason)) {
+                        sendResultReceiverFailure(resultReceiver);
+                    }
+                });
         return true;
     }
 
@@ -173,11 +186,24 @@
             @Nullable ImeTracker.Token statsToken, @InputMethodManager.HideFlags int flags,
             ResultReceiver resultReceiver, @SoftInputShowHideReason int reason)
             throws RemoteException {
-        offload(() -> mInner.hideSoftInput(client, windowToken, statsToken, flags, resultReceiver,
-                reason));
+        offload(
+                () -> {
+                    if (!mInner.hideSoftInput(
+                            client, windowToken, statsToken, flags, resultReceiver, reason)) {
+                        sendResultReceiverFailure(resultReceiver);
+                    }
+                });
         return true;
     }
 
+    private void sendResultReceiverFailure(ResultReceiver resultReceiver) {
+        resultReceiver.send(
+                mIsInputShown.getAsBoolean()
+                        ? InputMethodManager.RESULT_UNCHANGED_SHOWN
+                        : InputMethodManager.RESULT_UNCHANGED_HIDDEN,
+                null);
+    }
+
     @Override
     @EnforcePermission(Manifest.permission.TEST_INPUT_METHOD)
     public void hideSoftInputFromServerForTest() throws RemoteException {
@@ -415,14 +441,17 @@
 
     private void sendOnStartInputResult(
             IInputMethodClient client, InputBindResult res, int startInputSeq) {
-        InputMethodManagerService service = (InputMethodManagerService) mInner;
-        final ClientState cs = service.getClientState(client);
-        if (cs != null && cs.mClient != null) {
-            cs.mClient.onStartInputResult(res, startInputSeq);
-        } else {
-            // client is unbound.
-            Slog.i(TAG, "Client that requested startInputOrWindowGainedFocus is no longer"
-                    + " bound. InputBindResult: " + res + " for startInputSeq: " + startInputSeq);
+        synchronized (ImfLock.class) {
+            InputMethodManagerService service = (InputMethodManagerService) mInner;
+            final ClientState cs = service.getClientState(client);
+            if (cs != null && cs.mClient != null) {
+                cs.mClient.onStartInputResult(res, startInputSeq);
+            } else {
+                // client is unbound.
+                Slog.i(TAG, "Client that requested startInputOrWindowGainedFocus is no longer"
+                        + " bound. InputBindResult: " + res + " for startInputSeq: "
+                        + startInputSeq);
+            }
         }
     }
 }
diff --git a/services/core/java/com/android/server/media/MediaSessionRecord.java b/services/core/java/com/android/server/media/MediaSessionRecord.java
index a9a8272..5b3934e 100644
--- a/services/core/java/com/android/server/media/MediaSessionRecord.java
+++ b/services/core/java/com/android/server/media/MediaSessionRecord.java
@@ -687,27 +687,20 @@
 
     private static String toVolumeControlTypeString(
             @VolumeProvider.ControlType int volumeControlType) {
-        switch (volumeControlType) {
-            case VOLUME_CONTROL_FIXED:
-                return "FIXED";
-            case VOLUME_CONTROL_RELATIVE:
-                return "RELATIVE";
-            case VOLUME_CONTROL_ABSOLUTE:
-                return "ABSOLUTE";
-            default:
-                return TextUtils.formatSimple("unknown(%d)", volumeControlType);
-        }
+        return switch (volumeControlType) {
+            case VOLUME_CONTROL_FIXED -> "FIXED";
+            case VOLUME_CONTROL_RELATIVE -> "RELATIVE";
+            case VOLUME_CONTROL_ABSOLUTE -> "ABSOLUTE";
+            default -> TextUtils.formatSimple("unknown(%d)", volumeControlType);
+        };
     }
 
     private static String toVolumeTypeString(@PlaybackInfo.PlaybackType int volumeType) {
-        switch (volumeType) {
-            case PLAYBACK_TYPE_LOCAL:
-                return "LOCAL";
-            case PLAYBACK_TYPE_REMOTE:
-                return "REMOTE";
-            default:
-                return TextUtils.formatSimple("unknown(%d)", volumeType);
-        }
+        return switch (volumeType) {
+            case PLAYBACK_TYPE_LOCAL -> "LOCAL";
+            case PLAYBACK_TYPE_REMOTE -> "REMOTE";
+            default -> TextUtils.formatSimple("unknown(%d)", volumeType);
+        };
     }
 
     @Override
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/ondeviceintelligence/OnDeviceIntelligenceManagerService.java b/services/core/java/com/android/server/ondeviceintelligence/OnDeviceIntelligenceManagerService.java
index 37023e1..953300a 100644
--- a/services/core/java/com/android/server/ondeviceintelligence/OnDeviceIntelligenceManagerService.java
+++ b/services/core/java/com/android/server/ondeviceintelligence/OnDeviceIntelligenceManagerService.java
@@ -163,7 +163,7 @@
             }
 
             @Override
-            public void getVersion(RemoteCallback remoteCallback) throws RemoteException {
+            public void getVersion(RemoteCallback remoteCallback) {
                 Slog.i(TAG, "OnDeviceIntelligenceManagerInternal getVersion");
                 Objects.requireNonNull(remoteCallback);
                 mContext.enforceCallingOrSelfPermission(
@@ -244,7 +244,7 @@
 
             @Override
             public void requestFeatureDownload(Feature feature,
-                    ICancellationSignal cancellationSignal,
+                    AndroidFuture cancellationSignalFuture,
                     IDownloadCallback downloadCallback) throws RemoteException {
                 Slog.i(TAG, "OnDeviceIntelligenceManagerInternal requestFeatureDownload");
                 Objects.requireNonNull(feature);
@@ -261,16 +261,17 @@
                 ensureRemoteIntelligenceServiceInitialized();
                 mRemoteOnDeviceIntelligenceService.run(
                         service -> service.requestFeatureDownload(Binder.getCallingUid(), feature,
-                                cancellationSignal,
+                                cancellationSignalFuture,
                                 downloadCallback));
             }
 
 
             @Override
             public void requestTokenInfo(Feature feature,
-                    Bundle request, ICancellationSignal cancellationSignal,
+                    Bundle request,
+                    AndroidFuture cancellationSignalFuture,
                     ITokenInfoCallback tokenInfoCallback) throws RemoteException {
-                Slog.i(TAG, "OnDeviceIntelligenceManagerInternal prepareFeatureProcessing");
+                Slog.i(TAG, "OnDeviceIntelligenceManagerInternal requestTokenInfo");
                 Objects.requireNonNull(feature);
                 Objects.requireNonNull(request);
                 Objects.requireNonNull(tokenInfoCallback);
@@ -285,10 +286,11 @@
                             PersistableBundle.EMPTY);
                 }
                 ensureRemoteInferenceServiceInitialized();
+
                 mRemoteInferenceService.run(
                         service -> service.requestTokenInfo(Binder.getCallingUid(), feature,
                                 request,
-                                cancellationSignal,
+                                cancellationSignalFuture,
                                 tokenInfoCallback));
             }
 
@@ -296,8 +298,8 @@
             public void processRequest(Feature feature,
                     Bundle request,
                     int requestType,
-                    ICancellationSignal cancellationSignal,
-                    IProcessingSignal processingSignal,
+                    AndroidFuture cancellationSignalFuture,
+                    AndroidFuture processingSignalFuture,
                     IResponseCallback responseCallback)
                     throws RemoteException {
                 Slog.i(TAG, "OnDeviceIntelligenceManagerInternal processRequest");
@@ -316,7 +318,7 @@
                 mRemoteInferenceService.run(
                         service -> service.processRequest(Binder.getCallingUid(), feature, request,
                                 requestType,
-                                cancellationSignal, processingSignal,
+                                cancellationSignalFuture, processingSignalFuture,
                                 responseCallback));
             }
 
@@ -324,8 +326,8 @@
             public void processRequestStreaming(Feature feature,
                     Bundle request,
                     int requestType,
-                    ICancellationSignal cancellationSignal,
-                    IProcessingSignal processingSignal,
+                    AndroidFuture cancellationSignalFuture,
+                    AndroidFuture processingSignalFuture,
                     IStreamingResponseCallback streamingCallback) throws RemoteException {
                 Slog.i(TAG, "OnDeviceIntelligenceManagerInternal processRequestStreaming");
                 Objects.requireNonNull(feature);
@@ -343,7 +345,7 @@
                 mRemoteInferenceService.run(
                         service -> service.processRequestStreaming(Binder.getCallingUid(), feature,
                                 request, requestType,
-                                cancellationSignal, processingSignal,
+                                cancellationSignalFuture, processingSignalFuture,
                                 streamingCallback));
             }
 
@@ -356,7 +358,7 @@
         };
     }
 
-    private void ensureRemoteIntelligenceServiceInitialized() throws RemoteException {
+    private void ensureRemoteIntelligenceServiceInitialized() {
         synchronized (mLock) {
             if (mRemoteOnDeviceIntelligenceService == null) {
                 String serviceName = getServiceNames()[0];
@@ -388,25 +390,15 @@
             public void updateProcessingState(
                     Bundle processingState,
                     IProcessingUpdateStatusCallback callback) {
-                try {
-                    ensureRemoteInferenceServiceInitialized();
-                    mRemoteInferenceService.run(
-                            service -> service.updateProcessingState(
-                                    processingState, callback));
-                } catch (RemoteException unused) {
-                    try {
-                        callback.onFailure(
-                                OnDeviceIntelligenceException.PROCESSING_UPDATE_STATUS_CONNECTION_FAILED,
-                                "Received failure invoking the remote processing service.");
-                    } catch (RemoteException ex) {
-                        Slog.w(TAG, "Failed to send failure status.", ex);
-                    }
-                }
+                ensureRemoteInferenceServiceInitialized();
+                mRemoteInferenceService.run(
+                        service -> service.updateProcessingState(
+                                processingState, callback));
             }
         };
     }
 
-    private void ensureRemoteInferenceServiceInitialized() throws RemoteException {
+    private void ensureRemoteInferenceServiceInitialized() {
         synchronized (mLock) {
             if (mRemoteInferenceService == null) {
                 String serviceName = getServiceNames()[1];
@@ -457,34 +449,38 @@
         };
     }
 
-    private static void validateServiceElevated(String serviceName, boolean checkIsolated)
-            throws RemoteException {
-        if (TextUtils.isEmpty(serviceName)) {
-            throw new IllegalArgumentException("Received null/empty service name : " + serviceName);
-        }
-        ComponentName serviceComponent = ComponentName.unflattenFromString(
-                serviceName);
-        ServiceInfo serviceInfo = AppGlobals.getPackageManager().getServiceInfo(
-                serviceComponent,
-                PackageManager.MATCH_DIRECT_BOOT_AWARE
-                        | PackageManager.MATCH_DIRECT_BOOT_UNAWARE, 0);
-        if (serviceInfo != null) {
-            if (!checkIsolated) {
-                checkServiceRequiresPermission(serviceInfo,
-                        Manifest.permission.BIND_ON_DEVICE_INTELLIGENCE_SERVICE);
-                return;
+    private void validateServiceElevated(String serviceName, boolean checkIsolated) {
+        try {
+            if (TextUtils.isEmpty(serviceName)) {
+                throw new IllegalStateException(
+                        "Remote service is not configured to complete the request");
             }
+            ComponentName serviceComponent = ComponentName.unflattenFromString(
+                    serviceName);
+            ServiceInfo serviceInfo = AppGlobals.getPackageManager().getServiceInfo(
+                    serviceComponent,
+                    PackageManager.MATCH_DIRECT_BOOT_AWARE
+                            | PackageManager.MATCH_DIRECT_BOOT_UNAWARE, 0);
+            if (serviceInfo != null) {
+                if (!checkIsolated) {
+                    checkServiceRequiresPermission(serviceInfo,
+                            Manifest.permission.BIND_ON_DEVICE_INTELLIGENCE_SERVICE);
+                    return;
+                }
 
-            checkServiceRequiresPermission(serviceInfo,
-                    Manifest.permission.BIND_ON_DEVICE_SANDBOXED_INFERENCE_SERVICE);
-            if (!isIsolatedService(serviceInfo)) {
-                throw new SecurityException(
-                        "Call required an isolated service, but the configured service: "
-                                + serviceName + ", is not isolated");
+                checkServiceRequiresPermission(serviceInfo,
+                        Manifest.permission.BIND_ON_DEVICE_SANDBOXED_INFERENCE_SERVICE);
+                if (!isIsolatedService(serviceInfo)) {
+                    throw new SecurityException(
+                            "Call required an isolated service, but the configured service: "
+                                    + serviceName + ", is not isolated");
+                }
+            } else {
+                throw new IllegalStateException(
+                        "Remote service is not configured to complete the request.");
             }
-        } else {
-            throw new RuntimeException(
-                    "Could not find service info for serviceName: " + serviceName);
+        } catch (RemoteException e) {
+            throw new IllegalStateException("Could not fetch service info for remote services", e);
         }
     }
 
@@ -542,7 +538,8 @@
                 Manifest.permission.USE_ON_DEVICE_INTELLIGENCE, TAG);
         synchronized (mLock) {
             mTemporaryServiceNames = componentNames;
-
+            mRemoteOnDeviceIntelligenceService = null;
+            mRemoteInferenceService = null;
             if (mTemporaryHandler == null) {
                 mTemporaryHandler = new Handler(Looper.getMainLooper(), null, true) {
                     @Override
diff --git a/services/core/java/com/android/server/pm/ComputerEngine.java b/services/core/java/com/android/server/pm/ComputerEngine.java
index 9480c8e..2005b17 100644
--- a/services/core/java/com/android/server/pm/ComputerEngine.java
+++ b/services/core/java/com/android/server/pm/ComputerEngine.java
@@ -137,6 +137,7 @@
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.Preconditions;
 import com.android.modules.utils.TypedXmlSerializer;
+import com.android.server.ondeviceintelligence.OnDeviceIntelligenceManagerInternal;
 import com.android.server.pm.dex.DexManager;
 import com.android.server.pm.dex.PackageDexUsage;
 import com.android.server.pm.parsing.PackageInfoUtils;
@@ -4353,9 +4354,8 @@
         if (Process.isSdkSandboxUid(uid)) {
             uid = getBaseSdkSandboxUid();
         }
-        if (Process.isIsolatedUid(uid)
-                && mPermissionManager.getHotwordDetectionServiceProvider() != null
-                && uid == mPermissionManager.getHotwordDetectionServiceProvider().getUid()) {
+        final int callingUserId = UserHandle.getUserId(callingUid);
+        if (isKnownIsolatedComputeApp(uid, callingUserId)) {
             try {
                 uid = getIsolatedOwner(uid);
             } catch (IllegalStateException e) {
@@ -4363,7 +4363,6 @@
                 Slog.wtf(TAG, "Expected isolated uid " + uid + " to have an owner", e);
             }
         }
-        final int callingUserId = UserHandle.getUserId(callingUid);
         final int appId = UserHandle.getAppId(uid);
         final Object obj = mSettings.getSettingBase(appId);
         if (obj instanceof SharedUserSetting) {
@@ -4399,9 +4398,7 @@
             if (Process.isSdkSandboxUid(uid)) {
                 uid = getBaseSdkSandboxUid();
             }
-            if (Process.isIsolatedUid(uid)
-                    && mPermissionManager.getHotwordDetectionServiceProvider() != null
-                    && uid == mPermissionManager.getHotwordDetectionServiceProvider().getUid()) {
+            if (isKnownIsolatedComputeApp(uid, callingUserId)) {
                 try {
                     uid = getIsolatedOwner(uid);
                 } catch (IllegalStateException e) {
@@ -5802,6 +5799,43 @@
         return getPackage(mService.getSdkSandboxPackageName()).getUid();
     }
 
+
+    private boolean isKnownIsolatedComputeApp(int uid, int callingUserId) {
+        if (!Process.isIsolatedUid(uid)) {
+            return false;
+        }
+        final boolean isHotword =
+                mPermissionManager.getHotwordDetectionServiceProvider() != null
+                        && uid
+                        == mPermissionManager.getHotwordDetectionServiceProvider().getUid();
+        if (isHotword) {
+            return true;
+        }
+        OnDeviceIntelligenceManagerInternal onDeviceIntelligenceManagerInternal =
+                mInjector.getLocalService(OnDeviceIntelligenceManagerInternal.class);
+        if (onDeviceIntelligenceManagerInternal == null) {
+            return false;
+        }
+
+        String onDeviceIntelligencePackage =
+                onDeviceIntelligenceManagerInternal.getRemoteServicePackageName();
+        if (onDeviceIntelligencePackage == null) {
+            return false;
+        }
+
+        try {
+            if (getIsolatedOwner(uid) == getPackageUid(onDeviceIntelligencePackage, 0,
+                    callingUserId)) {
+                return true;
+            }
+        } catch (IllegalStateException e) {
+            // If the owner uid doesn't exist, just use the current uid
+            Slog.wtf(TAG, "Expected isolated uid " + uid + " to have an owner", e);
+        }
+
+        return false;
+    }
+
     @Nullable
     @Override
     public SharedUserApi getSharedUser(int sharedUserAppId) {
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/policy/PhoneWindowManager.java b/services/core/java/com/android/server/policy/PhoneWindowManager.java
index 12a5892..f655455 100644
--- a/services/core/java/com/android/server/policy/PhoneWindowManager.java
+++ b/services/core/java/com/android/server/policy/PhoneWindowManager.java
@@ -530,6 +530,14 @@
     // TODO(b/178103325): Track sleep/requested sleep for every display.
     volatile boolean mRequestedOrSleepingDefaultDisplay;
 
+    /**
+     * This is used to check whether to invoke {@link #updateScreenOffSleepToken} when screen is
+     * turned off. E.g. if it is false when screen is turned off and the display is swapping, it
+     * is expected that the screen will be on in a short time. Then it is unnecessary to acquire
+     * screen-off-sleep-token, so it can avoid intermediate visibility or lifecycle changes.
+     */
+    volatile boolean mIsGoingToSleepDefaultDisplay;
+
     volatile boolean mRecentsVisible;
     volatile boolean mNavBarVirtualKeyHapticFeedbackEnabled = true;
     volatile boolean mPictureInPictureVisible;
@@ -5470,6 +5478,15 @@
         }
 
         mRequestedOrSleepingDefaultDisplay = true;
+        mIsGoingToSleepDefaultDisplay = true;
+
+        // In case startedGoingToSleep is called after screenTurnedOff (the source caller is in
+        // order but the methods run on different threads) and updateScreenOffSleepToken was
+        // skipped. Then acquire sleep token if screen was off.
+        if (!mDefaultDisplayPolicy.isScreenOnFully() && !mDefaultDisplayPolicy.isScreenOnEarly()
+                && com.android.window.flags.Flags.skipSleepingWhenSwitchingDisplay()) {
+            updateScreenOffSleepToken(true /* acquire */, false /* isSwappingDisplay */);
+        }
 
         if (mKeyguardDelegate != null) {
             mKeyguardDelegate.onStartedGoingToSleep(pmSleepReason);
@@ -5493,6 +5510,7 @@
         MetricsLogger.histogram(mContext, "screen_timeout", mLockScreenTimeout / 1000);
 
         mRequestedOrSleepingDefaultDisplay = false;
+        mIsGoingToSleepDefaultDisplay = false;
         mDefaultDisplayPolicy.setAwake(false);
 
         // We must get this work done here because the power manager will drop
@@ -5528,7 +5546,7 @@
         }
         EventLogTags.writeScreenToggled(1);
 
-
+        mIsGoingToSleepDefaultDisplay = false;
         mDefaultDisplayPolicy.setAwake(true);
 
         // Since goToSleep performs these functions synchronously, we must
@@ -5630,7 +5648,10 @@
         if (DEBUG_WAKEUP) Slog.i(TAG, "Display" + displayId + " turned off...");
 
         if (displayId == DEFAULT_DISPLAY) {
-            updateScreenOffSleepToken(true, isSwappingDisplay);
+            if (!isSwappingDisplay || mIsGoingToSleepDefaultDisplay
+                    || !com.android.window.flags.Flags.skipSleepingWhenSwitchingDisplay()) {
+                updateScreenOffSleepToken(true /* acquire */, isSwappingDisplay);
+            }
             mRequestedOrSleepingDefaultDisplay = false;
             mDefaultDisplayPolicy.screenTurnedOff();
             synchronized (mLock) {
diff --git a/services/core/java/com/android/server/power/hint/Android.bp b/services/core/java/com/android/server/power/hint/Android.bp
new file mode 100644
index 0000000..8a98de6
--- /dev/null
+++ b/services/core/java/com/android/server/power/hint/Android.bp
@@ -0,0 +1,12 @@
+aconfig_declarations {
+    name: "power_hint_flags",
+    package: "com.android.server.power.hint",
+    srcs: [
+        "flags.aconfig",
+    ],
+}
+
+java_aconfig_library {
+    name: "power_hint_flags_lib",
+    aconfig_declarations: "power_hint_flags",
+}
diff --git a/services/core/java/com/android/server/power/hint/HintManagerService.java b/services/core/java/com/android/server/power/hint/HintManagerService.java
index aa1a41e..3f1b1c1 100644
--- a/services/core/java/com/android/server/power/hint/HintManagerService.java
+++ b/services/core/java/com/android/server/power/hint/HintManagerService.java
@@ -17,6 +17,7 @@
 package com.android.server.power.hint;
 
 import static com.android.internal.util.ConcurrentUtils.DIRECT_EXECUTOR;
+import static com.android.server.power.hint.Flags.powerhintThreadCleanup;
 
 import android.annotation.NonNull;
 import android.app.ActivityManager;
@@ -26,9 +27,12 @@
 import android.content.Context;
 import android.hardware.power.WorkDuration;
 import android.os.Binder;
+import android.os.Handler;
 import android.os.IBinder;
 import android.os.IHintManager;
 import android.os.IHintSession;
+import android.os.Looper;
+import android.os.Message;
 import android.os.PerformanceHintManager;
 import android.os.Process;
 import android.os.RemoteException;
@@ -36,6 +40,8 @@
 import android.text.TextUtils;
 import android.util.ArrayMap;
 import android.util.ArraySet;
+import android.util.IntArray;
+import android.util.Slog;
 import android.util.SparseIntArray;
 import android.util.StatsEvent;
 
@@ -46,20 +52,31 @@
 import com.android.internal.util.Preconditions;
 import com.android.server.FgThread;
 import com.android.server.LocalServices;
+import com.android.server.ServiceThread;
 import com.android.server.SystemService;
 import com.android.server.utils.Slogf;
 
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 /** An hint service implementation that runs in System Server process. */
 public final class HintManagerService extends SystemService {
     private static final String TAG = "HintManagerService";
     private static final boolean DEBUG = false;
+
+    private static final int EVENT_CLEAN_UP_UID = 3;
+    @VisibleForTesting  static final int CLEAN_UP_UID_DELAY_MILLIS = 1000;
+
+
     @VisibleForTesting final long mHintSessionPreferredRate;
 
     // Multi-level map storing all active AppHintSessions.
@@ -73,9 +90,15 @@
     /** Lock to protect HAL handles and listen list. */
     private final Object mLock = new Object();
 
+    @GuardedBy("mNonIsolatedTidsLock")
+    private final Map<Integer, Set<Long>> mNonIsolatedTids;
+
+    private final Object mNonIsolatedTidsLock = new Object();
+
     @VisibleForTesting final MyUidObserver mUidObserver;
 
     private final NativeWrapper mNativeWrapper;
+    private final CleanUpHandler mCleanUpHandler;
 
     private final ActivityManagerInternal mAmInternal;
 
@@ -94,6 +117,13 @@
     HintManagerService(Context context, Injector injector) {
         super(context);
         mContext = context;
+        if (powerhintThreadCleanup()) {
+            mCleanUpHandler = new CleanUpHandler(createCleanUpThread().getLooper());
+            mNonIsolatedTids = new HashMap<>();
+        } else {
+            mCleanUpHandler = null;
+            mNonIsolatedTids = null;
+        }
         mActiveSessions = new ArrayMap<>();
         mNativeWrapper = injector.createNativeWrapper();
         mNativeWrapper.halInit();
@@ -103,6 +133,13 @@
                 LocalServices.getService(ActivityManagerInternal.class));
     }
 
+    private ServiceThread createCleanUpThread() {
+        final ServiceThread handlerThread = new ServiceThread(TAG,
+                Process.THREAD_PRIORITY_LOWEST, true /*allowIo*/);
+        handlerThread.start();
+        return handlerThread;
+    }
+
     @VisibleForTesting
     static class Injector {
         NativeWrapper createNativeWrapper() {
@@ -306,7 +343,18 @@
         public void onUidStateChanged(int uid, int procState, long procStateSeq, int capability) {
             FgThread.getHandler().post(() -> {
                 synchronized (mCacheLock) {
-                    mProcStatesCache.put(uid, procState);
+                    if (powerhintThreadCleanup()) {
+                        final boolean before = isUidForeground(uid);
+                        mProcStatesCache.put(uid, procState);
+                        final boolean after = isUidForeground(uid);
+                        if (before != after) {
+                            final Message msg = mCleanUpHandler.obtainMessage(EVENT_CLEAN_UP_UID,
+                                    uid);
+                            mCleanUpHandler.sendMessageDelayed(msg, CLEAN_UP_UID_DELAY_MILLIS);
+                        }
+                    } else {
+                        mProcStatesCache.put(uid, procState);
+                    }
                 }
                 boolean shouldAllowUpdate = isUidForeground(uid);
                 synchronized (mLock) {
@@ -314,9 +362,10 @@
                     if (tokenMap == null) {
                         return;
                     }
-                    for (ArraySet<AppHintSession> sessionSet : tokenMap.values()) {
-                        for (AppHintSession s : sessionSet) {
-                            s.onProcStateChanged(shouldAllowUpdate);
+                    for (int i = tokenMap.size() - 1; i >= 0; i--) {
+                        final ArraySet<AppHintSession> sessionSet = tokenMap.valueAt(i);
+                        for (int j = sessionSet.size() - 1; j >= 0; j--) {
+                            sessionSet.valueAt(j).onProcStateChanged(shouldAllowUpdate);
                         }
                     }
                 }
@@ -324,52 +373,237 @@
         }
     }
 
+    final class CleanUpHandler extends Handler {
+        // status of processed tid used for caching
+        private static final int TID_NOT_CHECKED = 0;
+        private static final int TID_PASSED_CHECK = 1;
+        private static final int TID_EXITED = 2;
+
+        CleanUpHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            if (msg.what == EVENT_CLEAN_UP_UID) {
+                if (hasEqualMessages(msg.what, msg.obj)) {
+                    removeEqualMessages(msg.what, msg.obj);
+                    final Message newMsg = obtainMessage(msg.what, msg.obj);
+                    sendMessageDelayed(newMsg, CLEAN_UP_UID_DELAY_MILLIS);
+                    return;
+                }
+                final int uid = (int) msg.obj;
+                boolean isForeground = mUidObserver.isUidForeground(uid);
+                // store all sessions in a list and release the global lock
+                // we don't need to worry about stale data or racing as the session is synchronized
+                // itself and will perform its own closed status check in setThreads call
+                final List<AppHintSession> sessions;
+                synchronized (mLock) {
+                    final ArrayMap<IBinder, ArraySet<AppHintSession>> tokenMap =
+                            mActiveSessions.get(uid);
+                    if (tokenMap == null || tokenMap.isEmpty()) {
+                        return;
+                    }
+                    sessions = new ArrayList<>(tokenMap.size());
+                    for (int i = tokenMap.size() - 1; i >= 0; i--) {
+                        final ArraySet<AppHintSession> set = tokenMap.valueAt(i);
+                        for (int j = set.size() - 1; j >= 0; j--) {
+                            sessions.add(set.valueAt(j));
+                        }
+                    }
+                }
+                final long[] durationList = new long[sessions.size()];
+                final int[] invalidTidCntList = new int[sessions.size()];
+                final SparseIntArray checkedTids = new SparseIntArray();
+                int[] totalTidCnt = new int[1];
+                for (int i = sessions.size() - 1; i >= 0; i--) {
+                    final AppHintSession session = sessions.get(i);
+                    final long start = System.nanoTime();
+                    try {
+                        final int invalidCnt = cleanUpSession(session, checkedTids, totalTidCnt);
+                        final long elapsed = System.nanoTime() - start;
+                        invalidTidCntList[i] = invalidCnt;
+                        durationList[i] = elapsed;
+                    } catch (Exception e) {
+                        Slog.e(TAG, "Failed to clean up session " + session.mHalSessionPtr
+                                + " for UID " + session.mUid);
+                    }
+                }
+                logCleanUpMetrics(uid, invalidTidCntList, durationList, sessions.size(),
+                        totalTidCnt[0], isForeground);
+            }
+        }
+
+        private void logCleanUpMetrics(int uid, int[] count, long[] durationNsList, int sessionCnt,
+                int totalTidCnt, boolean isForeground) {
+            int maxInvalidTidCnt = Integer.MIN_VALUE;
+            int totalInvalidTidCnt = 0;
+            for (int i = 0; i < count.length; i++) {
+                totalInvalidTidCnt += count[i];
+                maxInvalidTidCnt = Math.max(maxInvalidTidCnt, count[i]);
+            }
+            if (DEBUG || totalInvalidTidCnt > 0) {
+                Arrays.sort(durationNsList);
+                long totalDurationNs = 0;
+                for (int i = 0; i < durationNsList.length; i++) {
+                    totalDurationNs += durationNsList[i];
+                }
+                int totalDurationUs = (int) TimeUnit.NANOSECONDS.toMicros(totalDurationNs);
+                int maxDurationUs = (int) TimeUnit.NANOSECONDS.toMicros(
+                        durationNsList[durationNsList.length - 1]);
+                int minDurationUs = (int) TimeUnit.NANOSECONDS.toMicros(durationNsList[0]);
+                int avgDurationUs = (int) TimeUnit.NANOSECONDS.toMicros(
+                        totalDurationNs / durationNsList.length);
+                int th90DurationUs = (int) TimeUnit.NANOSECONDS.toMicros(
+                        durationNsList[(int) (durationNsList.length * 0.9)]);
+                Slog.d(TAG,
+                        "Invalid tid found for UID" + uid + " in " + totalDurationUs + "us:\n\t"
+                                + "count("
+                                + " session: " + sessionCnt
+                                + " totalTid: " + totalTidCnt
+                                + " maxInvalidTid: " + maxInvalidTidCnt
+                                + " totalInvalidTid: " + totalInvalidTidCnt + ")\n\t"
+                                + "time per session("
+                                + " min: " + minDurationUs + "us"
+                                + " max: " + maxDurationUs + "us"
+                                + " avg: " + avgDurationUs + "us"
+                                + " 90%: " + th90DurationUs + "us" + ")\n\t"
+                                + "isForeground: " + isForeground);
+            }
+        }
+
+        // This will check if each TID currently linked to the session still exists. If it's
+        // previously registered as not an isolated process, then it will run tkill(pid, tid, 0) to
+        // verify that it's still running under the same pid. Otherwise, it will run
+        // kill(tid, 0) to only check if it exists. The result will be cached in checkedTids
+        // map with tid as the key and checked status as value.
+        public int cleanUpSession(AppHintSession session, SparseIntArray checkedTids, int[] total) {
+            if (session.isClosed()) {
+                return 0;
+            }
+            final int pid = session.mPid;
+            final int[] tids = session.getTidsInternal();
+            if (total != null && total.length == 1) {
+                total[0] += tids.length;
+            }
+            final IntArray filtered = new IntArray(tids.length);
+            for (int i = 0; i < tids.length; i++) {
+                int tid = tids[i];
+                if (checkedTids.get(tid, 0) != TID_NOT_CHECKED) {
+                    if (checkedTids.get(tid) == TID_PASSED_CHECK) {
+                        filtered.add(tid);
+                    }
+                    continue;
+                }
+                // if it was registered as a non-isolated then we perform more restricted check
+                final boolean isNotIsolated;
+                synchronized (mNonIsolatedTidsLock) {
+                    isNotIsolated = mNonIsolatedTids.containsKey(tid);
+                }
+                try {
+                    if (isNotIsolated) {
+                        Process.checkTid(pid, tid);
+                    } else {
+                        Process.checkPid(tid);
+                    }
+                    checkedTids.put(tid, TID_PASSED_CHECK);
+                    filtered.add(tid);
+                } catch (NoSuchElementException e) {
+                    checkedTids.put(tid, TID_EXITED);
+                } catch (Exception e) {
+                    Slog.w(TAG, "Unexpected exception when checking TID " + tid + " under PID "
+                            + pid + "(isolated: " + !isNotIsolated + ")", e);
+                    // if anything unexpected happens then we keep it, but don't store it as checked
+                    filtered.add(tid);
+                }
+            }
+            final int diff = tids.length - filtered.size();
+            if (diff > 0) {
+                synchronized (session) {
+                    // in case thread list is updated during the cleanup then we skip updating
+                    // the session but just return the number for reporting purpose
+                    final int[] newTids = session.getTidsInternal();
+                    if (newTids.length != tids.length) {
+                        Slog.d(TAG, "Skipped cleaning up the session as new tids are added");
+                        return diff;
+                    }
+                    Arrays.sort(newTids);
+                    Arrays.sort(tids);
+                    if (!Arrays.equals(newTids, tids)) {
+                        Slog.d(TAG, "Skipped cleaning up the session as new tids are updated");
+                        return diff;
+                    }
+                    Slog.d(TAG, "Cleaned up " + diff + " invalid tids for session "
+                            + session.mHalSessionPtr + " with UID " + session.mUid + "\n\t"
+                            + "before: " + Arrays.toString(tids) + "\n\t"
+                            + "after: " + filtered);
+                    final int[] filteredTids = filtered.toArray();
+                    if (filteredTids.length == 0) {
+                        session.mShouldForcePause = true;
+                        if (session.mUpdateAllowed) {
+                            session.pause();
+                        }
+                    } else {
+                        session.setThreadsInternal(filteredTids, false);
+                    }
+                }
+            }
+            return diff;
+        }
+    }
+
     @VisibleForTesting
     IHintManager.Stub getBinderServiceInstance() {
         return mService;
     }
 
     // returns the first invalid tid or null if not found
-    private Integer checkTidValid(int uid, int tgid, int [] tids) {
+    private Integer checkTidValid(int uid, int tgid, int [] tids, IntArray nonIsolated) {
         // Make sure all tids belongs to the same UID (including isolated UID),
         // tids can belong to different application processes.
         List<Integer> isolatedPids = null;
-        for (int threadId : tids) {
+        for (int i = 0; i < tids.length; i++) {
+            int tid = tids[i];
             final String[] procStatusKeys = new String[] {
                     "Uid:",
                     "Tgid:"
             };
             long[] output = new long[procStatusKeys.length];
-            Process.readProcLines("/proc/" + threadId + "/status", procStatusKeys, output);
+            Process.readProcLines("/proc/" + tid + "/status", procStatusKeys, output);
             int uidOfThreadId = (int) output[0];
             int pidOfThreadId = (int) output[1];
 
-            // use PID check for isolated processes, use UID check for non-isolated processes.
-            if (pidOfThreadId == tgid || uidOfThreadId == uid) {
+            // use PID check for non-isolated processes
+            if (nonIsolated != null && pidOfThreadId == tgid) {
+                nonIsolated.add(tid);
+                continue;
+            }
+            // use UID check for isolated processes.
+            if (uidOfThreadId == uid) {
                 continue;
             }
             // Only call into AM if the tid is either isolated or invalid
             if (isolatedPids == null) {
                 // To avoid deadlock, do not call into AMS if the call is from system.
                 if (uid == Process.SYSTEM_UID) {
-                    return threadId;
+                    return tid;
                 }
                 isolatedPids = mAmInternal.getIsolatedProcesses(uid);
                 if (isolatedPids == null) {
-                    return threadId;
+                    return tid;
                 }
             }
             if (isolatedPids.contains(pidOfThreadId)) {
                 continue;
             }
-            return threadId;
+            return tid;
         }
         return null;
     }
 
     private String formatTidCheckErrMsg(int callingUid, int[] tids, Integer invalidTid) {
         return "Tid" + invalidTid + " from list " + Arrays.toString(tids)
-                + " doesn't belong to the calling application" + callingUid;
+                + " doesn't belong to the calling application " + callingUid;
     }
 
     @VisibleForTesting
@@ -387,7 +621,10 @@
             final int callingTgid = Process.getThreadGroupLeader(Binder.getCallingPid());
             final long identity = Binder.clearCallingIdentity();
             try {
-                final Integer invalidTid = checkTidValid(callingUid, callingTgid, tids);
+                final IntArray nonIsolated = powerhintThreadCleanup() ? new IntArray(tids.length)
+                        : null;
+                final Integer invalidTid = checkTidValid(callingUid, callingTgid, tids,
+                        nonIsolated);
                 if (invalidTid != null) {
                     final String errMsg = formatTidCheckErrMsg(callingUid, tids, invalidTid);
                     Slogf.w(TAG, errMsg);
@@ -396,6 +633,14 @@
 
                 long halSessionPtr = mNativeWrapper.halCreateHintSession(callingTgid, callingUid,
                         tids, durationNanos);
+                if (powerhintThreadCleanup()) {
+                    synchronized (mNonIsolatedTidsLock) {
+                        for (int i = nonIsolated.size() - 1; i >= 0; i--) {
+                            mNonIsolatedTids.putIfAbsent(nonIsolated.get(i), new ArraySet<>());
+                            mNonIsolatedTids.get(nonIsolated.get(i)).add(halSessionPtr);
+                        }
+                    }
+                }
                 if (halSessionPtr == 0) {
                     return null;
                 }
@@ -482,6 +727,7 @@
         protected boolean mUpdateAllowed;
         protected int[] mNewThreadIds;
         protected boolean mPowerEfficient;
+        protected boolean mShouldForcePause;
 
         private enum SessionModes {
             POWER_EFFICIENCY,
@@ -498,6 +744,7 @@
             mTargetDurationNanos = durationNanos;
             mUpdateAllowed = true;
             mPowerEfficient = false;
+            mShouldForcePause = false;
             final boolean allowed = mUidObserver.isUidForeground(mUid);
             updateHintAllowed(allowed);
             try {
@@ -511,7 +758,7 @@
         @VisibleForTesting
         boolean updateHintAllowed(boolean allowed) {
             synchronized (this) {
-                if (allowed && !mUpdateAllowed) resume();
+                if (allowed && !mUpdateAllowed && !mShouldForcePause) resume();
                 if (!allowed && mUpdateAllowed) pause();
                 mUpdateAllowed = allowed;
                 return mUpdateAllowed;
@@ -521,7 +768,7 @@
         @Override
         public void updateTargetWorkDuration(long targetDurationNanos) {
             synchronized (this) {
-                if (mHalSessionPtr == 0 || !mUpdateAllowed) {
+                if (mHalSessionPtr == 0 || !mUpdateAllowed || mShouldForcePause) {
                     return;
                 }
                 Preconditions.checkArgument(targetDurationNanos > 0, "Expected"
@@ -534,7 +781,7 @@
         @Override
         public void reportActualWorkDuration(long[] actualDurationNanos, long[] timeStampNanos) {
             synchronized (this) {
-                if (mHalSessionPtr == 0 || !mUpdateAllowed) {
+                if (mHalSessionPtr == 0 || !mUpdateAllowed || mShouldForcePause) {
                     return;
                 }
                 Preconditions.checkArgument(actualDurationNanos.length != 0, "the count"
@@ -581,12 +828,25 @@
                 if (sessionSet.isEmpty()) tokenMap.remove(mToken);
                 if (tokenMap.isEmpty()) mActiveSessions.remove(mUid);
             }
+            if (powerhintThreadCleanup()) {
+                synchronized (mNonIsolatedTidsLock) {
+                    final int[] tids = getTidsInternal();
+                    for (int tid : tids) {
+                        if (mNonIsolatedTids.containsKey(tid)) {
+                            mNonIsolatedTids.get(tid).remove(mHalSessionPtr);
+                            if (mNonIsolatedTids.get(tid).isEmpty()) {
+                                mNonIsolatedTids.remove(tid);
+                            }
+                        }
+                    }
+                }
+            }
         }
 
         @Override
         public void sendHint(@PerformanceHintManager.Session.Hint int hint) {
             synchronized (this) {
-                if (mHalSessionPtr == 0 || !mUpdateAllowed) {
+                if (mHalSessionPtr == 0 || !mUpdateAllowed || mShouldForcePause) {
                     return;
                 }
                 Preconditions.checkArgument(hint >= 0, "the hint ID value should be"
@@ -596,33 +856,60 @@
         }
 
         public void setThreads(@NonNull int[] tids) {
+            setThreadsInternal(tids, true);
+        }
+
+        private void setThreadsInternal(int[] tids, boolean checkTid) {
+            if (tids.length == 0) {
+                throw new IllegalArgumentException("Thread id list can't be empty.");
+            }
+
             synchronized (this) {
                 if (mHalSessionPtr == 0) {
                     return;
                 }
-                if (tids.length == 0) {
-                    throw new IllegalArgumentException("Thread id list can't be empty.");
-                }
-                final int callingUid = Binder.getCallingUid();
-                final int callingTgid = Process.getThreadGroupLeader(Binder.getCallingPid());
-                final long identity = Binder.clearCallingIdentity();
-                try {
-                    final Integer invalidTid = checkTidValid(callingUid, callingTgid, tids);
-                    if (invalidTid != null) {
-                        final String errMsg = formatTidCheckErrMsg(callingUid, tids, invalidTid);
-                        Slogf.w(TAG, errMsg);
-                        throw new SecurityException(errMsg);
-                    }
-                } finally {
-                    Binder.restoreCallingIdentity(identity);
-                }
                 if (!mUpdateAllowed) {
                     Slogf.v(TAG, "update hint not allowed, storing tids.");
                     mNewThreadIds = tids;
+                    mShouldForcePause = false;
                     return;
                 }
+                if (checkTid) {
+                    final int callingUid = Binder.getCallingUid();
+                    final int callingTgid = Process.getThreadGroupLeader(Binder.getCallingPid());
+                    final IntArray nonIsolated = powerhintThreadCleanup() ? new IntArray() : null;
+                    final long identity = Binder.clearCallingIdentity();
+                    try {
+                        final Integer invalidTid = checkTidValid(callingUid, callingTgid, tids,
+                                nonIsolated);
+                        if (invalidTid != null) {
+                            final String errMsg = formatTidCheckErrMsg(callingUid, tids,
+                                    invalidTid);
+                            Slogf.w(TAG, errMsg);
+                            throw new SecurityException(errMsg);
+                        }
+                        if (powerhintThreadCleanup()) {
+                            synchronized (mNonIsolatedTidsLock) {
+                                for (int i = nonIsolated.size() - 1; i >= 0; i--) {
+                                    mNonIsolatedTids.putIfAbsent(nonIsolated.get(i),
+                                            new ArraySet<>());
+                                    mNonIsolatedTids.get(nonIsolated.get(i)).add(mHalSessionPtr);
+                                }
+                            }
+                        }
+                    } finally {
+                        Binder.restoreCallingIdentity(identity);
+                    }
+                }
                 mNativeWrapper.halSetThreads(mHalSessionPtr, tids);
                 mThreadIds = tids;
+                mNewThreadIds = null;
+                // if the update is allowed but the session is force paused by tid clean up, then
+                // it's waiting for this tid update to resume
+                if (mShouldForcePause) {
+                    resume();
+                    mShouldForcePause = false;
+                }
             }
         }
 
@@ -632,10 +919,24 @@
             }
         }
 
+        @VisibleForTesting
+        int[] getTidsInternal() {
+            synchronized (this) {
+                return mNewThreadIds != null ? Arrays.copyOf(mNewThreadIds, mNewThreadIds.length)
+                        : Arrays.copyOf(mThreadIds, mThreadIds.length);
+            }
+        }
+
+        boolean isClosed() {
+            synchronized (this) {
+                return mHalSessionPtr == 0;
+            }
+        }
+
         @Override
         public void setMode(int mode, boolean enabled) {
             synchronized (this) {
-                if (mHalSessionPtr == 0 || !mUpdateAllowed) {
+                if (mHalSessionPtr == 0 || !mUpdateAllowed || mShouldForcePause) {
                     return;
                 }
                 Preconditions.checkArgument(mode >= 0, "the mode Id value should be"
@@ -650,13 +951,13 @@
         @Override
         public void reportActualWorkDuration2(WorkDuration[] workDurations) {
             synchronized (this) {
-                if (mHalSessionPtr == 0 || !mUpdateAllowed) {
+                if (mHalSessionPtr == 0 || !mUpdateAllowed || mShouldForcePause) {
                     return;
                 }
                 Preconditions.checkArgument(workDurations.length != 0, "the count"
                         + " of work durations shouldn't be 0.");
-                for (WorkDuration workDuration : workDurations) {
-                    validateWorkDuration(workDuration);
+                for (int i = 0; i < workDurations.length; i++) {
+                    validateWorkDuration(workDurations[i]);
                 }
                 mNativeWrapper.halReportActualWorkDuration(mHalSessionPtr, workDurations);
             }
@@ -743,6 +1044,7 @@
                 pw.println(prefix + "SessionTIDs: " + Arrays.toString(mThreadIds));
                 pw.println(prefix + "SessionTargetDurationNanos: " + mTargetDurationNanos);
                 pw.println(prefix + "SessionAllowed: " + mUpdateAllowed);
+                pw.println(prefix + "SessionForcePaused: " + mShouldForcePause);
                 pw.println(prefix + "PowerEfficient: " + (mPowerEfficient ? "true" : "false"));
             }
         }
diff --git a/services/core/java/com/android/server/power/hint/flags.aconfig b/services/core/java/com/android/server/power/hint/flags.aconfig
new file mode 100644
index 0000000..f4afcb1
--- /dev/null
+++ b/services/core/java/com/android/server/power/hint/flags.aconfig
@@ -0,0 +1,8 @@
+package: "com.android.server.power.hint"
+
+flag {
+    name: "powerhint_thread_cleanup"
+    namespace: "game"
+    description: "Feature flag for auto PowerHintSession dead thread cleanup"
+    bug: "296160319"
+}
diff --git a/services/core/java/com/android/server/wm/ActivityTaskManagerService.java b/services/core/java/com/android/server/wm/ActivityTaskManagerService.java
index 060f1c8..6af496f 100644
--- a/services/core/java/com/android/server/wm/ActivityTaskManagerService.java
+++ b/services/core/java/com/android/server/wm/ActivityTaskManagerService.java
@@ -5682,29 +5682,6 @@
         throw e;
     }
 
-    /**
-     * Sets the corresponding {@link DisplayArea} information for the process global
-     * configuration. To be called when we need to show IME on a different {@link DisplayArea}
-     * or display.
-     *
-     * @param pid The process id associated with the IME window.
-     * @param imeContainer The DisplayArea that contains the IME window.
-     */
-    void onImeWindowSetOnDisplayArea(final int pid, @NonNull final DisplayArea imeContainer) {
-        if (pid == MY_PID || pid < 0) {
-            ProtoLog.w(WM_DEBUG_CONFIGURATION,
-                    "Trying to update display configuration for system/invalid process.");
-            return;
-        }
-        final WindowProcessController process = mProcessMap.getProcess(pid);
-        if (process == null) {
-            ProtoLog.w(WM_DEBUG_CONFIGURATION, "Trying to update display "
-                    + "configuration for invalid process, pid=%d", pid);
-            return;
-        }
-        process.registerDisplayAreaConfigurationListener(imeContainer);
-    }
-
     @Override
     public void setRunningRemoteTransitionDelegate(IApplicationThread delegate) {
         final TransitionController controller = getTransitionController();
diff --git a/services/core/java/com/android/server/wm/DisplayContent.java b/services/core/java/com/android/server/wm/DisplayContent.java
index eb1f052..46d4ce4 100644
--- a/services/core/java/com/android/server/wm/DisplayContent.java
+++ b/services/core/java/com/android/server/wm/DisplayContent.java
@@ -4171,11 +4171,6 @@
      */
     void setInputMethodWindowLocked(WindowState win) {
         mInputMethodWindow = win;
-        // Update display configuration for IME process.
-        if (mInputMethodWindow != null) {
-            final int imePid = mInputMethodWindow.mSession.mPid;
-            mAtmService.onImeWindowSetOnDisplayArea(imePid, mImeWindowsContainer);
-        }
         mInsetsStateController.getImeSourceProvider().setWindowContainer(win,
                 mDisplayPolicy.getImeSourceFrameProvider(), null);
         computeImeTarget(true /* updateImeTarget */);
diff --git a/services/core/java/com/android/server/wm/Session.java b/services/core/java/com/android/server/wm/Session.java
index 30134d8..e157318 100644
--- a/services/core/java/com/android/server/wm/Session.java
+++ b/services/core/java/com/android/server/wm/Session.java
@@ -283,14 +283,14 @@
             int lastSyncSeqId, ClientWindowFrames outFrames,
             MergedConfiguration mergedConfiguration, SurfaceControl outSurfaceControl,
             InsetsState outInsetsState, InsetsSourceControl.Array outActiveControls,
-            Bundle outSyncSeqIdBundle) {
+            Bundle outBundle) {
         if (false) Slog.d(TAG_WM, ">>>>>> ENTERED relayout from "
                 + Binder.getCallingPid());
         Trace.traceBegin(TRACE_TAG_WINDOW_MANAGER, mRelayoutTag);
         int res = mService.relayoutWindow(this, window, attrs,
                 requestedWidth, requestedHeight, viewFlags, flags, seq,
                 lastSyncSeqId, outFrames, mergedConfiguration, outSurfaceControl, outInsetsState,
-                outActiveControls, outSyncSeqIdBundle);
+                outActiveControls, outBundle);
         Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
         if (false) Slog.d(TAG_WM, "<<<<<< EXITING relayout to "
                 + Binder.getCallingPid());
diff --git a/services/core/java/com/android/server/wm/Task.java b/services/core/java/com/android/server/wm/Task.java
index 4c282bd..18d2718 100644
--- a/services/core/java/com/android/server/wm/Task.java
+++ b/services/core/java/com/android/server/wm/Task.java
@@ -6822,8 +6822,8 @@
      * A decor surface is requested by a {@link TaskFragmentOrganizer} and is placed below children
      * windows in the Task except for own Activities and TaskFragments in fully trusted mode. The
      * decor surface is created and shared with the client app with
-     * {@link android.window.TaskFragmentOperation#OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE} and
-     * be removed with
+     * {@link android.window.TaskFragmentOperation#OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE}
+     * and be removed with
      * {@link android.window.TaskFragmentOperation#OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE}.
      *
      * When boosted with
diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java
index c70c528..0effa6c 100644
--- a/services/core/java/com/android/server/wm/WindowManagerService.java
+++ b/services/core/java/com/android/server/wm/WindowManagerService.java
@@ -304,6 +304,7 @@
 import android.view.displayhash.DisplayHash;
 import android.view.displayhash.VerifiedDisplayHash;
 import android.view.inputmethod.ImeTracker;
+import android.window.ActivityWindowInfo;
 import android.window.AddToSurfaceSyncGroupResult;
 import android.window.ClientWindowFrames;
 import android.window.IGlobalDragListener;
@@ -2247,7 +2248,7 @@
             int lastSyncSeqId, ClientWindowFrames outFrames,
             MergedConfiguration outMergedConfiguration, SurfaceControl outSurfaceControl,
             InsetsState outInsetsState, InsetsSourceControl.Array outActiveControls,
-            Bundle outSyncIdBundle) {
+            Bundle outBundle) {
         if (outActiveControls != null) {
             outActiveControls.set(null);
         }
@@ -2578,6 +2579,13 @@
             if (outFrames != null && outMergedConfiguration != null) {
                 win.fillClientWindowFramesAndConfiguration(outFrames, outMergedConfiguration,
                         false /* useLatestConfig */, shouldRelayout);
+                if (Flags.activityWindowInfoFlag() && outBundle != null
+                        && win.mActivityRecord != null) {
+                    final ActivityWindowInfo activityWindowInfo = win.mActivityRecord
+                            .getActivityWindowInfo();
+                    outBundle.putParcelable(IWindowSession.KEY_RELAYOUT_BUNDLE_ACTIVITY_WINDOW_INFO,
+                            activityWindowInfo);
+                }
 
                 // Set resize-handled here because the values are sent back to the client.
                 win.onResizeHandled();
@@ -2607,7 +2615,7 @@
                         win.isVisible() /* visible */, false /* removed */);
             }
 
-            if (outSyncIdBundle != null) {
+            if (outBundle != null) {
                 final int maybeSyncSeqId;
                 if (win.syncNextBuffer() && viewVisibility == View.VISIBLE
                         && win.mSyncSeqId > lastSyncSeqId) {
@@ -2616,7 +2624,7 @@
                 } else {
                     maybeSyncSeqId = -1;
                 }
-                outSyncIdBundle.putInt("seqid", maybeSyncSeqId);
+                outBundle.putInt(IWindowSession.KEY_RELAYOUT_BUNDLE_SEQID, maybeSyncSeqId);
             }
 
             if (configChanged) {
diff --git a/services/core/java/com/android/server/wm/WindowOrganizerController.java b/services/core/java/com/android/server/wm/WindowOrganizerController.java
index d967cde..14ec41f 100644
--- a/services/core/java/com/android/server/wm/WindowOrganizerController.java
+++ b/services/core/java/com/android/server/wm/WindowOrganizerController.java
@@ -23,7 +23,7 @@
 import static android.view.Display.DEFAULT_DISPLAY;
 import static android.window.TaskFragmentOperation.OP_TYPE_CLEAR_ADJACENT_TASK_FRAGMENTS;
 import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_TASK_FRAGMENT;
-import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE;
+import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE;
 import static android.window.TaskFragmentOperation.OP_TYPE_DELETE_TASK_FRAGMENT;
 import static android.window.TaskFragmentOperation.OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE;
 import static android.window.TaskFragmentOperation.OP_TYPE_REORDER_TO_BOTTOM_OF_TASK;
@@ -1558,7 +1558,7 @@
                 }
                 break;
             }
-            case OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE: {
+            case OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE: {
                 taskFragment.getTask().moveOrCreateDecorSurfaceFor(taskFragment);
                 break;
             }
diff --git a/services/core/jni/com_android_server_input_InputManagerService.cpp b/services/core/jni/com_android_server_input_InputManagerService.cpp
index 610fcb5..70224db 100644
--- a/services/core/jni/com_android_server_input_InputManagerService.cpp
+++ b/services/core/jni/com_android_server_input_InputManagerService.cpp
@@ -143,6 +143,7 @@
     jmethodID getTouchCalibrationForInputDevice;
     jmethodID notifyDropWindow;
     jmethodID getParentSurfaceForPointers;
+    jmethodID getPackageUid;
 } gServiceClassInfo;
 
 static struct {
@@ -362,6 +363,7 @@
     void notifyDropWindow(const sp<IBinder>& token, float x, float y) override;
     void notifyDeviceInteraction(int32_t deviceId, nsecs_t timestamp,
                                  const std::set<gui::Uid>& uids) override;
+    gui::Uid getPackageUid(std::string package) override;
 
     /* --- PointerControllerPolicyInterface implementation --- */
 
@@ -1116,6 +1118,21 @@
     mInputManager->getMetricsCollector().notifyDeviceInteraction(deviceId, timestamp, uids);
 }
 
+gui::Uid NativeInputManager::getPackageUid(std::string package) {
+    ATRACE_CALL();
+    JNIEnv* env = jniEnv();
+    ScopedLocalFrame localFrame(env);
+
+    ScopedLocalRef<jstring> javaPackage(env, env->NewStringUTF(package.c_str()));
+    const jint uid =
+            env->CallIntMethod(mServiceObj, gServiceClassInfo.getPackageUid, javaPackage.get());
+    if (checkAndClearExceptionFromCallback(env, "getPackageUid")) {
+        LOG(FATAL) << __func__ << ": Failed to get UID for package: " << package;
+    }
+
+    return gui::Uid{static_cast<uint32_t>(uid)};
+}
+
 void NativeInputManager::notifySensorEvent(int32_t deviceId, InputDeviceSensorType sensorType,
                                            InputDeviceSensorAccuracy accuracy, nsecs_t timestamp,
                                            const std::vector<float>& values) {
@@ -3101,6 +3118,8 @@
     GET_METHOD_ID(gServiceClassInfo.getParentSurfaceForPointers, clazz,
                   "getParentSurfaceForPointers", "(I)J");
 
+    GET_METHOD_ID(gServiceClassInfo.getPackageUid, clazz, "getPackageUid", "(Ljava/lang/String;)I");
+
     // InputDevice
 
     FIND_CLASS(gInputDeviceClassInfo.clazz, "android/view/InputDevice");
diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java
index 3b2a3dd..e202bbf 100644
--- a/services/java/com/android/server/SystemServer.java
+++ b/services/java/com/android/server/SystemServer.java
@@ -1230,10 +1230,6 @@
         mSystemServiceManager.startService(ThermalManagerService.class);
         t.traceEnd();
 
-        t.traceBegin("StartHintManager");
-        mSystemServiceManager.startService(HintManagerService.class);
-        t.traceEnd();
-
         // Now that the power manager has been started, let the activity manager
         // initialize power management features.
         t.traceBegin("InitPowerManagement");
@@ -1614,6 +1610,10 @@
                 t.traceEnd();
             }
 
+            t.traceBegin("StartHintManager");
+            mSystemServiceManager.startService(HintManagerService.class);
+            t.traceEnd();
+
             // Grants default permissions and defines roles
             t.traceBegin("StartRoleManagerService");
             LocalManagerRegistry.addManager(RoleServicePlatformHelper.class,
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/mockingservicestests/src/com/android/server/RescuePartyTest.java b/services/tests/mockingservicestests/src/com/android/server/RescuePartyTest.java
index c30ac2d..682569f 100644
--- a/services/tests/mockingservicestests/src/com/android/server/RescuePartyTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/RescuePartyTest.java
@@ -26,6 +26,7 @@
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.verify;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.when;
 import static com.android.server.RescueParty.LEVEL_FACTORY_RESET;
+import static com.android.server.RescueParty.RESCUE_LEVEL_FACTORY_RESET;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -41,9 +42,11 @@
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.VersionedPackage;
+import android.crashrecovery.flags.Flags;
 import android.os.RecoverySystem;
 import android.os.SystemProperties;
 import android.os.UserHandle;
+import android.platform.test.flag.junit.SetFlagsRule;
 import android.provider.DeviceConfig;
 import android.provider.Settings;
 import android.util.ArraySet;
@@ -55,6 +58,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.mockito.Answers;
 import org.mockito.ArgumentCaptor;
@@ -69,6 +73,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.Executor;
 import java.util.concurrent.TimeUnit;
 
@@ -100,6 +105,9 @@
 
     private static final int THROTTLING_DURATION_MIN = 10;
 
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
     private MockitoSession mSession;
     private HashMap<String, String> mSystemSettingsMap;
     private HashMap<String, String> mCrashRecoveryPropertiesMap;
@@ -267,6 +275,42 @@
     }
 
     @Test
+    public void testBootLoopDetectionWithExecutionForAllRescueLevelsRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        RescueParty.onSettingsProviderPublished(mMockContext);
+        verify(() -> DeviceConfig.setMonitorCallback(eq(mMockContentResolver),
+                any(Executor.class),
+                mMonitorCallbackCaptor.capture()));
+        HashMap<String, Integer> verifiedTimesMap = new HashMap<String, Integer>();
+
+        // Record DeviceConfig accesses
+        DeviceConfig.MonitorCallback monitorCallback = mMonitorCallbackCaptor.getValue();
+        monitorCallback.onDeviceConfigAccess(CALLING_PACKAGE1, NAMESPACE1);
+        monitorCallback.onDeviceConfigAccess(CALLING_PACKAGE1, NAMESPACE2);
+
+        final String[] expectedAllResetNamespaces = new String[]{NAMESPACE1, NAMESPACE2};
+
+        noteBoot(1);
+        verifyDeviceConfigReset(expectedAllResetNamespaces, verifiedTimesMap);
+
+        noteBoot(2);
+        assertTrue(RescueParty.isRebootPropertySet());
+
+        noteBoot(3);
+        verifyOnlySettingsReset(Settings.RESET_MODE_UNTRUSTED_DEFAULTS);
+
+        noteBoot(4);
+        verifyOnlySettingsReset(Settings.RESET_MODE_UNTRUSTED_CHANGES);
+
+        noteBoot(5);
+        verifyOnlySettingsReset(Settings.RESET_MODE_TRUSTED_DEFAULTS);
+
+        setCrashRecoveryPropAttemptingReboot(false);
+        noteBoot(6);
+        assertTrue(RescueParty.isFactoryResetPropertySet());
+    }
+
+    @Test
     public void testPersistentAppCrashDetectionWithExecutionForAllRescueLevels() {
         noteAppCrash(1, true);
 
@@ -292,6 +336,47 @@
     }
 
     @Test
+    public void testPersistentAppCrashDetectionWithExecutionForAllRescueLevelsRecoverability() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        RescueParty.onSettingsProviderPublished(mMockContext);
+        verify(() -> DeviceConfig.setMonitorCallback(eq(mMockContentResolver),
+                any(Executor.class),
+                mMonitorCallbackCaptor.capture()));
+        HashMap<String, Integer> verifiedTimesMap = new HashMap<String, Integer>();
+
+        // Record DeviceConfig accesses
+        DeviceConfig.MonitorCallback monitorCallback = mMonitorCallbackCaptor.getValue();
+        monitorCallback.onDeviceConfigAccess(PERSISTENT_PACKAGE, NAMESPACE1);
+        monitorCallback.onDeviceConfigAccess(CALLING_PACKAGE1, NAMESPACE1);
+        monitorCallback.onDeviceConfigAccess(CALLING_PACKAGE1, NAMESPACE2);
+
+        final String[] expectedResetNamespaces = new String[]{NAMESPACE1};
+        final String[] expectedAllResetNamespaces = new String[]{NAMESPACE1, NAMESPACE2};
+
+        noteAppCrash(1, true);
+        verifyDeviceConfigReset(expectedResetNamespaces, verifiedTimesMap);
+
+        noteAppCrash(2, true);
+        verifyDeviceConfigReset(expectedAllResetNamespaces, verifiedTimesMap);
+
+        noteAppCrash(3, true);
+        assertTrue(RescueParty.isRebootPropertySet());
+
+        noteAppCrash(4, true);
+        verifyOnlySettingsReset(Settings.RESET_MODE_UNTRUSTED_DEFAULTS);
+
+        noteAppCrash(5, true);
+        verifyOnlySettingsReset(Settings.RESET_MODE_UNTRUSTED_CHANGES);
+
+        noteAppCrash(6, true);
+        verifyOnlySettingsReset(Settings.RESET_MODE_TRUSTED_DEFAULTS);
+
+        setCrashRecoveryPropAttemptingReboot(false);
+        noteAppCrash(7, true);
+        assertTrue(RescueParty.isFactoryResetPropertySet());
+    }
+
+    @Test
     public void testNonPersistentAppOnlyPerformsFlagResets() {
         noteAppCrash(1, false);
 
@@ -316,6 +401,45 @@
     }
 
     @Test
+    public void testNonPersistentAppOnlyPerformsFlagResetsRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        RescueParty.onSettingsProviderPublished(mMockContext);
+        verify(() -> DeviceConfig.setMonitorCallback(eq(mMockContentResolver),
+                any(Executor.class),
+                mMonitorCallbackCaptor.capture()));
+        HashMap<String, Integer> verifiedTimesMap = new HashMap<String, Integer>();
+
+        // Record DeviceConfig accesses
+        DeviceConfig.MonitorCallback monitorCallback = mMonitorCallbackCaptor.getValue();
+        monitorCallback.onDeviceConfigAccess(NON_PERSISTENT_PACKAGE, NAMESPACE1);
+        monitorCallback.onDeviceConfigAccess(CALLING_PACKAGE1, NAMESPACE1);
+        monitorCallback.onDeviceConfigAccess(CALLING_PACKAGE1, NAMESPACE2);
+
+        final String[] expectedResetNamespaces = new String[]{NAMESPACE1};
+        final String[] expectedAllResetNamespaces = new String[]{NAMESPACE1, NAMESPACE2};
+
+        noteAppCrash(1, false);
+        verifyDeviceConfigReset(expectedResetNamespaces, verifiedTimesMap);
+
+        noteAppCrash(2, false);
+        verifyDeviceConfigReset(expectedAllResetNamespaces, verifiedTimesMap);
+
+        noteAppCrash(3, false);
+        assertFalse(RescueParty.isRebootPropertySet());
+
+        noteAppCrash(4, false);
+        verifyNoSettingsReset(Settings.RESET_MODE_UNTRUSTED_DEFAULTS);
+        noteAppCrash(5, false);
+        verifyNoSettingsReset(Settings.RESET_MODE_UNTRUSTED_CHANGES);
+        noteAppCrash(6, false);
+        verifyNoSettingsReset(Settings.RESET_MODE_TRUSTED_DEFAULTS);
+
+        setCrashRecoveryPropAttemptingReboot(false);
+        noteAppCrash(7, false);
+        assertFalse(RescueParty.isFactoryResetPropertySet());
+    }
+
+    @Test
     public void testNonPersistentAppCrashDetectionWithScopedResets() {
         RescueParty.onSettingsProviderPublished(mMockContext);
         verify(() -> DeviceConfig.setMonitorCallback(eq(mMockContentResolver),
@@ -451,6 +575,19 @@
     }
 
     @Test
+    public void testIsRecoveryTriggeredRebootRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        for (int i = 0; i < RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteBoot(i + 1);
+        }
+        assertFalse(RescueParty.isFactoryResetPropertySet());
+        setCrashRecoveryPropAttemptingReboot(false);
+        noteBoot(RESCUE_LEVEL_FACTORY_RESET + 1);
+        assertTrue(RescueParty.isRecoveryTriggeredReboot());
+        assertTrue(RescueParty.isFactoryResetPropertySet());
+    }
+
+    @Test
     public void testIsRecoveryTriggeredRebootOnlyAfterRebootCompleted() {
         for (int i = 0; i < LEVEL_FACTORY_RESET; i++) {
             noteBoot(i + 1);
@@ -469,6 +606,25 @@
     }
 
     @Test
+    public void testIsRecoveryTriggeredRebootOnlyAfterRebootCompletedRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        for (int i = 0; i < RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteBoot(i + 1);
+        }
+        int mitigationCount = RESCUE_LEVEL_FACTORY_RESET + 1;
+        assertFalse(RescueParty.isFactoryResetPropertySet());
+        noteBoot(mitigationCount++);
+        assertFalse(RescueParty.isFactoryResetPropertySet());
+        noteBoot(mitigationCount++);
+        assertFalse(RescueParty.isFactoryResetPropertySet());
+        noteBoot(mitigationCount++);
+        setCrashRecoveryPropAttemptingReboot(false);
+        noteBoot(mitigationCount + 1);
+        assertTrue(RescueParty.isRecoveryTriggeredReboot());
+        assertTrue(RescueParty.isFactoryResetPropertySet());
+    }
+
+    @Test
     public void testThrottlingOnBootFailures() {
         setCrashRecoveryPropAttemptingReboot(false);
         long now = System.currentTimeMillis();
@@ -481,6 +637,19 @@
     }
 
     @Test
+    public void testThrottlingOnBootFailuresRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        setCrashRecoveryPropAttemptingReboot(false);
+        long now = System.currentTimeMillis();
+        long beforeTimeout = now - TimeUnit.MINUTES.toMillis(THROTTLING_DURATION_MIN - 1);
+        setCrashRecoveryPropLastFactoryReset(beforeTimeout);
+        for (int i = 1; i <= RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteBoot(i);
+        }
+        assertFalse(RescueParty.isRecoveryTriggeredReboot());
+    }
+
+    @Test
     public void testThrottlingOnAppCrash() {
         setCrashRecoveryPropAttemptingReboot(false);
         long now = System.currentTimeMillis();
@@ -493,6 +662,19 @@
     }
 
     @Test
+    public void testThrottlingOnAppCrashRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        setCrashRecoveryPropAttemptingReboot(false);
+        long now = System.currentTimeMillis();
+        long beforeTimeout = now - TimeUnit.MINUTES.toMillis(THROTTLING_DURATION_MIN - 1);
+        setCrashRecoveryPropLastFactoryReset(beforeTimeout);
+        for (int i = 0; i <= RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteAppCrash(i + 1, true);
+        }
+        assertFalse(RescueParty.isRecoveryTriggeredReboot());
+    }
+
+    @Test
     public void testNotThrottlingAfterTimeoutOnBootFailures() {
         setCrashRecoveryPropAttemptingReboot(false);
         long now = System.currentTimeMillis();
@@ -503,6 +685,20 @@
         }
         assertTrue(RescueParty.isRecoveryTriggeredReboot());
     }
+
+    @Test
+    public void testNotThrottlingAfterTimeoutOnBootFailuresRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        setCrashRecoveryPropAttemptingReboot(false);
+        long now = System.currentTimeMillis();
+        long afterTimeout = now - TimeUnit.MINUTES.toMillis(THROTTLING_DURATION_MIN + 1);
+        setCrashRecoveryPropLastFactoryReset(afterTimeout);
+        for (int i = 1; i <= RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteBoot(i);
+        }
+        assertTrue(RescueParty.isRecoveryTriggeredReboot());
+    }
+
     @Test
     public void testNotThrottlingAfterTimeoutOnAppCrash() {
         setCrashRecoveryPropAttemptingReboot(false);
@@ -516,6 +712,19 @@
     }
 
     @Test
+    public void testNotThrottlingAfterTimeoutOnAppCrashRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        setCrashRecoveryPropAttemptingReboot(false);
+        long now = System.currentTimeMillis();
+        long afterTimeout = now - TimeUnit.MINUTES.toMillis(THROTTLING_DURATION_MIN + 1);
+        setCrashRecoveryPropLastFactoryReset(afterTimeout);
+        for (int i = 0; i <= RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteAppCrash(i + 1, true);
+        }
+        assertTrue(RescueParty.isRecoveryTriggeredReboot());
+    }
+
+    @Test
     public void testNativeRescuePartyResets() {
         doReturn(true).when(() -> SettingsToPropertiesMapper.isNativeFlagsResetPerformed());
         doReturn(FAKE_RESET_NATIVE_NAMESPACES).when(
@@ -531,6 +740,7 @@
 
     @Test
     public void testExplicitlyEnablingAndDisablingRescue() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         SystemProperties.set(RescueParty.PROP_ENABLE_RESCUE, Boolean.toString(false));
         SystemProperties.set(PROP_DISABLE_RESCUE, Boolean.toString(true));
         assertEquals(RescuePartyObserver.getInstance(mMockContext).execute(sFailingPackage,
@@ -543,6 +753,7 @@
 
     @Test
     public void testDisablingRescueByDeviceConfigFlag() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         SystemProperties.set(RescueParty.PROP_ENABLE_RESCUE, Boolean.toString(false));
         SystemProperties.set(PROP_DEVICE_CONFIG_DISABLE_FLAG, Boolean.toString(true));
 
@@ -568,6 +779,20 @@
     }
 
     @Test
+    public void testDisablingFactoryResetByDeviceConfigFlagRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        SystemProperties.set(PROP_DISABLE_FACTORY_RESET_FLAG, Boolean.toString(true));
+
+        for (int i = 0; i < RESCUE_LEVEL_FACTORY_RESET; i++) {
+            noteBoot(i + 1);
+        }
+        assertFalse(RescueParty.isFactoryResetPropertySet());
+
+        // Restore the property value initialized in SetUp()
+        SystemProperties.set(PROP_DISABLE_FACTORY_RESET_FLAG, "");
+    }
+
+    @Test
     public void testHealthCheckLevels() {
         RescuePartyObserver observer = RescuePartyObserver.getInstance(mMockContext);
 
@@ -594,6 +819,46 @@
     }
 
     @Test
+    public void testHealthCheckLevelsRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        RescuePartyObserver observer = RescuePartyObserver.getInstance(mMockContext);
+
+        // Ensure that no action is taken for cases where the failure reason is unknown
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_UNKNOWN, 1),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_0);
+
+        // Ensure the correct user impact is returned for each mitigation count.
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 1),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_10);
+
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 2),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 3),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 4),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 5),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 6),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+
+        assertEquals(observer.onHealthCheckFailed(sFailingPackage,
+                PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING, 7),
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+    }
+
+    @Test
     public void testBootLoopLevels() {
         RescuePartyObserver observer = RescuePartyObserver.getInstance(mMockContext);
 
@@ -606,6 +871,19 @@
     }
 
     @Test
+    public void testBootLoopLevelsRecoverabilityDetection() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        RescuePartyObserver observer = RescuePartyObserver.getInstance(mMockContext);
+
+        assertEquals(observer.onBootLoop(1), PackageHealthObserverImpact.USER_IMPACT_LEVEL_20);
+        assertEquals(observer.onBootLoop(2), PackageHealthObserverImpact.USER_IMPACT_LEVEL_50);
+        assertEquals(observer.onBootLoop(3), PackageHealthObserverImpact.USER_IMPACT_LEVEL_71);
+        assertEquals(observer.onBootLoop(4), PackageHealthObserverImpact.USER_IMPACT_LEVEL_75);
+        assertEquals(observer.onBootLoop(5), PackageHealthObserverImpact.USER_IMPACT_LEVEL_80);
+        assertEquals(observer.onBootLoop(6), PackageHealthObserverImpact.USER_IMPACT_LEVEL_100);
+    }
+
+    @Test
     public void testResetDeviceConfigForPackagesOnlyRuntimeMap() {
         RescueParty.onSettingsProviderPublished(mMockContext);
         verify(() -> DeviceConfig.setMonitorCallback(eq(mMockContentResolver),
@@ -727,11 +1005,26 @@
 
     private void verifySettingsResets(int resetMode, String[] resetNamespaces,
             HashMap<String, Integer> configResetVerifiedTimesMap) {
+        verifyOnlySettingsReset(resetMode);
+        verifyDeviceConfigReset(resetNamespaces, configResetVerifiedTimesMap);
+    }
+
+    private void verifyOnlySettingsReset(int resetMode) {
         verify(() -> Settings.Global.resetToDefaultsAsUser(mMockContentResolver, null,
                 resetMode, UserHandle.USER_SYSTEM));
         verify(() -> Settings.Secure.resetToDefaultsAsUser(eq(mMockContentResolver), isNull(),
                 eq(resetMode), anyInt()));
-        // Verify DeviceConfig resets
+    }
+
+    private void verifyNoSettingsReset(int resetMode) {
+        verify(() -> Settings.Global.resetToDefaultsAsUser(mMockContentResolver, null,
+                resetMode, UserHandle.USER_SYSTEM), never());
+        verify(() -> Settings.Secure.resetToDefaultsAsUser(eq(mMockContentResolver), isNull(),
+                eq(resetMode), anyInt()), never());
+    }
+
+    private void verifyDeviceConfigReset(String[] resetNamespaces,
+            Map<String, Integer> configResetVerifiedTimesMap) {
         if (resetNamespaces == null) {
             verify(() -> DeviceConfig.resetToDefaults(anyInt(), anyString()), never());
         } else {
@@ -818,9 +1111,16 @@
 
         // mock properties in BootThreshold
         try {
-            mSpyBootThreshold = spy(watchdog.new BootThreshold(
-                PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
-                PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS));
+            if (Flags.recoverabilityDetection()) {
+                mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT));
+            } else {
+                mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS));
+            }
             mCrashRecoveryPropertiesMap = new HashMap<>();
 
             doAnswer((Answer<Integer>) invocationOnMock -> {
diff --git a/services/tests/mockingservicestests/src/com/android/server/am/ServiceBindingOomAdjPolicyTest.java b/services/tests/mockingservicestests/src/com/android/server/am/ServiceBindingOomAdjPolicyTest.java
index 97b7af8..680ab16 100644
--- a/services/tests/mockingservicestests/src/com/android/server/am/ServiceBindingOomAdjPolicyTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/am/ServiceBindingOomAdjPolicyTest.java
@@ -36,7 +36,6 @@
 
 import static org.junit.Assert.assertNotEquals;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -185,8 +184,8 @@
         doReturn(false).when(mAms.mAtmInternal).hasSystemAlertWindowPermission(anyInt(), anyInt(),
                 any());
         doReturn(true).when(mAms.mOomAdjuster.mCachedAppOptimizer).useFreezer();
-        doNothing().when(mAms.mOomAdjuster.mCachedAppOptimizer).freezeAppAsyncInternalLSP(
-                any(), anyLong(), anyBoolean(), anyBoolean());
+        doNothing().when(mAms.mOomAdjuster.mCachedAppOptimizer).freezeAppAsyncAtEarliestLSP(
+                any());
         doReturn(false).when(mAms.mAppProfiler).updateLowMemStateLSP(anyInt(), anyInt(),
                 anyInt(), anyLong());
 
@@ -503,7 +502,7 @@
         if (clientApp.isFreezable()) {
             verify(mAms.mOomAdjuster.mCachedAppOptimizer,
                     times(Flags.serviceBindingOomAdjPolicy() ? 1 : 0))
-                    .freezeAppAsyncInternalLSP(eq(clientApp), eq(0L), anyBoolean(), anyBoolean());
+                    .freezeAppAsyncAtEarliestLSP(eq(clientApp));
             clearInvocations(mAms.mOomAdjuster.mCachedAppOptimizer);
         }
 
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityManagerServiceTest.java b/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityManagerServiceTest.java
index 53c460c..9d32ed8 100644
--- a/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityManagerServiceTest.java
+++ b/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityManagerServiceTest.java
@@ -21,7 +21,6 @@
 import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_FULLSCREEN;
 import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_NONE;
 import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_WINDOW;
-import static android.view.accessibility.Flags.FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG;
 import static android.view.accessibility.Flags.FLAG_SKIP_ACCESSIBILITY_WARNING_DIALOG_FOR_TRUSTED_SERVICES;
 
 import static com.android.internal.accessibility.AccessibilityShortcutController.ACCESSIBILITY_HEARING_AIDS_COMPONENT_NAME;
@@ -885,7 +884,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void testIsAccessibilityServiceWarningRequired_requiredByDefault() {
         mockManageAccessibilityGranted(mTestableContext);
         final AccessibilityServiceInfo info = mockAccessibilityServiceInfo(COMPONENT_NAME);
@@ -894,7 +892,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void testIsAccessibilityServiceWarningRequired_notRequiredIfAlreadyEnabled() {
         mockManageAccessibilityGranted(mTestableContext);
         final AccessibilityServiceInfo info_a = mockAccessibilityServiceInfo(COMPONENT_NAME);
@@ -909,7 +906,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG)
     public void testIsAccessibilityServiceWarningRequired_notRequiredIfExistingShortcut() {
         mockManageAccessibilityGranted(mTestableContext);
         final AccessibilityServiceInfo info_a = mockAccessibilityServiceInfo(
@@ -930,9 +926,7 @@
     }
 
     @Test
-    @RequiresFlagsEnabled({
-            FLAG_CLEANUP_ACCESSIBILITY_WARNING_DIALOG,
-            FLAG_SKIP_ACCESSIBILITY_WARNING_DIALOG_FOR_TRUSTED_SERVICES})
+    @RequiresFlagsEnabled(FLAG_SKIP_ACCESSIBILITY_WARNING_DIALOG_FOR_TRUSTED_SERVICES)
     public void testIsAccessibilityServiceWarningRequired_notRequiredIfAllowlisted() {
         mockManageAccessibilityGranted(mTestableContext);
         final AccessibilityServiceInfo info_a = mockAccessibilityServiceInfo(
diff --git a/services/tests/servicestests/src/com/android/server/companion/virtual/VirtualDeviceTest.java b/services/tests/servicestests/src/com/android/server/companion/virtual/VirtualDeviceTest.java
index a4628ee..4d1d17f 100644
--- a/services/tests/servicestests/src/com/android/server/companion/virtual/VirtualDeviceTest.java
+++ b/services/tests/servicestests/src/com/android/server/companion/virtual/VirtualDeviceTest.java
@@ -141,6 +141,7 @@
     @Test
     public void virtualDevice_hasCustomAudioInputSupport() throws Exception {
         mSetFlagsRule.enableFlags(Flags.FLAG_VDM_PUBLIC_APIS);
+        mSetFlagsRule.enableFlags(android.media.audiopolicy.Flags.FLAG_AUDIO_MIX_TEST_API);
 
         VirtualDevice virtualDevice =
                 new VirtualDevice(
@@ -150,6 +151,10 @@
         assertThat(virtualDevice.hasCustomAudioInputSupport()).isFalse();
 
         when(mVirtualDevice.getDevicePolicy(POLICY_TYPE_AUDIO)).thenReturn(DEVICE_POLICY_CUSTOM);
+        when(mVirtualDevice.hasCustomAudioInputSupport()).thenReturn(false);
+        assertThat(virtualDevice.hasCustomAudioInputSupport()).isFalse();
+
+        when(mVirtualDevice.hasCustomAudioInputSupport()).thenReturn(true);
         assertThat(virtualDevice.hasCustomAudioInputSupport()).isTrue();
     }
 
diff --git a/services/tests/servicestests/src/com/android/server/power/hint/HintManagerServiceTest.java b/services/tests/servicestests/src/com/android/server/power/hint/HintManagerServiceTest.java
index 66599e9..510e7c4 100644
--- a/services/tests/servicestests/src/com/android/server/power/hint/HintManagerServiceTest.java
+++ b/services/tests/servicestests/src/com/android/server/power/hint/HintManagerServiceTest.java
@@ -17,6 +17,8 @@
 package com.android.server.power.hint;
 
 
+import static com.android.server.power.hint.HintManagerService.CLEAN_UP_UID_DELAY_MILLIS;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertArrayEquals;
@@ -45,6 +47,9 @@
 import android.os.IHintSession;
 import android.os.PerformanceHintManager;
 import android.os.Process;
+import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
 import android.util.Log;
 
 import com.android.server.FgThread;
@@ -54,11 +59,13 @@
 import com.android.server.power.hint.HintManagerService.NativeWrapper;
 
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.CountDownLatch;
@@ -71,7 +78,7 @@
  * Tests for {@link com.android.server.power.hint.HintManagerService}.
  *
  * Build/Install/Run:
- *  atest FrameworksServicesTests:HintManagerServiceTest
+ * atest FrameworksServicesTests:HintManagerServiceTest
  */
 public class HintManagerServiceTest {
     private static final String TAG = "HintManagerServiceTest";
@@ -110,9 +117,15 @@
         makeWorkDuration(2L, 13L, 2L, 8L, 0L),
     };
 
-    @Mock private Context mContext;
-    @Mock private HintManagerService.NativeWrapper mNativeWrapperMock;
-    @Mock private ActivityManagerInternal mAmInternalMock;
+    @Mock
+    private Context mContext;
+    @Mock
+    private HintManagerService.NativeWrapper mNativeWrapperMock;
+    @Mock
+    private ActivityManagerInternal mAmInternalMock;
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule =
+            DeviceFlagsValueProvider.createCheckFlagsRule();
 
     private HintManagerService mService;
 
@@ -122,12 +135,11 @@
         when(mNativeWrapperMock.halGetHintSessionPreferredRate())
                 .thenReturn(DEFAULT_HINT_PREFERRED_RATE);
         when(mNativeWrapperMock.halCreateHintSession(eq(TGID), eq(UID), eq(SESSION_TIDS_A),
-              eq(DEFAULT_TARGET_DURATION))).thenReturn(1L);
+                eq(DEFAULT_TARGET_DURATION))).thenReturn(1L);
         when(mNativeWrapperMock.halCreateHintSession(eq(TGID), eq(UID), eq(SESSION_TIDS_B),
-              eq(DEFAULT_TARGET_DURATION))).thenReturn(2L);
+                eq(DEFAULT_TARGET_DURATION))).thenReturn(2L);
         when(mNativeWrapperMock.halCreateHintSession(eq(TGID), eq(UID), eq(SESSION_TIDS_C),
-              eq(0L))).thenReturn(1L);
-        when(mAmInternalMock.getIsolatedProcesses(anyInt())).thenReturn(null);
+                eq(0L))).thenReturn(1L);
         LocalServices.removeServiceForTest(ActivityManagerInternal.class);
         LocalServices.addService(ActivityManagerInternal.class, mAmInternalMock);
     }
@@ -434,6 +446,163 @@
     }
 
     @Test
+    @RequiresFlagsEnabled(Flags.FLAG_POWERHINT_THREAD_CLEANUP)
+    public void testCleanupDeadThreads() throws Exception {
+        HintManagerService service = createService();
+        IBinder token = new Binder();
+        CountDownLatch stopLatch1 = new CountDownLatch(1);
+        int threadCount = 3;
+        int[] tids1 = createThreads(threadCount, stopLatch1);
+        long sessionPtr1 = 111;
+        when(mNativeWrapperMock.halCreateHintSession(eq(TGID), eq(UID), eq(tids1),
+                eq(DEFAULT_TARGET_DURATION))).thenReturn(sessionPtr1);
+        AppHintSession session1 = (AppHintSession) service.getBinderServiceInstance()
+                .createHintSession(token, tids1, DEFAULT_TARGET_DURATION);
+        assertNotNull(session1);
+
+        // for test only to avoid conflicting with any real thread that exists on device
+        int isoProc1 = -100;
+        int isoProc2 = 9999;
+        when(mAmInternalMock.getIsolatedProcesses(eq(UID))).thenReturn(List.of(0));
+
+        CountDownLatch stopLatch2 = new CountDownLatch(1);
+        int[] tids2 = createThreads(threadCount, stopLatch2);
+        int[] tids2WithIsolated = Arrays.copyOf(tids2, tids2.length + 2);
+        int[] expectedTids2 = Arrays.copyOf(tids2, tids2.length + 1);
+        expectedTids2[tids2.length] = isoProc1;
+        tids2WithIsolated[threadCount] = isoProc1;
+        tids2WithIsolated[threadCount + 1] = isoProc2;
+        long sessionPtr2 = 222;
+        when(mNativeWrapperMock.halCreateHintSession(eq(TGID), eq(UID), eq(tids2WithIsolated),
+                eq(DEFAULT_TARGET_DURATION))).thenReturn(sessionPtr2);
+        AppHintSession session2 = (AppHintSession) service.getBinderServiceInstance()
+                .createHintSession(token, tids2WithIsolated, DEFAULT_TARGET_DURATION);
+        assertNotNull(session2);
+
+        // trigger clean up through UID state change by making the process background
+        service.mUidObserver.onUidStateChanged(UID,
+                ActivityManager.PROCESS_STATE_IMPORTANT_BACKGROUND, 0, 0);
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500) + TimeUnit.MILLISECONDS.toNanos(
+                CLEAN_UP_UID_DELAY_MILLIS));
+        verify(mNativeWrapperMock, never()).halSetThreads(eq(sessionPtr1), any());
+        verify(mNativeWrapperMock, never()).halSetThreads(eq(sessionPtr2), any());
+        // the new TIDs pending list should be updated
+        assertArrayEquals(session2.getTidsInternal(), expectedTids2);
+        reset(mNativeWrapperMock);
+
+        // this should resume and update the threads so those never-existed invalid isolated
+        // processes should be cleaned up
+        service.mUidObserver.onUidStateChanged(UID,
+                ActivityManager.PROCESS_STATE_IMPORTANT_FOREGROUND, 0, 0);
+        // wait for the async uid state change to trigger resume and setThreads
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500));
+        verify(mNativeWrapperMock, times(1)).halSetThreads(eq(sessionPtr2), eq(expectedTids2));
+        reset(mNativeWrapperMock);
+
+        // let all session 1 threads to exit and the cleanup should force pause the session
+        stopLatch1.countDown();
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
+        service.mUidObserver.onUidStateChanged(UID,
+                ActivityManager.PROCESS_STATE_IMPORTANT_FOREGROUND, 0, 0);
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500) + TimeUnit.MILLISECONDS.toNanos(
+                CLEAN_UP_UID_DELAY_MILLIS));
+        verify(mNativeWrapperMock, times(1)).halPauseHintSession(eq(sessionPtr1));
+        verify(mNativeWrapperMock, never()).halSetThreads(eq(sessionPtr1), any());
+        verify(mNativeWrapperMock, never()).halSetThreads(eq(sessionPtr2), any());
+        // all hints will have no effect as the session is force paused while proc in foreground
+        verifyAllHintsEnabled(session1, false);
+        verifyAllHintsEnabled(session2, true);
+        reset(mNativeWrapperMock);
+
+        // in foreground, set new tids for session 1 then it should be resumed and all hints allowed
+        stopLatch1 = new CountDownLatch(1);
+        tids1 = createThreads(threadCount, stopLatch1);
+        session1.setThreads(tids1);
+        verify(mNativeWrapperMock, times(1)).halSetThreads(eq(sessionPtr1), eq(tids1));
+        verify(mNativeWrapperMock, times(1)).halResumeHintSession(eq(sessionPtr1));
+        verifyAllHintsEnabled(session1, true);
+        reset(mNativeWrapperMock);
+
+        // let all session 1 and 2 non isolated threads to exit
+        stopLatch1.countDown();
+        stopLatch2.countDown();
+        expectedTids2 = new int[]{isoProc1};
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
+        service.mUidObserver.onUidStateChanged(UID,
+                ActivityManager.PROCESS_STATE_IMPORTANT_BACKGROUND, 0, 0);
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500) + TimeUnit.MILLISECONDS.toNanos(
+                CLEAN_UP_UID_DELAY_MILLIS));
+        verify(mNativeWrapperMock, times(1)).halPauseHintSession(eq(sessionPtr1));
+        verify(mNativeWrapperMock, never()).halSetThreads(eq(sessionPtr1), any());
+        verify(mNativeWrapperMock, never()).halSetThreads(eq(sessionPtr2), any());
+        // in background, set threads for session 1 then it should not be force paused next time
+        session1.setThreads(SESSION_TIDS_A);
+        // the new TIDs pending list should be updated
+        assertArrayEquals(session1.getTidsInternal(), SESSION_TIDS_A);
+        assertArrayEquals(session2.getTidsInternal(), expectedTids2);
+        verifyAllHintsEnabled(session1, false);
+        verifyAllHintsEnabled(session2, false);
+        reset(mNativeWrapperMock);
+
+        service.mUidObserver.onUidStateChanged(UID,
+                ActivityManager.PROCESS_STATE_IMPORTANT_FOREGROUND, 0, 0);
+        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500) + TimeUnit.MILLISECONDS.toNanos(
+                CLEAN_UP_UID_DELAY_MILLIS));
+        verify(mNativeWrapperMock, times(1)).halSetThreads(eq(sessionPtr1),
+                eq(SESSION_TIDS_A));
+        verify(mNativeWrapperMock, times(1)).halSetThreads(eq(sessionPtr2),
+                eq(expectedTids2));
+        verifyAllHintsEnabled(session1, true);
+        verifyAllHintsEnabled(session2, true);
+    }
+
+    private void verifyAllHintsEnabled(AppHintSession session, boolean verifyEnabled) {
+        session.reportActualWorkDuration2(new WorkDuration[]{makeWorkDuration(1, 3, 2, 1, 1000)});
+        session.reportActualWorkDuration(new long[]{1}, new long[]{2});
+        session.updateTargetWorkDuration(3);
+        session.setMode(0, true);
+        session.sendHint(1);
+        if (verifyEnabled) {
+            verify(mNativeWrapperMock, times(1)).halReportActualWorkDuration(
+                    eq(session.mHalSessionPtr), any());
+            verify(mNativeWrapperMock, times(1)).halSetMode(eq(session.mHalSessionPtr), anyInt(),
+                    anyBoolean());
+            verify(mNativeWrapperMock, times(1)).halUpdateTargetWorkDuration(
+                    eq(session.mHalSessionPtr), anyLong());
+            verify(mNativeWrapperMock, times(1)).halSendHint(eq(session.mHalSessionPtr), anyInt());
+        } else {
+            verify(mNativeWrapperMock, never()).halReportActualWorkDuration(
+                    eq(session.mHalSessionPtr), any());
+            verify(mNativeWrapperMock, never()).halSetMode(eq(session.mHalSessionPtr), anyInt(),
+                    anyBoolean());
+            verify(mNativeWrapperMock, never()).halUpdateTargetWorkDuration(
+                    eq(session.mHalSessionPtr), anyLong());
+            verify(mNativeWrapperMock, never()).halSendHint(eq(session.mHalSessionPtr), anyInt());
+        }
+    }
+
+    private int[] createThreads(int threadCount, CountDownLatch stopLatch)
+            throws InterruptedException {
+        int[] tids = new int[threadCount];
+        AtomicInteger k = new AtomicInteger(0);
+        CountDownLatch latch = new CountDownLatch(threadCount);
+        for (int j = 0; j < threadCount; j++) {
+            Thread thread = new Thread(() -> {
+                try {
+                    tids[k.getAndIncrement()] = android.os.Process.myTid();
+                    latch.countDown();
+                    stopLatch.await();
+                } catch (InterruptedException e) {
+                    throw new RuntimeException(e);
+                }
+            });
+            thread.start();
+        }
+        latch.await();
+        return tids;
+    }
+
+    @Test
     public void testSetMode() throws Exception {
         HintManagerService service = createService();
         IBinder token = new Binder();
@@ -457,7 +626,8 @@
         // Set session to background, then the duration would not be updated.
         service.mUidObserver.onUidStateChanged(
                 a.mUid, ActivityManager.PROCESS_STATE_TRANSIENT_BACKGROUND, 0, 0);
-        FgThread.getHandler().runWithScissors(() -> { }, 500);
+        FgThread.getHandler().runWithScissors(() -> {
+        }, 500);
         assertFalse(service.mUidObserver.isUidForeground(a.mUid));
         a.setMode(0, true);
         verify(mNativeWrapperMock, never()).halSetMode(anyLong(), anyInt(), anyBoolean());
@@ -519,7 +689,10 @@
                     LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500));
                     service.mUidObserver.onUidStateChanged(UID,
                             ActivityManager.PROCESS_STATE_TRANSIENT_BACKGROUND, 0, 0);
-                    LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(500));
+                    // let the cleanup work proceed
+                    LockSupport.parkNanos(
+                            TimeUnit.MILLISECONDS.toNanos(500) + TimeUnit.MILLISECONDS.toNanos(
+                                    CLEAN_UP_UID_DELAY_MILLIS));
                 }
                 Log.d(TAG, "notifier thread min " + min + " max " + max + " avg " + sum / count);
                 service.mUidObserver.onUidGone(UID, true);
diff --git a/services/tests/servicestests/src/com/android/server/power/hint/TEST_MAPPING b/services/tests/servicestests/src/com/android/server/power/hint/TEST_MAPPING
new file mode 100644
index 0000000..2d5df07
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/power/hint/TEST_MAPPING
@@ -0,0 +1,15 @@
+{
+  "postsubmit": [
+    {
+      "name": "FrameworksServicesTests",
+      "options": [
+        {
+          "include-filter": "com.android.server.power.hint"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    }
+  ]
+}
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/wmtests/src/com/android/server/policy/PhoneWindowManagerTests.java b/services/tests/wmtests/src/com/android/server/policy/PhoneWindowManagerTests.java
index 29467f2..a80e2f8 100644
--- a/services/tests/wmtests/src/com/android/server/policy/PhoneWindowManagerTests.java
+++ b/services/tests/wmtests/src/com/android/server/policy/PhoneWindowManagerTests.java
@@ -16,10 +16,14 @@
 
 package com.android.server.policy;
 
+import static android.view.Display.DEFAULT_DISPLAY;
 import static android.view.WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY;
 import static android.view.WindowManager.LayoutParams.TYPE_WALLPAPER;
 import static android.view.WindowManagerGlobal.ADD_OKAY;
 
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doAnswer;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doNothing;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
@@ -33,18 +37,27 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
 
 import android.app.ActivityManager;
 import android.app.AppOpsManager;
+import android.content.Context;
+import android.os.PowerManager;
 import android.platform.test.flag.junit.SetFlagsRule;
 
 import androidx.test.filters.SmallTest;
 
+import com.android.server.LocalServices;
 import com.android.server.pm.UserManagerInternal;
 import com.android.server.wm.ActivityTaskManagerInternal;
+import com.android.server.wm.DisplayPolicy;
+import com.android.server.wm.DisplayRotation;
+import com.android.server.wm.WindowManagerInternal;
 
 import org.junit.After;
 import org.junit.Before;
@@ -64,16 +77,27 @@
     public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
 
     PhoneWindowManager mPhoneWindowManager;
+    private ActivityTaskManagerInternal mAtmInternal;
+    private Context mContext;
 
     @Before
     public void setUp() {
         mPhoneWindowManager = spy(new PhoneWindowManager());
         spyOn(ActivityManager.getService());
+        mContext = getInstrumentation().getTargetContext();
+        spyOn(mContext);
+        mAtmInternal = mock(ActivityTaskManagerInternal.class);
+        LocalServices.addService(ActivityTaskManagerInternal.class, mAtmInternal);
+        mPhoneWindowManager.mActivityTaskManagerInternal = mAtmInternal;
+        LocalServices.addService(WindowManagerInternal.class, mock(WindowManagerInternal.class));
     }
 
     @After
     public void tearDown() {
         reset(ActivityManager.getService());
+        reset(mContext);
+        LocalServices.removeServiceForTest(ActivityTaskManagerInternal.class);
+        LocalServices.removeServiceForTest(WindowManagerInternal.class);
     }
 
     @Test
@@ -99,6 +123,60 @@
     }
 
     @Test
+    public void testScreenTurnedOff() {
+        mSetFlagsRule.enableFlags(com.android.window.flags.Flags
+                .FLAG_SKIP_SLEEPING_WHEN_SWITCHING_DISPLAY);
+        doNothing().when(mPhoneWindowManager).updateSettings(any());
+        doNothing().when(mPhoneWindowManager).initializeHdmiState();
+        final boolean[] isScreenTurnedOff = { false };
+        final DisplayPolicy displayPolicy = mock(DisplayPolicy.class);
+        doAnswer(invocation -> isScreenTurnedOff[0] = true).when(displayPolicy).screenTurnedOff();
+        doAnswer(invocation -> !isScreenTurnedOff[0]).when(displayPolicy).isScreenOnEarly();
+        doAnswer(invocation -> !isScreenTurnedOff[0]).when(displayPolicy).isScreenOnFully();
+
+        mPhoneWindowManager.mDefaultDisplayPolicy = displayPolicy;
+        mPhoneWindowManager.mDefaultDisplayRotation = mock(DisplayRotation.class);
+        final ActivityTaskManagerInternal.SleepTokenAcquirer tokenAcquirer =
+                mock(ActivityTaskManagerInternal.SleepTokenAcquirer.class);
+        doReturn(tokenAcquirer).when(mAtmInternal).createSleepTokenAcquirer(anyString());
+        final PowerManager pm = mock(PowerManager.class);
+        doReturn(true).when(pm).isInteractive();
+        doReturn(pm).when(mContext).getSystemService(eq(Context.POWER_SERVICE));
+
+        mContext.getMainThreadHandler().runWithScissors(() -> mPhoneWindowManager.init(
+                new PhoneWindowManager.Injector(mContext,
+                        mock(WindowManagerPolicy.WindowManagerFuncs.class))), 0);
+        assertThat(isScreenTurnedOff[0]).isFalse();
+        assertThat(mPhoneWindowManager.mIsGoingToSleepDefaultDisplay).isFalse();
+
+        // Skip sleep-token for non-sleep-screen-off.
+        clearInvocations(tokenAcquirer);
+        mPhoneWindowManager.screenTurnedOff(DEFAULT_DISPLAY, true /* isSwappingDisplay */);
+        verify(tokenAcquirer, never()).acquire(anyInt(), anyBoolean());
+        assertThat(isScreenTurnedOff[0]).isTrue();
+
+        // Apply sleep-token for sleep-screen-off.
+        mPhoneWindowManager.startedGoingToSleep(DEFAULT_DISPLAY, 0 /* reason */);
+        assertThat(mPhoneWindowManager.mIsGoingToSleepDefaultDisplay).isTrue();
+        mPhoneWindowManager.screenTurnedOff(DEFAULT_DISPLAY, true /* isSwappingDisplay */);
+        verify(tokenAcquirer).acquire(eq(DEFAULT_DISPLAY), eq(true));
+
+        mPhoneWindowManager.finishedGoingToSleep(DEFAULT_DISPLAY, 0 /* reason */);
+        assertThat(mPhoneWindowManager.mIsGoingToSleepDefaultDisplay).isFalse();
+
+        // Simulate unexpected reversed order: screenTurnedOff -> startedGoingToSleep. The sleep
+        // token can still be acquired.
+        isScreenTurnedOff[0] = false;
+        clearInvocations(tokenAcquirer);
+        mPhoneWindowManager.screenTurnedOff(DEFAULT_DISPLAY, true /* isSwappingDisplay */);
+        verify(tokenAcquirer, never()).acquire(anyInt(), anyBoolean());
+        assertThat(displayPolicy.isScreenOnEarly()).isFalse();
+        assertThat(displayPolicy.isScreenOnFully()).isFalse();
+        mPhoneWindowManager.startedGoingToSleep(DEFAULT_DISPLAY, 0 /* reason */);
+        verify(tokenAcquirer).acquire(eq(DEFAULT_DISPLAY), eq(false));
+    }
+
+    @Test
     public void testCheckAddPermission_withoutAccessibilityOverlay_noAccessibilityAppOpLogged() {
         mSetFlagsRule.enableFlags(android.view.contentprotection.flags.Flags
                 .FLAG_CREATE_ACCESSIBILITY_OVERLAY_APP_OP_ENABLED);
@@ -130,11 +208,8 @@
 
     private void mockStartDockOrHome() throws Exception {
         doNothing().when(ActivityManager.getService()).stopAppSwitches();
-        ActivityTaskManagerInternal mMockActivityTaskManagerInternal =
-                mock(ActivityTaskManagerInternal.class);
-        when(mMockActivityTaskManagerInternal.startHomeOnDisplay(
+        when(mAtmInternal.startHomeOnDisplay(
                 anyInt(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(false);
-        mPhoneWindowManager.mActivityTaskManagerInternal = mMockActivityTaskManagerInternal;
         mPhoneWindowManager.mUserManagerInternal = mock(UserManagerInternal.class);
     }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java b/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java
index 4e360d0..2c88ed2 100644
--- a/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java
@@ -1068,16 +1068,6 @@
                 mDisplayContent.getImeTarget(IME_TARGET_LAYERING));
     }
 
-    @SetupWindows(addWindows = W_INPUT_METHOD)
-    @Test
-    public void testInputMethodSet_listenOnDisplayAreaConfigurationChanged() {
-        spyOn(mAtm);
-        mDisplayContent.setInputMethodWindowLocked(mImeWindow);
-
-        verify(mAtm).onImeWindowSetOnDisplayArea(
-                mImeWindow.mSession.mPid, mDisplayContent.getImeContainer());
-    }
-
     @Test
     public void testAllowsTopmostFullscreenOrientation() {
         final DisplayContent dc = createNewDisplay();
diff --git a/services/tests/wmtests/src/com/android/server/wm/TaskFragmentOrganizerControllerTest.java b/services/tests/wmtests/src/com/android/server/wm/TaskFragmentOrganizerControllerTest.java
index 897a3da..52485ee 100644
--- a/services/tests/wmtests/src/com/android/server/wm/TaskFragmentOrganizerControllerTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/TaskFragmentOrganizerControllerTest.java
@@ -25,7 +25,7 @@
 import static android.view.WindowManager.TRANSIT_NONE;
 import static android.view.WindowManager.TRANSIT_OPEN;
 import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_TASK_FRAGMENT;
-import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE;
+import static android.window.TaskFragmentOperation.OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE;
 import static android.window.TaskFragmentOperation.OP_TYPE_DELETE_TASK_FRAGMENT;
 import static android.window.TaskFragmentOperation.OP_TYPE_REMOVE_TASK_FRAGMENT_DECOR_SURFACE;
 import static android.window.TaskFragmentOperation.OP_TYPE_REORDER_TO_BOTTOM_OF_TASK;
@@ -1835,7 +1835,7 @@
 
         final TaskFragment tf = createTaskFragment(task);
         final TaskFragmentOperation operation = new TaskFragmentOperation.Builder(
-                OP_TYPE_CREATE_TASK_FRAGMENT_DECOR_SURFACE).build();
+                OP_TYPE_CREATE_OR_MOVE_TASK_FRAGMENT_DECOR_SURFACE).build();
         mTransaction.addTaskFragmentOperation(tf.getFragmentToken(), operation);
 
         assertApplyTransactionAllowed(mTransaction);
diff --git a/services/tests/wmtests/src/com/android/server/wm/WindowManagerServiceTests.java b/services/tests/wmtests/src/com/android/server/wm/WindowManagerServiceTests.java
index 12f46df..48b12f7 100644
--- a/services/tests/wmtests/src/com/android/server/wm/WindowManagerServiceTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/WindowManagerServiceTests.java
@@ -90,6 +90,7 @@
 import android.util.MergedConfiguration;
 import android.view.ContentRecordingSession;
 import android.view.IWindow;
+import android.view.IWindowSession;
 import android.view.InputChannel;
 import android.view.InsetsSourceControl;
 import android.view.InsetsState;
@@ -99,6 +100,7 @@
 import android.view.WindowInsets;
 import android.view.WindowManager;
 import android.view.WindowManager.LayoutParams;
+import android.window.ActivityWindowInfo;
 import android.window.ClientWindowFrames;
 import android.window.InputTransferToken;
 import android.window.ScreenCapture;
@@ -1216,6 +1218,35 @@
         mWm.reportKeepClearAreasChanged(session, window, new ArrayList<>(), new ArrayList<>());
     }
 
+    @Test
+    public void testRelayout_appWindowSendActivityWindowInfo() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_ACTIVITY_WINDOW_INFO_FLAG);
+
+        // Skip unnecessary operations of relayout.
+        spyOn(mWm.mWindowPlacerLocked);
+        doNothing().when(mWm.mWindowPlacerLocked).performSurfacePlacement(anyBoolean());
+
+        final Task task = createTask(mDisplayContent);
+        final WindowState win = createAppWindow(task, ACTIVITY_TYPE_STANDARD, "appWindow");
+        mWm.mWindowMap.put(win.mClient.asBinder(), win);
+
+        final int w = 100;
+        final int h = 200;
+        final ClientWindowFrames outFrames = new ClientWindowFrames();
+        final MergedConfiguration outConfig = new MergedConfiguration();
+        final SurfaceControl outSurfaceControl = new SurfaceControl();
+        final InsetsState outInsetsState = new InsetsState();
+        final InsetsSourceControl.Array outControls = new InsetsSourceControl.Array();
+        final Bundle outBundle = new Bundle();
+
+        mWm.relayoutWindow(win.mSession, win.mClient, win.mAttrs, w, h, View.GONE, 0, 0, 0,
+                outFrames, outConfig, outSurfaceControl, outInsetsState, outControls, outBundle);
+
+        final ActivityWindowInfo activityWindowInfo = outBundle.getParcelable(
+                IWindowSession.KEY_RELAYOUT_BUNDLE_ACTIVITY_WINDOW_INFO, ActivityWindowInfo.class);
+        assertEquals(win.mActivityRecord.getActivityWindowInfo(), activityWindowInfo);
+    }
+
     class TestResultReceiver implements IResultReceiver {
         public android.os.Bundle resultData;
         private final IBinder mBinder = mock(IBinder.class);
diff --git a/telephony/java/android/telephony/satellite/stub/ISatellite.aidl b/telephony/java/android/telephony/satellite/stub/ISatellite.aidl
index 9441fb5..36485c6 100644
--- a/telephony/java/android/telephony/satellite/stub/ISatellite.aidl
+++ b/telephony/java/android/telephony/satellite/stub/ISatellite.aidl
@@ -347,28 +347,6 @@
             in IIntegerConsumer callback);
 
     /**
-     * Request to get whether satellite communication is allowed for the current location.
-     *
-     * @param resultCallback The callback to receive the error code result of the operation.
-     *                       This must only be sent when the result is not
-     *                       SatelliteResult#SATELLITE_RESULT_SUCCESS.
-     * @param callback If the result is SatelliteResult#SATELLITE_RESULT_SUCCESS, the callback to
-     *                 receive whether satellite communication is allowed for the current location.
-     *
-     * Valid result codes returned:
-     *   SatelliteResult:SATELLITE_RESULT_SUCCESS
-     *   SatelliteResult:SATELLITE_RESULT_SERVICE_ERROR
-     *   SatelliteResult:SATELLITE_RESULT_MODEM_ERROR
-     *   SatelliteResult:SATELLITE_RESULT_INVALID_MODEM_STATE
-     *   SatelliteResult:SATELLITE_RESULT_INVALID_ARGUMENTS
-     *   SatelliteResult:SATELLITE_RESULT_RADIO_NOT_AVAILABLE
-     *   SatelliteResult:SATELLITE_RESULT_REQUEST_NOT_SUPPORTED
-     *   SatelliteResult:SATELLITE_RESULT_NO_RESOURCES
-     */
-    void requestIsSatelliteCommunicationAllowedForCurrentLocation(
-            in IIntegerConsumer resultCallback, in IBooleanConsumer callback);
-
-    /**
      * Request to get the time after which the satellite will be visible. This is an int
      * representing the duration in seconds after which the satellite will be visible.
      * This will return 0 if the satellite is currently visible.
diff --git a/telephony/java/android/telephony/satellite/stub/SatelliteImplBase.java b/telephony/java/android/telephony/satellite/stub/SatelliteImplBase.java
index f17ff17..b7dc79f 100644
--- a/telephony/java/android/telephony/satellite/stub/SatelliteImplBase.java
+++ b/telephony/java/android/telephony/satellite/stub/SatelliteImplBase.java
@@ -194,17 +194,6 @@
         }
 
         @Override
-        public void requestIsSatelliteCommunicationAllowedForCurrentLocation(
-                IIntegerConsumer resultCallback, IBooleanConsumer callback)
-                throws RemoteException {
-            executeMethodAsync(
-                    () -> SatelliteImplBase.this
-                            .requestIsSatelliteCommunicationAllowedForCurrentLocation(
-                                    resultCallback, callback),
-                    "requestIsCommunicationAllowedForCurrentLocation");
-        }
-
-        @Override
         public void requestTimeForNextSatelliteVisibility(IIntegerConsumer resultCallback,
                 IIntegerConsumer callback) throws RemoteException {
             executeMethodAsync(
@@ -638,30 +627,6 @@
     }
 
     /**
-     * Request to get whether satellite communication is allowed for the current location.
-     *
-     * @param resultCallback The callback to receive the error code result of the operation.
-     *                       This must only be sent when the result is not
-     *                       SatelliteResult#SATELLITE_RESULT_SUCCESS.
-     * @param callback If the result is SatelliteResult#SATELLITE_RESULT_SUCCESS, the callback to
-     *                 receive whether satellite communication is allowed for the current location.
-     *
-     * Valid result codes returned:
-     *   SatelliteResult:SATELLITE_RESULT_SUCCESS
-     *   SatelliteResult:SATELLITE_RESULT_SERVICE_ERROR
-     *   SatelliteResult:SATELLITE_RESULT_MODEM_ERROR
-     *   SatelliteResult:SATELLITE_RESULT_INVALID_MODEM_STATE
-     *   SatelliteResult:SATELLITE_RESULT_INVALID_ARGUMENTS
-     *   SatelliteResult:SATELLITE_RESULT_RADIO_NOT_AVAILABLE
-     *   SatelliteResult:SATELLITE_RESULT_REQUEST_NOT_SUPPORTED
-     *   SatelliteResult:SATELLITE_RESULT_NO_RESOURCES
-     */
-    public void requestIsSatelliteCommunicationAllowedForCurrentLocation(
-            @NonNull IIntegerConsumer resultCallback, @NonNull IBooleanConsumer callback) {
-        // stub implementation
-    }
-
-    /**
      * Request to get the time after which the satellite will be visible. This is an int
      * representing the duration in seconds after which the satellite will be visible.
      * This will return 0 if the satellite is currently visible.
diff --git a/tests/CtsSurfaceControlTestsStaging/src/main/java/android/view/surfacecontroltests/SurfaceControlTest.java b/tests/CtsSurfaceControlTestsStaging/src/main/java/android/view/surfacecontroltests/SurfaceControlTest.java
index caaee63..4d48276 100644
--- a/tests/CtsSurfaceControlTestsStaging/src/main/java/android/view/surfacecontroltests/SurfaceControlTest.java
+++ b/tests/CtsSurfaceControlTestsStaging/src/main/java/android/view/surfacecontroltests/SurfaceControlTest.java
@@ -30,10 +30,12 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+@Ignore // b/330376055: Write tests for functionality for both dVRR and MRR devices.
 @RunWith(AndroidJUnit4.class)
 public class SurfaceControlTest {
     private static final String TAG = "SurfaceControlTest";
diff --git a/tests/PackageWatchdog/Android.bp b/tests/PackageWatchdog/Android.bp
index e0e6c4c..2c5fdd3 100644
--- a/tests/PackageWatchdog/Android.bp
+++ b/tests/PackageWatchdog/Android.bp
@@ -28,8 +28,10 @@
     static_libs: [
         "junit",
         "mockito-target-extended-minus-junit4",
+        "flag-junit",
         "frameworks-base-testutils",
         "androidx.test.rules",
+        "PlatformProperties",
         "services.core",
         "services.net",
         "truth",
diff --git a/tests/PackageWatchdog/src/com/android/server/CrashRecoveryTest.java b/tests/PackageWatchdog/src/com/android/server/CrashRecoveryTest.java
new file mode 100644
index 0000000..081da11
--- /dev/null
+++ b/tests/PackageWatchdog/src/com/android/server/CrashRecoveryTest.java
@@ -0,0 +1,644 @@
+/*
+ * Copyright (C) 2019 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;
+
+import static android.service.watchdog.ExplicitHealthCheckService.PackageConfig;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doAnswer;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertFalse;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.VersionedPackage;
+import android.content.rollback.PackageRollbackInfo;
+import android.content.rollback.RollbackInfo;
+import android.content.rollback.RollbackManager;
+import android.crashrecovery.flags.Flags;
+import android.net.ConnectivityModuleConnector;
+import android.net.ConnectivityModuleConnector.ConnectivityModuleHealthListener;
+import android.os.Handler;
+import android.os.SystemProperties;
+import android.os.test.TestLooper;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.provider.DeviceConfig;
+import android.util.AtomicFile;
+
+import androidx.test.InstrumentationRegistry;
+
+import com.android.dx.mockito.inline.extended.ExtendedMockito;
+import com.android.server.RescueParty.RescuePartyObserver;
+import com.android.server.pm.ApexManager;
+import com.android.server.rollback.RollbackPackageHealthObserver;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Answers;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+import org.mockito.stubbing.Answer;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * Test CrashRecovery, integration tests that include PackageWatchdog, RescueParty and
+ * RollbackPackageHealthObserver
+ */
+public class CrashRecoveryTest {
+    private static final String PROP_DEVICE_CONFIG_DISABLE_FLAG =
+            "persist.device_config.configuration.disable_rescue_party";
+
+    private static final String APP_A = "com.package.a";
+    private static final String APP_B = "com.package.b";
+    private static final String APP_C = "com.package.c";
+    private static final long VERSION_CODE = 1L;
+    private static final long SHORT_DURATION = TimeUnit.SECONDS.toMillis(1);
+
+    private static final RollbackInfo ROLLBACK_INFO_LOW = getRollbackInfo(APP_A, VERSION_CODE, 1,
+                PackageManager.ROLLBACK_USER_IMPACT_LOW);
+    private static final RollbackInfo ROLLBACK_INFO_HIGH = getRollbackInfo(APP_B, VERSION_CODE, 2,
+            PackageManager.ROLLBACK_USER_IMPACT_HIGH);
+    private static final RollbackInfo ROLLBACK_INFO_MANUAL = getRollbackInfo(APP_C, VERSION_CODE, 3,
+            PackageManager.ROLLBACK_USER_IMPACT_ONLY_MANUAL);
+
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+    private final TestClock mTestClock = new TestClock();
+    private TestLooper mTestLooper;
+    private Context mSpyContext;
+    // Keep track of all created watchdogs to apply device config changes
+    private List<PackageWatchdog> mAllocatedWatchdogs;
+    @Mock
+    private ConnectivityModuleConnector mConnectivityModuleConnector;
+    @Mock
+    private PackageManager mMockPackageManager;
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ApexManager mApexManager;
+    @Mock
+    RollbackManager mRollbackManager;
+    // Mock only sysprop apis
+    private PackageWatchdog.BootThreshold mSpyBootThreshold;
+    @Captor
+    private ArgumentCaptor<ConnectivityModuleHealthListener> mConnectivityModuleCallbackCaptor;
+    private MockitoSession mSession;
+    private HashMap<String, String> mSystemSettingsMap;
+    private HashMap<String, String> mCrashRecoveryPropertiesMap;
+
+    @Before
+    public void setUp() throws Exception {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
+        MockitoAnnotations.initMocks(this);
+        new File(InstrumentationRegistry.getContext().getFilesDir(),
+                "package-watchdog.xml").delete();
+        adoptShellPermissions(Manifest.permission.READ_DEVICE_CONFIG,
+                Manifest.permission.WRITE_DEVICE_CONFIG);
+        mTestLooper = new TestLooper();
+        mSpyContext = spy(InstrumentationRegistry.getContext());
+        when(mSpyContext.getPackageManager()).thenReturn(mMockPackageManager);
+        when(mMockPackageManager.getPackageInfo(anyString(), anyInt())).then(inv -> {
+            final PackageInfo res = new PackageInfo();
+            res.packageName = inv.getArgument(0);
+            res.setLongVersionCode(VERSION_CODE);
+            return res;
+        });
+        mSession = ExtendedMockito.mockitoSession()
+                .initMocks(this)
+                .strictness(Strictness.LENIENT)
+                .spyStatic(SystemProperties.class)
+                .spyStatic(RescueParty.class)
+                .startMocking();
+        mSystemSettingsMap = new HashMap<>();
+
+        // Mock SystemProperties setter and various getters
+        doAnswer((Answer<Void>) invocationOnMock -> {
+                    String key = invocationOnMock.getArgument(0);
+                    String value = invocationOnMock.getArgument(1);
+
+                    mSystemSettingsMap.put(key, value);
+                    return null;
+                }
+        ).when(() -> SystemProperties.set(anyString(), anyString()));
+
+        doAnswer((Answer<Integer>) invocationOnMock -> {
+                    String key = invocationOnMock.getArgument(0);
+                    int defaultValue = invocationOnMock.getArgument(1);
+
+                    String storedValue = mSystemSettingsMap.get(key);
+                    return storedValue == null ? defaultValue : Integer.parseInt(storedValue);
+                }
+        ).when(() -> SystemProperties.getInt(anyString(), anyInt()));
+
+        doAnswer((Answer<Long>) invocationOnMock -> {
+                    String key = invocationOnMock.getArgument(0);
+                    long defaultValue = invocationOnMock.getArgument(1);
+
+                    String storedValue = mSystemSettingsMap.get(key);
+                    return storedValue == null ? defaultValue : Long.parseLong(storedValue);
+                }
+        ).when(() -> SystemProperties.getLong(anyString(), anyLong()));
+
+        doAnswer((Answer<Boolean>) invocationOnMock -> {
+                    String key = invocationOnMock.getArgument(0);
+                    boolean defaultValue = invocationOnMock.getArgument(1);
+
+                    String storedValue = mSystemSettingsMap.get(key);
+                    return storedValue == null ? defaultValue : Boolean.parseBoolean(storedValue);
+                }
+        ).when(() -> SystemProperties.getBoolean(anyString(), anyBoolean()));
+
+        SystemProperties.set(RescueParty.PROP_ENABLE_RESCUE, Boolean.toString(true));
+        SystemProperties.set(PROP_DEVICE_CONFIG_DISABLE_FLAG, Boolean.toString(false));
+
+        DeviceConfig.setProperty(DeviceConfig.NAMESPACE_ROLLBACK,
+                PackageWatchdog.PROPERTY_WATCHDOG_EXPLICIT_HEALTH_CHECK_ENABLED,
+                Boolean.toString(true), false);
+
+        DeviceConfig.setProperty(DeviceConfig.NAMESPACE_ROLLBACK,
+                PackageWatchdog.PROPERTY_WATCHDOG_TRIGGER_FAILURE_COUNT,
+                Integer.toString(PackageWatchdog.DEFAULT_TRIGGER_FAILURE_COUNT), false);
+
+        mAllocatedWatchdogs = new ArrayList<>();
+        RescuePartyObserver.reset();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        dropShellPermissions();
+        mSession.finishMocking();
+        // Clean up listeners since too many listeners will delay notifications significantly
+        for (PackageWatchdog watchdog : mAllocatedWatchdogs) {
+            watchdog.removePropertyChangedListener();
+        }
+        mAllocatedWatchdogs.clear();
+    }
+
+    @Test
+    public void testBootLoopWithRescueParty() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        RescuePartyObserver rescuePartyObserver = setUpRescuePartyObserver(watchdog);
+
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(1);
+        int bootCounter = 0;
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT; i++) {
+            watchdog.noteBoot();
+            bootCounter += 1;
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(1);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(2);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+            bootCounter += 1;
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(2);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(3);
+
+        int bootLoopThreshold = PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD - bootCounter;
+        for (int i = 0; i < bootLoopThreshold; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(3);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(4);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(4);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(5);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(5);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(6);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(6);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(7);
+    }
+
+    @Test
+    public void testBootLoopWithRollbackPackageHealthObserver() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        RollbackPackageHealthObserver rollbackObserver =
+                setUpRollbackPackageHealthObserver(watchdog);
+
+        verify(rollbackObserver, never()).executeBootLoopMitigation(1);
+        int bootCounter = 0;
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT; i++) {
+            watchdog.noteBoot();
+            bootCounter += 1;
+        }
+        verify(rollbackObserver).executeBootLoopMitigation(1);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(2);
+
+        // Update the list of available rollbacks after executing bootloop mitigation once
+        when(mRollbackManager.getAvailableRollbacks()).thenReturn(List.of(ROLLBACK_INFO_HIGH,
+                ROLLBACK_INFO_MANUAL));
+
+        int bootLoopThreshold = PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD - bootCounter;
+        for (int i = 0; i < bootLoopThreshold; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rollbackObserver).executeBootLoopMitigation(2);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(3);
+
+        // Update the list of available rollbacks after executing bootloop mitigation once
+        when(mRollbackManager.getAvailableRollbacks()).thenReturn(List.of(ROLLBACK_INFO_MANUAL));
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rollbackObserver, never()).executeBootLoopMitigation(3);
+    }
+
+    @Test
+    public void testBootLoopWithRescuePartyAndRollbackPackageHealthObserver() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        RescuePartyObserver rescuePartyObserver = setUpRescuePartyObserver(watchdog);
+        RollbackPackageHealthObserver rollbackObserver =
+                setUpRollbackPackageHealthObserver(watchdog);
+
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(1);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(1);
+        int bootCounter = 0;
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT; i++) {
+            watchdog.noteBoot();
+            bootCounter += 1;
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(1);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(2);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(1);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+            bootCounter += 1;
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(2);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(3);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(2);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+            bootCounter += 1;
+        }
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(3);
+        verify(rollbackObserver).executeBootLoopMitigation(1);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(2);
+        // Update the list of available rollbacks after executing bootloop mitigation once
+        when(mRollbackManager.getAvailableRollbacks()).thenReturn(List.of(ROLLBACK_INFO_HIGH,
+                ROLLBACK_INFO_MANUAL));
+
+        int bootLoopThreshold = PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD - bootCounter;
+        for (int i = 0; i < bootLoopThreshold; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(3);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(4);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(2);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(4);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(5);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(2);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(5);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(6);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(2);
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(6);
+        verify(rollbackObserver).executeBootLoopMitigation(2);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(3);
+        // Update the list of available rollbacks after executing bootloop mitigation
+        when(mRollbackManager.getAvailableRollbacks()).thenReturn(List.of(ROLLBACK_INFO_MANUAL));
+
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; i++) {
+            watchdog.noteBoot();
+        }
+        verify(rescuePartyObserver).executeBootLoopMitigation(6);
+        verify(rescuePartyObserver, never()).executeBootLoopMitigation(7);
+        verify(rollbackObserver, never()).executeBootLoopMitigation(3);
+    }
+
+    RollbackPackageHealthObserver setUpRollbackPackageHealthObserver(PackageWatchdog watchdog) {
+        RollbackPackageHealthObserver rollbackObserver =
+                spy(new RollbackPackageHealthObserver(mSpyContext, mApexManager));
+        when(mSpyContext.getSystemService(RollbackManager.class)).thenReturn(mRollbackManager);
+        when(mRollbackManager.getAvailableRollbacks()).thenReturn(List.of(ROLLBACK_INFO_LOW,
+                ROLLBACK_INFO_HIGH, ROLLBACK_INFO_MANUAL));
+        when(mSpyContext.getPackageManager()).thenReturn(mMockPackageManager);
+
+        watchdog.registerHealthObserver(rollbackObserver);
+        return rollbackObserver;
+    }
+
+    RescuePartyObserver setUpRescuePartyObserver(PackageWatchdog watchdog) {
+        setCrashRecoveryPropRescueBootCount(0);
+        RescuePartyObserver rescuePartyObserver = spy(RescuePartyObserver.getInstance(mSpyContext));
+        assertFalse(RescueParty.isRebootPropertySet());
+        watchdog.registerHealthObserver(rescuePartyObserver);
+        return rescuePartyObserver;
+    }
+
+    private static RollbackInfo getRollbackInfo(String packageName, long versionCode,
+            int rollbackId, int rollbackUserImpact) {
+        VersionedPackage appFrom = new VersionedPackage(packageName, versionCode + 1);
+        VersionedPackage appTo = new VersionedPackage(packageName, versionCode);
+        PackageRollbackInfo packageRollbackInfo = new PackageRollbackInfo(appFrom, appTo, null,
+                null, false, false, null);
+        RollbackInfo rollbackInfo = new RollbackInfo(rollbackId, List.of(packageRollbackInfo),
+                false, null, 111, rollbackUserImpact);
+        return rollbackInfo;
+    }
+
+    private void adoptShellPermissions(String... permissions) {
+        androidx.test.platform.app.InstrumentationRegistry
+                .getInstrumentation()
+                .getUiAutomation()
+                .adoptShellPermissionIdentity(permissions);
+    }
+
+    private void dropShellPermissions() {
+        androidx.test.platform.app.InstrumentationRegistry
+                .getInstrumentation()
+                .getUiAutomation()
+                .dropShellPermissionIdentity();
+    }
+
+
+    private PackageWatchdog createWatchdog() {
+        return createWatchdog(new TestController(), true /* withPackagesReady */);
+    }
+
+    private PackageWatchdog createWatchdog(TestController controller, boolean withPackagesReady) {
+        AtomicFile policyFile =
+                new AtomicFile(new File(mSpyContext.getFilesDir(), "package-watchdog.xml"));
+        Handler handler = new Handler(mTestLooper.getLooper());
+        PackageWatchdog watchdog =
+                new PackageWatchdog(mSpyContext, policyFile, handler, handler, controller,
+                        mConnectivityModuleConnector, mTestClock);
+        mockCrashRecoveryProperties(watchdog);
+
+        // Verify controller is not automatically started
+        assertThat(controller.mIsEnabled).isFalse();
+        if (withPackagesReady) {
+            // Only capture the NetworkStack callback for the latest registered watchdog
+            reset(mConnectivityModuleConnector);
+            watchdog.onPackagesReady();
+            // Verify controller by default is started when packages are ready
+            assertThat(controller.mIsEnabled).isTrue();
+
+            verify(mConnectivityModuleConnector).registerHealthListener(
+                    mConnectivityModuleCallbackCaptor.capture());
+        }
+        mAllocatedWatchdogs.add(watchdog);
+        return watchdog;
+    }
+
+    // Mock CrashRecoveryProperties as they cannot be accessed due to SEPolicy restrictions
+    private void mockCrashRecoveryProperties(PackageWatchdog watchdog) {
+        mCrashRecoveryPropertiesMap = new HashMap<>();
+
+        // mock properties in RescueParty
+        try {
+
+            doAnswer((Answer<Boolean>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.attempting_factory_reset", "false");
+                return Boolean.parseBoolean(storedValue);
+            }).when(() -> RescueParty.isFactoryResetPropertySet());
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                boolean value = invocationOnMock.getArgument(0);
+                mCrashRecoveryPropertiesMap.put("crashrecovery.attempting_factory_reset",
+                        Boolean.toString(value));
+                return null;
+            }).when(() -> RescueParty.setFactoryResetProperty(anyBoolean()));
+
+            doAnswer((Answer<Boolean>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.attempting_reboot", "false");
+                return Boolean.parseBoolean(storedValue);
+            }).when(() -> RescueParty.isRebootPropertySet());
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                boolean value = invocationOnMock.getArgument(0);
+                setCrashRecoveryPropAttemptingReboot(value);
+                return null;
+            }).when(() -> RescueParty.setRebootProperty(anyBoolean()));
+
+            doAnswer((Answer<Long>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("persist.crashrecovery.last_factory_reset", "0");
+                return Long.parseLong(storedValue);
+            }).when(() -> RescueParty.getLastFactoryResetTimeMs());
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                long value = invocationOnMock.getArgument(0);
+                setCrashRecoveryPropLastFactoryReset(value);
+                return null;
+            }).when(() -> RescueParty.setLastFactoryResetTimeMs(anyLong()));
+
+            doAnswer((Answer<Integer>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.max_rescue_level_attempted", "0");
+                return Integer.parseInt(storedValue);
+            }).when(() -> RescueParty.getMaxRescueLevelAttempted());
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                int value = invocationOnMock.getArgument(0);
+                mCrashRecoveryPropertiesMap.put("crashrecovery.max_rescue_level_attempted",
+                        Integer.toString(value));
+                return null;
+            }).when(() -> RescueParty.setMaxRescueLevelAttempted(anyInt()));
+
+        } catch (Exception e) {
+            // tests will fail, just printing the error
+            System.out.println("Error while mocking crashrecovery properties " + e.getMessage());
+        }
+
+        try {
+            if (Flags.recoverabilityDetection()) {
+                mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT));
+            } else {
+                mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS));
+            }
+
+            doAnswer((Answer<Integer>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.rescue_boot_count", "0");
+                return Integer.parseInt(storedValue);
+            }).when(mSpyBootThreshold).getCount();
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                int count = invocationOnMock.getArgument(0);
+                mCrashRecoveryPropertiesMap.put("crashrecovery.rescue_boot_count",
+                        Integer.toString(count));
+                return null;
+            }).when(mSpyBootThreshold).setCount(anyInt());
+
+            doAnswer((Answer<Integer>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.boot_mitigation_count", "0");
+                return Integer.parseInt(storedValue);
+            }).when(mSpyBootThreshold).getMitigationCount();
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                int count = invocationOnMock.getArgument(0);
+                mCrashRecoveryPropertiesMap.put("crashrecovery.boot_mitigation_count",
+                        Integer.toString(count));
+                return null;
+            }).when(mSpyBootThreshold).setMitigationCount(anyInt());
+
+            doAnswer((Answer<Long>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.rescue_boot_start", "0");
+                return Long.parseLong(storedValue);
+            }).when(mSpyBootThreshold).getStart();
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                long count = invocationOnMock.getArgument(0);
+                mCrashRecoveryPropertiesMap.put("crashrecovery.rescue_boot_start",
+                        Long.toString(count));
+                return null;
+            }).when(mSpyBootThreshold).setStart(anyLong());
+
+            doAnswer((Answer<Long>) invocationOnMock -> {
+                String storedValue = mCrashRecoveryPropertiesMap
+                        .getOrDefault("crashrecovery.boot_mitigation_start", "0");
+                return Long.parseLong(storedValue);
+            }).when(mSpyBootThreshold).getMitigationStart();
+            doAnswer((Answer<Void>) invocationOnMock -> {
+                long count = invocationOnMock.getArgument(0);
+                mCrashRecoveryPropertiesMap.put("crashrecovery.boot_mitigation_start",
+                        Long.toString(count));
+                return null;
+            }).when(mSpyBootThreshold).setMitigationStart(anyLong());
+
+            Field mBootThresholdField = watchdog.getClass().getDeclaredField("mBootThreshold");
+            mBootThresholdField.setAccessible(true);
+            mBootThresholdField.set(watchdog, mSpyBootThreshold);
+        } catch (Exception e) {
+            // tests will fail, just printing the error
+            System.out.println("Error detected while spying BootThreshold" + e.getMessage());
+        }
+    }
+
+    private void setCrashRecoveryPropRescueBootCount(int count) {
+        mCrashRecoveryPropertiesMap.put("crashrecovery.rescue_boot_count",
+                Integer.toString(count));
+    }
+
+    private void setCrashRecoveryPropAttemptingReboot(boolean value) {
+        mCrashRecoveryPropertiesMap.put("crashrecovery.attempting_reboot",
+                Boolean.toString(value));
+    }
+
+    private void setCrashRecoveryPropLastFactoryReset(long value) {
+        mCrashRecoveryPropertiesMap.put("persist.crashrecovery.last_factory_reset",
+                Long.toString(value));
+    }
+
+    private static class TestController extends ExplicitHealthCheckController {
+        TestController() {
+            super(null /* controller */);
+        }
+
+        private boolean mIsEnabled;
+        private List<String> mSupportedPackages = new ArrayList<>();
+        private List<String> mRequestedPackages = new ArrayList<>();
+        private Consumer<List<PackageConfig>> mSupportedConsumer;
+        private List<Set> mSyncRequests = new ArrayList<>();
+
+        @Override
+        public void setEnabled(boolean enabled) {
+            mIsEnabled = enabled;
+            if (!mIsEnabled) {
+                mSupportedPackages.clear();
+            }
+        }
+
+        @Override
+        public void setCallbacks(Consumer<String> passedConsumer,
+                Consumer<List<PackageConfig>> supportedConsumer, Runnable notifySyncRunnable) {
+            mSupportedConsumer = supportedConsumer;
+        }
+
+        @Override
+        public void syncRequests(Set<String> packages) {
+            mSyncRequests.add(packages);
+            mRequestedPackages.clear();
+            if (mIsEnabled) {
+                packages.retainAll(mSupportedPackages);
+                mRequestedPackages.addAll(packages);
+                List<PackageConfig> packageConfigs = new ArrayList<>();
+                for (String packageName: packages) {
+                    packageConfigs.add(new PackageConfig(packageName, SHORT_DURATION));
+                }
+                mSupportedConsumer.accept(packageConfigs);
+            } else {
+                mSupportedConsumer.accept(Collections.emptyList());
+            }
+        }
+    }
+
+    private static class TestClock implements PackageWatchdog.SystemClock {
+        // Note 0 is special to the internal clock of PackageWatchdog. We need to start from
+        // a non-zero value in order not to disrupt the logic of PackageWatchdog.
+        private long mUpTimeMillis = 1;
+        @Override
+        public long uptimeMillis() {
+            return mUpTimeMillis;
+        }
+    }
+}
diff --git a/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java b/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java
index 75284c7..4f27e06 100644
--- a/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java
+++ b/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java
@@ -36,11 +36,13 @@
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.VersionedPackage;
+import android.crashrecovery.flags.Flags;
 import android.net.ConnectivityModuleConnector;
 import android.net.ConnectivityModuleConnector.ConnectivityModuleHealthListener;
 import android.os.Handler;
 import android.os.SystemProperties;
 import android.os.test.TestLooper;
+import android.platform.test.flag.junit.SetFlagsRule;
 import android.provider.DeviceConfig;
 import android.util.AtomicFile;
 import android.util.Xml;
@@ -54,11 +56,13 @@
 import com.android.modules.utils.TypedXmlSerializer;
 import com.android.server.PackageWatchdog.HealthCheckState;
 import com.android.server.PackageWatchdog.MonitoredPackage;
+import com.android.server.PackageWatchdog.ObserverInternal;
 import com.android.server.PackageWatchdog.PackageHealthObserver;
 import com.android.server.PackageWatchdog.PackageHealthObserverImpact;
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
@@ -99,6 +103,10 @@
     private static final String OBSERVER_NAME_4 = "observer4";
     private static final long SHORT_DURATION = TimeUnit.SECONDS.toMillis(1);
     private static final long LONG_DURATION = TimeUnit.SECONDS.toMillis(5);
+
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
     private final TestClock mTestClock = new TestClock();
     private TestLooper mTestLooper;
     private Context mSpyContext;
@@ -128,6 +136,7 @@
 
     @Before
     public void setUp() throws Exception {
+        mSetFlagsRule.enableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         MockitoAnnotations.initMocks(this);
         new File(InstrumentationRegistry.getContext().getFilesDir(),
                 "package-watchdog.xml").delete();
@@ -444,6 +453,7 @@
      */
     @Test
     public void testPackageFailureNotifyAllDifferentImpacts() throws Exception {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         PackageWatchdog watchdog = createWatchdog();
         TestObserver observerNone = new TestObserver(OBSERVER_NAME_1,
                 PackageHealthObserverImpact.USER_IMPACT_LEVEL_0);
@@ -488,6 +498,52 @@
         assertThat(observerLowPackages).containsExactly(APP_A);
     }
 
+    @Test
+    public void testPackageFailureNotifyAllDifferentImpactsRecoverability() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver observerNone = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_0);
+        TestObserver observerHigh = new TestObserver(OBSERVER_NAME_2,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_50);
+        TestObserver observerMid = new TestObserver(OBSERVER_NAME_3,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_30);
+        TestObserver observerLow = new TestObserver(OBSERVER_NAME_4,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_10);
+
+        // Start observing for all impact observers
+        watchdog.startObservingHealth(observerNone, Arrays.asList(APP_A, APP_B, APP_C, APP_D),
+                SHORT_DURATION);
+        watchdog.startObservingHealth(observerHigh, Arrays.asList(APP_A, APP_B, APP_C),
+                SHORT_DURATION);
+        watchdog.startObservingHealth(observerMid, Arrays.asList(APP_A, APP_B),
+                SHORT_DURATION);
+        watchdog.startObservingHealth(observerLow, Arrays.asList(APP_A),
+                SHORT_DURATION);
+
+        // Then fail all apps above the threshold
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
+                        new VersionedPackage(APP_B, VERSION_CODE),
+                        new VersionedPackage(APP_C, VERSION_CODE),
+                        new VersionedPackage(APP_D, VERSION_CODE)),
+                PackageWatchdog.FAILURE_REASON_UNKNOWN);
+
+        // Verify least impact observers are notifed of package failures
+        List<String> observerNonePackages = observerNone.mMitigatedPackages;
+        List<String> observerHighPackages = observerHigh.mMitigatedPackages;
+        List<String> observerMidPackages = observerMid.mMitigatedPackages;
+        List<String> observerLowPackages = observerLow.mMitigatedPackages;
+
+        // APP_D failure observed by only observerNone is not caught cos its impact is none
+        assertThat(observerNonePackages).isEmpty();
+        // APP_C failure is caught by observerHigh cos it's the lowest impact observer
+        assertThat(observerHighPackages).containsExactly(APP_C);
+        // APP_B failure is caught by observerMid cos it's the lowest impact observer
+        assertThat(observerMidPackages).containsExactly(APP_B);
+        // APP_A failure is caught by observerLow cos it's the lowest impact observer
+        assertThat(observerLowPackages).containsExactly(APP_A);
+    }
+
     /**
      * Test package failure and least impact observers are notified successively.
      * State transistions:
@@ -501,6 +557,7 @@
      */
     @Test
     public void testPackageFailureNotifyLeastImpactSuccessively() throws Exception {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         PackageWatchdog watchdog = createWatchdog();
         TestObserver observerFirst = new TestObserver(OBSERVER_NAME_1,
                 PackageHealthObserverImpact.USER_IMPACT_LEVEL_10);
@@ -563,11 +620,76 @@
         assertThat(observerSecond.mMitigatedPackages).isEmpty();
     }
 
+    @Test
+    public void testPackageFailureNotifyLeastImpactSuccessivelyRecoverability() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver observerFirst = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_10);
+        TestObserver observerSecond = new TestObserver(OBSERVER_NAME_2,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_30);
+
+        // Start observing for observerFirst and observerSecond with failure handling
+        watchdog.startObservingHealth(observerFirst, Arrays.asList(APP_A), LONG_DURATION);
+        watchdog.startObservingHealth(observerSecond, Arrays.asList(APP_A), LONG_DURATION);
+
+        // Then fail APP_A above the threshold
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)),
+                PackageWatchdog.FAILURE_REASON_UNKNOWN);
+
+        // Verify only observerFirst is notifed
+        assertThat(observerFirst.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observerSecond.mMitigatedPackages).isEmpty();
+
+        // After observerFirst handles failure, next action it has is high impact
+        observerFirst.mImpact = PackageHealthObserverImpact.USER_IMPACT_LEVEL_50;
+        observerFirst.mMitigatedPackages.clear();
+        observerSecond.mMitigatedPackages.clear();
+
+        // Then fail APP_A again above the threshold
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)),
+                PackageWatchdog.FAILURE_REASON_UNKNOWN);
+
+        // Verify only observerSecond is notifed cos it has least impact
+        assertThat(observerSecond.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observerFirst.mMitigatedPackages).isEmpty();
+
+        // After observerSecond handles failure, it has no further actions
+        observerSecond.mImpact = PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
+        observerFirst.mMitigatedPackages.clear();
+        observerSecond.mMitigatedPackages.clear();
+
+        // Then fail APP_A again above the threshold
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)),
+                PackageWatchdog.FAILURE_REASON_UNKNOWN);
+
+        // Verify only observerFirst is notifed cos it has the only action
+        assertThat(observerFirst.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observerSecond.mMitigatedPackages).isEmpty();
+
+        // After observerFirst handles failure, it too has no further actions
+        observerFirst.mImpact = PackageHealthObserverImpact.USER_IMPACT_LEVEL_0;
+        observerFirst.mMitigatedPackages.clear();
+        observerSecond.mMitigatedPackages.clear();
+
+        // Then fail APP_A again above the threshold
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)),
+                PackageWatchdog.FAILURE_REASON_UNKNOWN);
+
+        // Verify no observer is notified cos no actions left
+        assertThat(observerFirst.mMitigatedPackages).isEmpty();
+        assertThat(observerSecond.mMitigatedPackages).isEmpty();
+    }
+
     /**
      * Test package failure and notifies only one observer even with observer impact tie.
      */
     @Test
     public void testPackageFailureNotifyOneSameImpact() throws Exception {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         PackageWatchdog watchdog = createWatchdog();
         TestObserver observer1 = new TestObserver(OBSERVER_NAME_1,
                 PackageHealthObserverImpact.USER_IMPACT_LEVEL_100);
@@ -588,6 +710,28 @@
         assertThat(observer2.mMitigatedPackages).isEmpty();
     }
 
+    @Test
+    public void testPackageFailureNotifyOneSameImpactRecoverabilityDetection() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver observer1 = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_50);
+        TestObserver observer2 = new TestObserver(OBSERVER_NAME_2,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_50);
+
+        // Start observing for observer1 and observer2 with failure handling
+        watchdog.startObservingHealth(observer2, Arrays.asList(APP_A), SHORT_DURATION);
+        watchdog.startObservingHealth(observer1, Arrays.asList(APP_A), SHORT_DURATION);
+
+        // Then fail APP_A above the threshold
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)),
+                PackageWatchdog.FAILURE_REASON_UNKNOWN);
+
+        // Verify only one observer is notifed
+        assertThat(observer1.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observer2.mMitigatedPackages).isEmpty();
+    }
+
     /**
      * Test package passing explicit health checks does not fail and vice versa.
      */
@@ -818,6 +962,7 @@
 
     @Test
     public void testNetworkStackFailure() {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         final PackageWatchdog wd = createWatchdog();
 
         // Start observing with failure handling
@@ -835,6 +980,25 @@
         assertThat(observer.mMitigatedPackages).containsExactly(APP_A);
     }
 
+    @Test
+    public void testNetworkStackFailureRecoverabilityDetection() {
+        final PackageWatchdog wd = createWatchdog();
+
+        // Start observing with failure handling
+        TestObserver observer = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_100);
+        wd.startObservingHealth(observer, Collections.singletonList(APP_A), SHORT_DURATION);
+
+        // Notify of NetworkStack failure
+        mConnectivityModuleCallbackCaptor.getValue().onNetworkStackFailure(APP_A);
+
+        // Run handler so package failures are dispatched to observers
+        mTestLooper.dispatchAll();
+
+        // Verify the NetworkStack observer is notified
+        assertThat(observer.mMitigatedPackages).isEmpty();
+    }
+
     /** Test default values are used when device property is invalid. */
     @Test
     public void testInvalidConfig_watchdogTriggerFailureCount() {
@@ -1045,6 +1209,7 @@
     /** Ensure that boot loop mitigation is done when the number of boots meets the threshold. */
     @Test
     public void testBootLoopDetection_meetsThreshold() {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         PackageWatchdog watchdog = createWatchdog();
         TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1);
         watchdog.registerHealthObserver(bootObserver);
@@ -1054,6 +1219,16 @@
         assertThat(bootObserver.mitigatedBootLoop()).isTrue();
     }
 
+    @Test
+    public void testBootLoopDetection_meetsThresholdRecoverability() {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1);
+        watchdog.registerHealthObserver(bootObserver);
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD; i++) {
+            watchdog.noteBoot();
+        }
+        assertThat(bootObserver.mitigatedBootLoop()).isTrue();
+    }
 
     /**
      * Ensure that boot loop mitigation is not done when the number of boots does not meet the
@@ -1071,10 +1246,43 @@
     }
 
     /**
+     * Ensure that boot loop mitigation is not done when the number of boots does not meet the
+     * threshold.
+     */
+    @Test
+    public void testBootLoopDetection_doesNotMeetThresholdRecoverabilityLowImpact() {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_30);
+        watchdog.registerHealthObserver(bootObserver);
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT - 1; i++) {
+            watchdog.noteBoot();
+        }
+        assertThat(bootObserver.mitigatedBootLoop()).isFalse();
+    }
+
+    /**
+     * Ensure that boot loop mitigation is not done when the number of boots does not meet the
+     * threshold.
+     */
+    @Test
+    public void testBootLoopDetection_doesNotMeetThresholdRecoverabilityHighImpact() {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_80);
+        watchdog.registerHealthObserver(bootObserver);
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD - 1; i++) {
+            watchdog.noteBoot();
+        }
+        assertThat(bootObserver.mitigatedBootLoop()).isFalse();
+    }
+
+    /**
      * Ensure that boot loop mitigation is done for the observer with the lowest user impact
      */
     @Test
     public void testBootLoopMitigationDoneForLowestUserImpact() {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         PackageWatchdog watchdog = createWatchdog();
         TestObserver bootObserver1 = new TestObserver(OBSERVER_NAME_1);
         bootObserver1.setImpact(PackageHealthObserverImpact.USER_IMPACT_LEVEL_10);
@@ -1089,11 +1297,28 @@
         assertThat(bootObserver2.mitigatedBootLoop()).isFalse();
     }
 
+    @Test
+    public void testBootLoopMitigationDoneForLowestUserImpactRecoverability() {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver bootObserver1 = new TestObserver(OBSERVER_NAME_1);
+        bootObserver1.setImpact(PackageHealthObserverImpact.USER_IMPACT_LEVEL_10);
+        TestObserver bootObserver2 = new TestObserver(OBSERVER_NAME_2);
+        bootObserver2.setImpact(PackageHealthObserverImpact.USER_IMPACT_LEVEL_30);
+        watchdog.registerHealthObserver(bootObserver1);
+        watchdog.registerHealthObserver(bootObserver2);
+        for (int i = 0; i < PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD; i++) {
+            watchdog.noteBoot();
+        }
+        assertThat(bootObserver1.mitigatedBootLoop()).isTrue();
+        assertThat(bootObserver2.mitigatedBootLoop()).isFalse();
+    }
+
     /**
      * Ensure that the correct mitigation counts are sent to the boot loop observer.
      */
     @Test
     public void testMultipleBootLoopMitigation() {
+        mSetFlagsRule.disableFlags(Flags.FLAG_RECOVERABILITY_DETECTION);
         PackageWatchdog watchdog = createWatchdog();
         TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1);
         watchdog.registerHealthObserver(bootObserver);
@@ -1114,6 +1339,64 @@
         assertThat(bootObserver.mBootMitigationCounts).isEqualTo(List.of(1, 2, 3, 4, 1, 2, 3, 4));
     }
 
+    @Test
+    public void testMultipleBootLoopMitigationRecoverabilityLowImpact() {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_30);
+        watchdog.registerHealthObserver(bootObserver);
+        for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT - 1; j++) {
+            watchdog.noteBoot();
+        }
+        for (int i = 0; i < 4; i++) {
+            for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; j++) {
+                watchdog.noteBoot();
+            }
+        }
+
+        moveTimeForwardAndDispatch(PackageWatchdog.DEFAULT_DEESCALATION_WINDOW_MS + 1);
+
+        for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT - 1; j++) {
+            watchdog.noteBoot();
+        }
+        for (int i = 0; i < 4; i++) {
+            for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; j++) {
+                watchdog.noteBoot();
+            }
+        }
+
+        assertThat(bootObserver.mBootMitigationCounts).isEqualTo(List.of(1, 2, 3, 4, 1, 2, 3, 4));
+    }
+
+    @Test
+    public void testMultipleBootLoopMitigationRecoverabilityHighImpact() {
+        PackageWatchdog watchdog = createWatchdog();
+        TestObserver bootObserver = new TestObserver(OBSERVER_NAME_1,
+                PackageHealthObserverImpact.USER_IMPACT_LEVEL_80);
+        watchdog.registerHealthObserver(bootObserver);
+        for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD - 1; j++) {
+            watchdog.noteBoot();
+        }
+        for (int i = 0; i < 4; i++) {
+            for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; j++) {
+                watchdog.noteBoot();
+            }
+        }
+
+        moveTimeForwardAndDispatch(PackageWatchdog.DEFAULT_DEESCALATION_WINDOW_MS + 1);
+
+        for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_THRESHOLD - 1; j++) {
+            watchdog.noteBoot();
+        }
+        for (int i = 0; i < 4; i++) {
+            for (int j = 0; j < PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT; j++) {
+                watchdog.noteBoot();
+            }
+        }
+
+        assertThat(bootObserver.mBootMitigationCounts).isEqualTo(List.of(1, 2, 3, 4, 1, 2, 3, 4));
+    }
+
     /**
      * Ensure that passing a null list of failed packages does not cause any mitigation logic to
      * execute.
@@ -1304,6 +1587,78 @@
     }
 
     /**
+     * Ensure that a {@link ObserverInternal} may be correctly written and read in order to persist
+     * across reboots.
+     */
+    @Test
+    @SuppressWarnings("GuardedBy")
+    public void testWritingAndReadingObserverInternalRecoverability() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+
+        LongArrayQueue mitigationCalls = new LongArrayQueue();
+        mitigationCalls.addLast(1000);
+        mitigationCalls.addLast(2000);
+        mitigationCalls.addLast(3000);
+        MonitoredPackage writePkg = watchdog.newMonitoredPackage(
+                "test.package", 1000, 2000, true, mitigationCalls);
+        final int bootMitigationCount = 4;
+        ObserverInternal writeObserver = new ObserverInternal("test", List.of(writePkg),
+                bootMitigationCount);
+
+        // Write the observer
+        File tmpFile = File.createTempFile("observer-watchdog-test", ".xml");
+        AtomicFile testFile = new AtomicFile(tmpFile);
+        FileOutputStream stream = testFile.startWrite();
+        TypedXmlSerializer outputSerializer = Xml.resolveSerializer(stream);
+        outputSerializer.startDocument(null, true);
+        writeObserver.writeLocked(outputSerializer);
+        outputSerializer.endDocument();
+        testFile.finishWrite(stream);
+
+        // Read the observer
+        TypedXmlPullParser parser = Xml.resolvePullParser(testFile.openRead());
+        XmlUtils.beginDocument(parser, "observer");
+        ObserverInternal readObserver = ObserverInternal.read(parser, watchdog);
+
+        assertThat(readObserver.name).isEqualTo(writeObserver.name);
+        assertThat(readObserver.getBootMitigationCount()).isEqualTo(bootMitigationCount);
+    }
+
+    /**
+     * Ensure that boot mitigation counts may be correctly written and read as metadata
+     * in order to persist across reboots.
+     */
+    @Test
+    @SuppressWarnings("GuardedBy")
+    public void testWritingAndReadingMetadataBootMitigationCountRecoverability() throws Exception {
+        PackageWatchdog watchdog = createWatchdog();
+        String filePath = InstrumentationRegistry.getContext().getFilesDir().toString()
+                + "metadata_file.txt";
+
+        ObserverInternal observer1 = new ObserverInternal("test1", List.of(), 1);
+        ObserverInternal observer2 = new ObserverInternal("test2", List.of(), 2);
+        watchdog.registerObserverInternal(observer1);
+        watchdog.registerObserverInternal(observer2);
+
+        mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS,
+                PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT));
+
+        watchdog.saveAllObserversBootMitigationCountToMetadata(filePath);
+
+        observer1.setBootMitigationCount(0);
+        observer2.setBootMitigationCount(0);
+        assertThat(observer1.getBootMitigationCount()).isEqualTo(0);
+        assertThat(observer2.getBootMitigationCount()).isEqualTo(0);
+
+        mSpyBootThreshold.readAllObserversBootMitigationCountIfNecessary(filePath);
+
+        assertThat(observer1.getBootMitigationCount()).isEqualTo(1);
+        assertThat(observer2.getBootMitigationCount()).isEqualTo(2);
+    }
+
+    /**
      * Tests device config changes are propagated correctly.
      */
     @Test
@@ -1440,11 +1795,19 @@
 
     // Mock CrashRecoveryProperties as they cannot be accessed due to SEPolicy restrictions
     private void mockCrashRecoveryProperties(PackageWatchdog watchdog) {
+        mCrashRecoveryPropertiesMap = new HashMap<>();
+
         try {
-            mSpyBootThreshold = spy(watchdog.new BootThreshold(
-                PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
-                PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS));
-            mCrashRecoveryPropertiesMap = new HashMap<>();
+            if (Flags.recoverabilityDetection()) {
+                mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_MITIGATION_INCREMENT));
+            } else {
+                mSpyBootThreshold = spy(watchdog.new BootThreshold(
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_COUNT,
+                    PackageWatchdog.DEFAULT_BOOT_LOOP_TRIGGER_WINDOW_MS));
+            }
 
             doAnswer((Answer<Integer>) invocationOnMock -> {
                 String storedValue = mCrashRecoveryPropertiesMap
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
diff --git a/tools/app_metadata_bundles/Android.bp b/tools/app_metadata_bundles/Android.bp
new file mode 100644
index 0000000..be6bea6
--- /dev/null
+++ b/tools/app_metadata_bundles/Android.bp
@@ -0,0 +1,26 @@
+package {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+java_library_host {
+    name: "asllib",
+    srcs: [
+        "src/lib/java/**/*.java",
+    ],
+}
+
+java_binary_host {
+    name: "aslgen",
+    manifest: "src/aslgen/aslgen.mf",
+    srcs: [
+        "src/aslgen/java/**/*.java",
+    ],
+    static_libs: [
+        "asllib",
+    ],
+}
diff --git a/tools/app_metadata_bundles/OWNERS b/tools/app_metadata_bundles/OWNERS
new file mode 100644
index 0000000..a2a250b
--- /dev/null
+++ b/tools/app_metadata_bundles/OWNERS
@@ -0,0 +1,2 @@
+wenhaowang@google.com
+mloh@google.com
diff --git a/tools/app_metadata_bundles/README.md b/tools/app_metadata_bundles/README.md
new file mode 100644
index 0000000..6e8d287
--- /dev/null
+++ b/tools/app_metadata_bundles/README.md
@@ -0,0 +1,9 @@
+# App metadata bundles
+
+This project delivers a comprehensive toolchain solution for developers
+to efficiently manage app metadata bundles.
+
+The project consists of two subprojects:
+
+  * A pure Java library, and
+  * A pure Java command-line tool.
diff --git a/tools/app_metadata_bundles/src/aslgen/aslgen.mf b/tools/app_metadata_bundles/src/aslgen/aslgen.mf
new file mode 100644
index 0000000..fc656e2
--- /dev/null
+++ b/tools/app_metadata_bundles/src/aslgen/aslgen.mf
@@ -0,0 +1 @@
+Main-Class: com.android.aslgen.Main
\ No newline at end of file
diff --git a/tools/app_metadata_bundles/src/aslgen/java/com/android/aslgen/Main.java b/tools/app_metadata_bundles/src/aslgen/java/com/android/aslgen/Main.java
new file mode 100644
index 0000000..df003b6
--- /dev/null
+++ b/tools/app_metadata_bundles/src/aslgen/java/com/android/aslgen/Main.java
@@ -0,0 +1,110 @@
+/*
+ * 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.aslgen;
+
+import com.android.asllib.AndroidSafetyLabel;
+import com.android.asllib.AndroidSafetyLabel.Format;
+
+import org.xml.sax.SAXException;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.TransformerException;
+
+public class Main {
+
+    /** Takes the options to make file conversion. */
+    public static void main(String[] args)
+            throws IOException, ParserConfigurationException, SAXException, TransformerException {
+
+        String inFile = null;
+        String outFile = null;
+        Format inFormat = Format.NULL;
+        Format outFormat = Format.NULL;
+
+
+        // Except for "--help", all arguments require a value currently.
+        // So just make sure we have an even number and
+        // then process them all two at a time.
+        if (args.length == 1 && "--help".equals(args[0])) {
+            showUsage();
+            return;
+        }
+        if (args.length % 2 != 0) {
+            throw new IllegalArgumentException("Argument is missing corresponding value");
+        }
+        for (int i = 0; i < args.length - 1; i += 2) {
+            final String arg = args[i].trim();
+            final String argValue = args[i + 1].trim();
+            if ("--in-path".equals(arg)) {
+                inFile = argValue;
+            } else if ("--out-path".equals(arg)) {
+                outFile = argValue;
+            } else if ("--in-format".equals(arg)) {
+                inFormat = getFormat(argValue);
+            } else if ("--out-format".equals(arg)) {
+                outFormat = getFormat(argValue);
+            } else {
+                throw new IllegalArgumentException("Unknown argument: " + arg);
+            }
+        }
+
+        if (inFile == null) {
+            throw new IllegalArgumentException("input file is required");
+        }
+
+        if (outFile == null) {
+            throw new IllegalArgumentException("output file is required");
+        }
+
+        if (inFormat == Format.NULL) {
+            throw new IllegalArgumentException("input format is required");
+        }
+
+        if (outFormat == Format.NULL) {
+            throw new IllegalArgumentException("output format is required");
+        }
+
+        System.out.println("in path: " + inFile);
+        System.out.println("out path: " + outFile);
+        System.out.println("in format: " + inFormat);
+        System.out.println("out format: " + outFormat);
+
+        var asl = AndroidSafetyLabel.readFromStream(new FileInputStream(inFile), inFormat);
+        asl.writeToStream(new FileOutputStream(outFile), outFormat);
+    }
+
+    private static Format getFormat(String argValue) {
+        if ("hr".equals(argValue)) {
+            return Format.HUMAN_READABLE;
+        } else if ("od".equals(argValue)) {
+            return Format.ON_DEVICE;
+        } else {
+            return Format.NULL;
+        }
+    }
+
+    private static void showUsage() {
+        AndroidSafetyLabel.test();
+        System.err.println(
+                "Usage:\n"
+        );
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/AndroidSafetyLabel.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/AndroidSafetyLabel.java
new file mode 100644
index 0000000..07e0e73
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/AndroidSafetyLabel.java
@@ -0,0 +1,102 @@
+/*
+ * 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.asllib;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.xml.sax.SAXException;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+public class AndroidSafetyLabel {
+
+    public enum Format {
+        NULL, HUMAN_READABLE, ON_DEVICE;
+    }
+
+    private final SafetyLabels mSafetyLabels;
+
+    public SafetyLabels getSafetyLabels() {
+        return mSafetyLabels;
+    }
+
+    private AndroidSafetyLabel(SafetyLabels safetyLabels) {
+        this.mSafetyLabels = safetyLabels;
+    }
+
+    /** Reads a {@link AndroidSafetyLabel} from an {@link InputStream}. */
+    // TODO(b/329902686): Support conversion in both directions, specified by format.
+    public static AndroidSafetyLabel readFromStream(InputStream in, Format format)
+            throws IOException, ParserConfigurationException, SAXException {
+        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+        factory.setNamespaceAware(true);
+        Document document = factory.newDocumentBuilder().parse(in);
+
+        Element appMetadataBundles =
+                XmlUtils.getSingleElement(document, XmlUtils.HR_TAG_APP_METADATA_BUNDLES);
+
+        return AndroidSafetyLabel.createFromHrElement(appMetadataBundles);
+    }
+
+    /** Write the content of the {@link AndroidSafetyLabel} to a {@link OutputStream}. */
+    // TODO(b/329902686): Support conversion in both directions, specified by format.
+    public void writeToStream(OutputStream out, Format format)
+            throws IOException, ParserConfigurationException, TransformerException {
+        var docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+        var document = docBuilder.newDocument();
+        document.appendChild(this.toOdDomElement(document));
+
+        TransformerFactory transformerFactory = TransformerFactory.newInstance();
+        Transformer transformer = transformerFactory.newTransformer();
+        transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+        transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
+        StreamResult streamResult = new StreamResult(out); // out
+        DOMSource domSource = new DOMSource(document);
+        transformer.transform(domSource, streamResult);
+    }
+
+    /** Creates an {@link AndroidSafetyLabel} from human-readable DOM element */
+    public static AndroidSafetyLabel createFromHrElement(Element appMetadataBundlesEle) {
+        Element safetyLabelsEle =
+                XmlUtils.getSingleElement(appMetadataBundlesEle, XmlUtils.HR_TAG_SAFETY_LABELS);
+        SafetyLabels safetyLabels = SafetyLabels.createFromHrElement(safetyLabelsEle);
+        return new AndroidSafetyLabel(safetyLabels);
+    }
+
+    /** Creates an on-device DOM element from an {@link AndroidSafetyLabel} */
+    public Element toOdDomElement(Document doc) {
+        Element aslEle = doc.createElement(XmlUtils.OD_TAG_BUNDLE);
+        aslEle.appendChild(mSafetyLabels.toOdDomElement(doc));
+        return aslEle;
+    }
+
+    public static void test() {
+        // TODO(b/329902686): Add tests.
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataCategory.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataCategory.java
new file mode 100644
index 0000000..efdaa40
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataCategory.java
@@ -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.asllib;
+
+import java.util.Map;
+
+/**
+ * Data usage category representation containing one or more {@link DataType}. Valid category keys
+ * are defined in {@link DataCategoryConstants}, each category has a valid set of types {@link
+ * DataType}, which are mapped in {@link DataTypeConstants}
+ */
+public class DataCategory {
+    private final Map<String, DataType> mDataTypes;
+
+    private DataCategory(Map<String, DataType> dataTypes) {
+        this.mDataTypes = dataTypes;
+    }
+
+    /** Return the type {@link Map} of String type key to {@link DataType} */
+
+    public Map<String, DataType> getDataTypes() {
+        return mDataTypes;
+    }
+
+    /** Creates a {@link DataCategory} given map of {@param dataTypes}. */
+    public static DataCategory create(Map<String, DataType> dataTypes) {
+        return new DataCategory(dataTypes);
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataCategoryConstants.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataCategoryConstants.java
new file mode 100644
index 0000000..b364c8b
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataCategoryConstants.java
@@ -0,0 +1,74 @@
+/*
+ * 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.asllib;
+
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Constants for determining valid {@link String} data types for usage within {@link SafetyLabels},
+ * {@link DataCategory}, and {@link DataType}
+ */
+public class DataCategoryConstants {
+
+    public static final String CATEGORY_PERSONAL = "personal";
+    public static final String CATEGORY_FINANCIAL = "financial";
+    public static final String CATEGORY_LOCATION = "location";
+    public static final String CATEGORY_EMAIL_TEXT_MESSAGE = "email_text_message";
+    public static final String CATEGORY_PHOTO_VIDEO = "photo_video";
+    public static final String CATEGORY_AUDIO = "audio";
+    public static final String CATEGORY_STORAGE = "storage";
+    public static final String CATEGORY_HEALTH_FITNESS = "health_fitness";
+    public static final String CATEGORY_CONTACTS = "contacts";
+    public static final String CATEGORY_CALENDAR = "calendar";
+    public static final String CATEGORY_IDENTIFIERS = "identifiers";
+    public static final String CATEGORY_APP_PERFORMANCE = "app_performance";
+    public static final String CATEGORY_ACTIONS_IN_APP = "actions_in_app";
+    public static final String CATEGORY_SEARCH_AND_BROWSING = "search_and_browsing";
+
+    /** Set of valid categories */
+    public static final Set<String> VALID_CATEGORIES =
+            Collections.unmodifiableSet(
+                    new HashSet<>(
+                            Arrays.asList(
+                                    CATEGORY_PERSONAL,
+                                    CATEGORY_FINANCIAL,
+                                    CATEGORY_LOCATION,
+                                    CATEGORY_EMAIL_TEXT_MESSAGE,
+                                    CATEGORY_PHOTO_VIDEO,
+                                    CATEGORY_AUDIO,
+                                    CATEGORY_STORAGE,
+                                    CATEGORY_HEALTH_FITNESS,
+                                    CATEGORY_CONTACTS,
+                                    CATEGORY_CALENDAR,
+                                    CATEGORY_IDENTIFIERS,
+                                    CATEGORY_APP_PERFORMANCE,
+                                    CATEGORY_ACTIONS_IN_APP,
+                                    CATEGORY_SEARCH_AND_BROWSING)));
+
+    /** Returns {@link Set} of valid {@link String} category keys */
+    public static Set<String> getValidDataCategories() {
+        return VALID_CATEGORIES;
+    }
+
+    private DataCategoryConstants() {
+        /* do nothing - hide constructor */
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataLabels.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataLabels.java
new file mode 100644
index 0000000..d2c3d75b
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataLabels.java
@@ -0,0 +1,176 @@
+/*
+ * 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.asllib;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Data label representation with data shared and data collected maps containing zero or more {@link
+ * DataCategory}
+ */
+public class DataLabels {
+    private final Map<String, DataCategory> mDataAccessed;
+    private final Map<String, DataCategory> mDataCollected;
+    private final Map<String, DataCategory> mDataShared;
+
+    public DataLabels(
+            Map<String, DataCategory> dataAccessed,
+            Map<String, DataCategory> dataCollected,
+            Map<String, DataCategory> dataShared) {
+        mDataAccessed = dataAccessed;
+        mDataCollected = dataCollected;
+        mDataShared = dataShared;
+    }
+
+    /**
+     * Returns the data accessed {@link Map} of {@link com.android.asllib.DataCategoryConstants} to
+     * {@link DataCategory}
+     */
+    public Map<String, DataCategory> getDataAccessed() {
+        return mDataAccessed;
+    }
+
+    /**
+     * Returns the data collected {@link Map} of {@link com.android.asllib.DataCategoryConstants} to
+     * {@link DataCategory}
+     */
+    public Map<String, DataCategory> getDataCollected() {
+        return mDataCollected;
+    }
+
+    /**
+     * Returns the data shared {@link Map} of {@link com.android.asllib.DataCategoryConstants} to
+     * {@link DataCategory}
+     */
+    public Map<String, DataCategory> getDataShared() {
+        return mDataShared;
+    }
+
+    /** Creates a {@link DataLabels} from the human-readable DOM element. */
+    public static DataLabels createFromHrElement(Element ele) {
+        Map<String, DataCategory> dataAccessed =
+                getDataCategoriesWithTag(ele, XmlUtils.HR_TAG_DATA_ACCESSED);
+        Map<String, DataCategory> dataCollected =
+                getDataCategoriesWithTag(ele, XmlUtils.HR_TAG_DATA_COLLECTED);
+        Map<String, DataCategory> dataShared =
+                getDataCategoriesWithTag(ele, XmlUtils.HR_TAG_DATA_SHARED);
+        return new DataLabels(dataAccessed, dataCollected, dataShared);
+    }
+
+    private static Map<String, DataCategory> getDataCategoriesWithTag(
+            Element dataLabelsEle, String dataCategoryUsageTypeTag) {
+        Map<String, Map<String, DataType>> dataTypeMap =
+                new HashMap<String, Map<String, DataType>>();
+        NodeList dataSharedNodeList = dataLabelsEle.getElementsByTagName(dataCategoryUsageTypeTag);
+
+        for (int i = 0; i < dataSharedNodeList.getLength(); i++) {
+            Element dataSharedEle = (Element) dataSharedNodeList.item(i);
+            String dataCategoryName = dataSharedEle.getAttribute(XmlUtils.HR_ATTR_DATA_CATEGORY);
+            String dataTypeName = dataSharedEle.getAttribute(XmlUtils.HR_ATTR_DATA_TYPE);
+
+            if (!dataTypeMap.containsKey((dataCategoryName))) {
+                dataTypeMap.put(dataCategoryName, new HashMap<String, DataType>());
+            }
+            dataTypeMap
+                    .get(dataCategoryName)
+                    .put(dataTypeName, DataType.createFromHrElement(dataSharedEle));
+        }
+
+        Map<String, DataCategory> dataCategoryMap = new HashMap<String, DataCategory>();
+        for (String dataCategoryName : dataTypeMap.keySet()) {
+            Map<String, DataType> dataTypes = dataTypeMap.get(dataCategoryName);
+            dataCategoryMap.put(dataCategoryName, DataCategory.create(dataTypes));
+        }
+        return dataCategoryMap;
+    }
+
+    /** Gets the on-device DOM element for the {@link DataLabels}. */
+    public Element toOdDomElement(Document doc) {
+        Element dataLabelsEle =
+                XmlUtils.createPbundleEleWithName(doc, XmlUtils.OD_NAME_DATA_LABELS);
+
+        maybeAppendDataUsages(doc, dataLabelsEle, mDataCollected, XmlUtils.OD_NAME_DATA_ACCESSED);
+        maybeAppendDataUsages(doc, dataLabelsEle, mDataCollected, XmlUtils.OD_NAME_DATA_COLLECTED);
+        maybeAppendDataUsages(doc, dataLabelsEle, mDataShared, XmlUtils.OD_NAME_DATA_SHARED);
+
+        return dataLabelsEle;
+    }
+
+    private void maybeAppendDataUsages(
+            Document doc,
+            Element dataLabelsEle,
+            Map<String, DataCategory> dataCategoriesMap,
+            String dataUsageTypeName) {
+        if (dataCategoriesMap.isEmpty()) {
+            return;
+        }
+        Element dataUsageEle = XmlUtils.createPbundleEleWithName(doc, dataUsageTypeName);
+
+        for (String dataCategoryName : dataCategoriesMap.keySet()) {
+            Element dataCategoryEle = XmlUtils.createPbundleEleWithName(doc, dataCategoryName);
+            DataCategory dataCategory = dataCategoriesMap.get(dataCategoryName);
+            for (String dataTypeName : dataCategory.getDataTypes().keySet()) {
+                DataType dataType = dataCategory.getDataTypes().get(dataTypeName);
+                Element dataTypeEle = XmlUtils.createPbundleEleWithName(doc, dataTypeName);
+                if (!dataType.getPurposeSet().isEmpty()) {
+                    Element purposesEle = doc.createElement(XmlUtils.OD_TAG_INT_ARRAY);
+                    purposesEle.setAttribute(XmlUtils.OD_ATTR_NAME, XmlUtils.OD_NAME_PURPOSES);
+                    purposesEle.setAttribute(
+                            XmlUtils.OD_ATTR_NUM, String.valueOf(dataType.getPurposeSet().size()));
+                    for (DataType.Purpose purpose : dataType.getPurposeSet()) {
+                        Element purposeEle = doc.createElement(XmlUtils.OD_TAG_ITEM);
+                        purposeEle.setAttribute(
+                                XmlUtils.OD_ATTR_VALUE, String.valueOf(purpose.getValue()));
+                        purposesEle.appendChild(purposeEle);
+                    }
+                    dataTypeEle.appendChild(purposesEle);
+                }
+
+                maybeAddBoolToOdElement(
+                        doc,
+                        dataTypeEle,
+                        dataType.getIsCollectionOptional(),
+                        XmlUtils.OD_NAME_IS_COLLECTION_OPTIONAL);
+                maybeAddBoolToOdElement(
+                        doc,
+                        dataTypeEle,
+                        dataType.getIsSharingOptional(),
+                        XmlUtils.OD_NAME_IS_SHARING_OPTIONAL);
+                maybeAddBoolToOdElement(
+                        doc, dataTypeEle, dataType.getEphemeral(), XmlUtils.OD_NAME_EPHEMERAL);
+
+                dataCategoryEle.appendChild(dataTypeEle);
+            }
+            dataUsageEle.appendChild(dataCategoryEle);
+        }
+        dataLabelsEle.appendChild(dataUsageEle);
+    }
+
+    private static void maybeAddBoolToOdElement(
+            Document doc, Element parentEle, Boolean b, String odName) {
+        if (b == null) {
+            return;
+        }
+        Element ele = XmlUtils.createOdBooleanEle(doc, odName, b);
+        parentEle.appendChild(ele);
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataType.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataType.java
new file mode 100644
index 0000000..7451c69
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataType.java
@@ -0,0 +1,145 @@
+/*
+ * 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.asllib;
+
+import org.w3c.dom.Element;
+
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Data usage type representation. Types are specific to a {@link DataCategory} and contains
+ * metadata related to the data usage purpose.
+ */
+public class DataType {
+    public enum Purpose {
+        PURPOSE_APP_FUNCTIONALITY(1),
+        PURPOSE_ANALYTICS(2),
+        PURPOSE_DEVELOPER_COMMUNICATIONS(3),
+        PURPOSE_FRAUD_PREVENTION_SECURITY(4),
+        PURPOSE_ADVERTISING(5),
+        PURPOSE_PERSONALIZATION(6),
+        PURPOSE_ACCOUNT_MANAGEMENT(7);
+
+        private static final String PURPOSE_PREFIX = "PURPOSE_";
+
+        private final int mValue;
+
+        Purpose(int value) {
+            this.mValue = value;
+        }
+
+        /** Get the int value associated with the Purpose. */
+        public int getValue() {
+            return mValue;
+        }
+
+        /** Get the Purpose associated with the int value. */
+        public static Purpose forValue(int value) {
+            for (Purpose e : values()) {
+                if (e.getValue() == value) {
+                    return e;
+                }
+            }
+            throw new IllegalArgumentException("No enum for value: " + value);
+        }
+
+        /** Get the Purpose associated with the human-readable String. */
+        public static Purpose forString(String s) {
+            for (Purpose e : values()) {
+                if (e.toString().equals(s)) {
+                    return e;
+                }
+            }
+            throw new IllegalArgumentException("No enum for str: " + s);
+        }
+
+        /** Human-readable String representation of Purpose. */
+        public String toString() {
+            if (!this.name().startsWith(PURPOSE_PREFIX)) {
+                return this.name();
+            }
+            return this.name().substring(PURPOSE_PREFIX.length()).toLowerCase();
+        }
+    }
+
+    private final Set<Purpose> mPurposeSet;
+    private final Boolean mIsCollectionOptional;
+    private final Boolean mIsSharingOptional;
+    private final Boolean mEphemeral;
+
+    private DataType(
+            Set<Purpose> purposeSet,
+            Boolean isCollectionOptional,
+            Boolean isSharingOptional,
+            Boolean ephemeral) {
+        this.mPurposeSet = purposeSet;
+        this.mIsCollectionOptional = isCollectionOptional;
+        this.mIsSharingOptional = isSharingOptional;
+        this.mEphemeral = ephemeral;
+    }
+
+    /**
+     * Returns {@link Set} of valid {@link Integer} purposes for using the associated data category
+     * and type
+     */
+    public Set<Purpose> getPurposeSet() {
+        return mPurposeSet;
+    }
+
+    /**
+     * For data-collected, returns {@code true} if data usage is user optional and {@code false} if
+     * data usage is required. Should return {@code null} for data-accessed and data-shared.
+     */
+    public Boolean getIsCollectionOptional() {
+        return mIsCollectionOptional;
+    }
+
+    /**
+     * For data-shared, returns {@code true} if data usage is user optional and {@code false} if
+     * data usage is required. Should return {@code null} for data-accessed and data-collected.
+     */
+    public Boolean getIsSharingOptional() {
+        return mIsSharingOptional;
+    }
+
+    /**
+     * For data-collected, returns {@code true} if data usage is user optional and {@code false} if
+     * data usage is processed ephemerally. Should return {@code null} for data-shared.
+     */
+    public Boolean getEphemeral() {
+        return mEphemeral;
+    }
+
+    /** Creates a {@link DataType} from the human-readable DOM element. */
+    public static DataType createFromHrElement(Element hrDataTypeEle) {
+        Set<Purpose> purposeSet =
+                Arrays.stream(hrDataTypeEle.getAttribute(XmlUtils.HR_ATTR_PURPOSES).split("\\|"))
+                        .map(Purpose::forString)
+                        .collect(Collectors.toUnmodifiableSet());
+        Boolean isCollectionOptional =
+                XmlUtils.fromString(
+                        hrDataTypeEle.getAttribute(XmlUtils.HR_ATTR_IS_SHARING_OPTIONAL));
+        Boolean isSharingOptional =
+                XmlUtils.fromString(
+                        hrDataTypeEle.getAttribute(XmlUtils.HR_ATTR_IS_COLLECTION_OPTIONAL));
+        Boolean ephemeral =
+                XmlUtils.fromString(hrDataTypeEle.getAttribute(XmlUtils.HR_ATTR_EPHEMERAL));
+        return new DataType(purposeSet, isCollectionOptional, isSharingOptional, ephemeral);
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataTypeConstants.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataTypeConstants.java
new file mode 100644
index 0000000..a0a7537
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/DataTypeConstants.java
@@ -0,0 +1,156 @@
+/*
+ * 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.asllib;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Constants for determining valid {@link String} data types for usage within {@link SafetyLabels},
+ * {@link DataCategory}, and {@link DataType}
+ */
+public class DataTypeConstants {
+    /** Data types for {@link DataCategoryConstants.CATEGORY_PERSONAL} */
+    public static final String TYPE_NAME = "name";
+
+    public static final String TYPE_EMAIL_ADDRESS = "email_address";
+    public static final String TYPE_PHONE_NUMBER = "phone_number";
+    public static final String TYPE_RACE_ETHNICITY = "race_ethnicity";
+    public static final String TYPE_POLITICAL_OR_RELIGIOUS_BELIEFS =
+            "political_or_religious_beliefs";
+    public static final String TYPE_SEXUAL_ORIENTATION_OR_GENDER_IDENTITY =
+            "sexual_orientation_or_gender_identity";
+    public static final String TYPE_PERSONAL_IDENTIFIERS = "personal_identifiers";
+    public static final String TYPE_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_FINANCIAL} */
+    public static final String TYPE_CARD_BANK_ACCOUNT = "card_bank_account";
+
+    public static final String TYPE_PURCHASE_HISTORY = "purchase_history";
+    public static final String TYPE_CREDIT_SCORE = "credit_score";
+    public static final String TYPE_FINANCIAL_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_LOCATION} */
+    public static final String TYPE_APPROX_LOCATION = "approx_location";
+
+    public static final String TYPE_PRECISE_LOCATION = "precise_location";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_EMAIL_TEXT_MESSAGE} */
+    public static final String TYPE_EMAILS = "emails";
+
+    public static final String TYPE_TEXT_MESSAGES = "text_messages";
+    public static final String TYPE_EMAIL_TEXT_MESSAGE_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_PHOTO_VIDEO} */
+    public static final String TYPE_PHOTOS = "photos";
+
+    public static final String TYPE_VIDEOS = "videos";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_AUDIO} */
+    public static final String TYPE_SOUND_RECORDINGS = "sound_recordings";
+
+    public static final String TYPE_MUSIC_FILES = "music_files";
+    public static final String TYPE_AUDIO_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_STORAGE} */
+    public static final String TYPE_FILES_DOCS = "files_docs";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_HEALTH_FITNESS} */
+    public static final String TYPE_HEALTH = "health";
+
+    public static final String TYPE_FITNESS = "fitness";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_CONTACTS} */
+    public static final String TYPE_CONTACTS = "contacts";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_CALENDAR} */
+    public static final String TYPE_CALENDAR = "calendar";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_IDENTIFIERS} */
+    public static final String TYPE_IDENTIFIERS_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_APP_PERFORMANCE} */
+    public static final String TYPE_CRASH_LOGS = "crash_logs";
+
+    public static final String TYPE_PERFORMANCE_DIAGNOSTICS = "performance_diagnostics";
+    public static final String TYPE_APP_PERFORMANCE_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_ACTIONS_IN_APP} */
+    public static final String TYPE_USER_INTERACTION = "user_interaction";
+
+    public static final String TYPE_IN_APP_SEARCH_HISTORY = "in_app_search_history";
+    public static final String TYPE_INSTALLED_APPS = "installed_apps";
+    public static final String TYPE_USER_GENERATED_CONTENT = "user_generated_content";
+    public static final String TYPE_ACTIONS_IN_APP_OTHER = "other";
+
+    /** Data types for {@link DataCategoryConstants.CATEGORY_SEARCH_AND_BROWSING} */
+    public static final String TYPE_WEB_BROWSING_HISTORY = "web_browsing_history";
+
+    /** Set of valid categories */
+    public static final Set<String> VALID_TYPES =
+            Collections.unmodifiableSet(
+                    new HashSet<>(
+                            Arrays.asList(
+                                    TYPE_NAME,
+                                    TYPE_EMAIL_ADDRESS,
+                                    TYPE_PHONE_NUMBER,
+                                    TYPE_RACE_ETHNICITY,
+                                    TYPE_POLITICAL_OR_RELIGIOUS_BELIEFS,
+                                    TYPE_SEXUAL_ORIENTATION_OR_GENDER_IDENTITY,
+                                    TYPE_PERSONAL_IDENTIFIERS,
+                                    TYPE_OTHER,
+                                    TYPE_CARD_BANK_ACCOUNT,
+                                    TYPE_PURCHASE_HISTORY,
+                                    TYPE_CREDIT_SCORE,
+                                    TYPE_FINANCIAL_OTHER,
+                                    TYPE_APPROX_LOCATION,
+                                    TYPE_PRECISE_LOCATION,
+                                    TYPE_EMAILS,
+                                    TYPE_TEXT_MESSAGES,
+                                    TYPE_EMAIL_TEXT_MESSAGE_OTHER,
+                                    TYPE_PHOTOS,
+                                    TYPE_VIDEOS,
+                                    TYPE_SOUND_RECORDINGS,
+                                    TYPE_MUSIC_FILES,
+                                    TYPE_AUDIO_OTHER,
+                                    TYPE_FILES_DOCS,
+                                    TYPE_HEALTH,
+                                    TYPE_FITNESS,
+                                    TYPE_CONTACTS,
+                                    TYPE_CALENDAR,
+                                    TYPE_IDENTIFIERS_OTHER,
+                                    TYPE_CRASH_LOGS,
+                                    TYPE_PERFORMANCE_DIAGNOSTICS,
+                                    TYPE_APP_PERFORMANCE_OTHER,
+                                    TYPE_USER_INTERACTION,
+                                    TYPE_IN_APP_SEARCH_HISTORY,
+                                    TYPE_INSTALLED_APPS,
+                                    TYPE_USER_GENERATED_CONTENT,
+                                    TYPE_ACTIONS_IN_APP_OTHER,
+                                    TYPE_WEB_BROWSING_HISTORY)));
+
+    /** Returns {@link Set} of valid {@link String} category keys */
+    public static Set<String> getValidDataTypes() {
+        return VALID_TYPES;
+    }
+
+    private DataTypeConstants() {
+        /* do nothing - hide constructor */
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/SafetyLabels.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/SafetyLabels.java
new file mode 100644
index 0000000..6ba15e1
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/SafetyLabels.java
@@ -0,0 +1,65 @@
+/*
+ * 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.asllib;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+/** Safety Label representation containing zero or more {@link DataCategory} for data shared */
+public class SafetyLabels {
+
+    private final Long mVersion;
+    private final DataLabels mDataLabels;
+
+    private SafetyLabels(Long version, DataLabels dataLabels) {
+        this.mVersion = version;
+        this.mDataLabels = dataLabels;
+    }
+
+    /** Returns the data label for the safety label */
+    public DataLabels getDataLabel() {
+        return mDataLabels;
+    }
+
+    /** Gets the version of the {@link SafetyLabels}. */
+    public Long getVersion() {
+        return mVersion;
+    }
+
+    /** Creates a {@link SafetyLabels} from the human-readable DOM element. */
+    public static SafetyLabels createFromHrElement(Element safetyLabelsEle) {
+        Long version;
+        try {
+            version = Long.parseLong(safetyLabelsEle.getAttribute(XmlUtils.HR_ATTR_VERSION));
+        } catch (Exception e) {
+            throw new IllegalArgumentException(
+                    "Malformed or missing required version in safety labels.");
+        }
+        Element dataLabelsEle =
+                XmlUtils.getSingleElement(safetyLabelsEle, XmlUtils.HR_TAG_DATA_LABELS);
+        DataLabels dataLabels = DataLabels.createFromHrElement(dataLabelsEle);
+        return new SafetyLabels(version, dataLabels);
+    }
+
+    /** Creates an on-device DOM element from the {@link SafetyLabels}. */
+    public Element toOdDomElement(Document doc) {
+        Element safetyLabelsEle =
+                XmlUtils.createPbundleEleWithName(doc, XmlUtils.OD_NAME_SAFETY_LABELS);
+        safetyLabelsEle.appendChild(mDataLabels.toOdDomElement(doc));
+        return safetyLabelsEle;
+    }
+}
diff --git a/tools/app_metadata_bundles/src/lib/java/com/android/asllib/XmlUtils.java b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/XmlUtils.java
new file mode 100644
index 0000000..4392c2c
--- /dev/null
+++ b/tools/app_metadata_bundles/src/lib/java/com/android/asllib/XmlUtils.java
@@ -0,0 +1,119 @@
+/*
+ * 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.asllib;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+public class XmlUtils {
+    public static final String HR_TAG_APP_METADATA_BUNDLES = "app-metadata-bundles";
+    public static final String HR_TAG_SAFETY_LABELS = "safety-labels";
+    public static final String HR_TAG_DATA_LABELS = "data-labels";
+    public static final String HR_TAG_DATA_ACCESSED = "data-accessed";
+    public static final String HR_TAG_DATA_COLLECTED = "data-collected";
+    public static final String HR_TAG_DATA_SHARED = "data-shared";
+
+    public static final String HR_ATTR_DATA_CATEGORY = "dataCategory";
+    public static final String HR_ATTR_DATA_TYPE = "dataType";
+    public static final String HR_ATTR_IS_COLLECTION_OPTIONAL = "isCollectionOptional";
+    public static final String HR_ATTR_IS_SHARING_OPTIONAL = "isSharingOptional";
+    public static final String HR_ATTR_EPHEMERAL = "ephemeral";
+    public static final String HR_ATTR_PURPOSES = "purposes";
+    public static final String HR_ATTR_VERSION = "version";
+
+    public static final String OD_TAG_BUNDLE = "bundle";
+    public static final String OD_TAG_PBUNDLE_AS_MAP = "pbundle_as_map";
+    public static final String OD_TAG_BOOLEAN = "boolean";
+    public static final String OD_TAG_INT_ARRAY = "int-array";
+    public static final String OD_TAG_ITEM = "item";
+    public static final String OD_ATTR_NAME = "name";
+    public static final String OD_ATTR_VALUE = "value";
+    public static final String OD_ATTR_NUM = "num";
+    public static final String OD_NAME_SAFETY_LABELS = "safety_labels";
+    public static final String OD_NAME_DATA_LABELS = "data_labels";
+    public static final String OD_NAME_DATA_ACCESSED = "data_accessed";
+    public static final String OD_NAME_DATA_COLLECTED = "data_collected";
+    public static final String OD_NAME_DATA_SHARED = "data_shared";
+    public static final String OD_NAME_PURPOSES = "purposes";
+    public static final String OD_NAME_IS_COLLECTION_OPTIONAL = "is_collection_optional";
+    public static final String OD_NAME_IS_SHARING_OPTIONAL = "is_sharing_optional";
+    public static final String OD_NAME_EPHEMERAL = "ephemeral";
+
+    public static final String TRUE_STR = "true";
+    public static final String FALSE_STR = "false";
+
+    /** Gets the single top-level {@link Element} having the {@param tagName}. */
+    public static Element getSingleElement(Document doc, String tagName) {
+        var elements = doc.getElementsByTagName(tagName);
+        return getSingleElement(elements, tagName);
+    }
+
+    /**
+     * Gets the single {@link Element} within {@param parentEle} and having the {@param tagName}.
+     */
+    public static Element getSingleElement(Element parentEle, String tagName) {
+        var elements = parentEle.getElementsByTagName(tagName);
+        return getSingleElement(elements, tagName);
+    }
+
+    /** Gets the single {@link Element} from {@param elements} and having the {@param tagName}. */
+    public static Element getSingleElement(NodeList elements, String tagName) {
+        if (elements.getLength() != 1) {
+            throw new IllegalArgumentException(
+                    String.format("Expected 1 %s but got %s.", tagName, elements.getLength()));
+        }
+        var elementAsNode = elements.item(0);
+        if (!(elementAsNode instanceof Element)) {
+            throw new IllegalStateException(String.format("%s was not an element.", tagName));
+        }
+        return ((Element) elementAsNode);
+    }
+
+    /** Gets the Boolean from the String value. */
+    public static Boolean fromString(String s) {
+        if (s == null) {
+            return null;
+        }
+        if (s.equals(TRUE_STR)) {
+            return true;
+        } else if (s.equals(FALSE_STR)) {
+            return false;
+        }
+        return null;
+    }
+
+    /** Creates an on-device PBundle DOM Element with the given attribute name. */
+    public static Element createPbundleEleWithName(Document doc, String name) {
+        var ele = doc.createElement(XmlUtils.OD_TAG_PBUNDLE_AS_MAP);
+        ele.setAttribute(XmlUtils.OD_ATTR_NAME, name);
+        return ele;
+    }
+
+    /** Create an on-device Boolean DOM Element with the given attribute name. */
+    public static Element createOdBooleanEle(Document doc, String name, boolean b) {
+        var ele = doc.createElement(XmlUtils.OD_TAG_BOOLEAN);
+        ele.setAttribute(XmlUtils.OD_ATTR_NAME, name);
+        ele.setAttribute(XmlUtils.OD_ATTR_VALUE, String.valueOf(b));
+        return ele;
+    }
+
+    /** Returns whether the String is null or empty. */
+    public static boolean isNullOrEmpty(String s) {
+        return s == null || s.isEmpty();
+    }
+}