Merge "Fix spatial audio tests when flag is enabled" into main
diff --git a/SQLITE_OWNERS b/SQLITE_OWNERS
index 1ff72e7..783a0b6 100644
--- a/SQLITE_OWNERS
+++ b/SQLITE_OWNERS
@@ -1,2 +1,9 @@
+# Android platform SQLite owners are responsible for:
+# 1. Periodically updating libsqlite from upstream sqlite.org.
+# 2. Escalating libsqlite bug reports to upstream sqlite.org.
+# 3. Addressing bugs, performance regressions, and feature requests
+#    in Android SDK SQLite wrappers (android.database.sqlite.*).
+# 4. Reviewing proposed changes to said Android SDK SQLite wrappers.
+
 shayba@google.com
 shombert@google.com
diff --git a/api/Android.bp b/api/Android.bp
index 26899ea..cd1997c 100644
--- a/api/Android.bp
+++ b/api/Android.bp
@@ -376,7 +376,6 @@
     high_mem: true, // Lots of sources => high memory use, see b/170701554
     installable: false,
     annotations_enabled: true,
-    previous_api: ":android.api.public.latest",
     merge_annotations_dirs: ["metalava-manual"],
     defaults_visibility: ["//frameworks/base/api"],
     visibility: [
diff --git a/api/StubLibraries.bp b/api/StubLibraries.bp
index 5b7e25b..12820f9 100644
--- a/api/StubLibraries.bp
+++ b/api/StubLibraries.bp
@@ -38,6 +38,9 @@
         "android-non-updatable-stubs-defaults",
         "module-classpath-stubs-defaults",
     ],
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.public.latest",
     check_api: {
         current: {
             api_file: ":non-updatable-current.txt",
@@ -118,6 +121,9 @@
         "module-classpath-stubs-defaults",
     ],
     flags: priv_apps,
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.system.latest",
     check_api: {
         current: {
             api_file: ":non-updatable-system-current.txt",
@@ -178,6 +184,9 @@
         "module-classpath-stubs-defaults",
     ],
     flags: test + priv_apps_in_stubs,
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.test.latest",
     check_api: {
         current: {
             api_file: ":non-updatable-test-current.txt",
@@ -257,6 +266,9 @@
         "module-classpath-stubs-defaults",
     ],
     flags: priv_apps_in_stubs + module_libs,
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.module-lib.latest",
     check_api: {
         current: {
             api_file: ":non-updatable-module-lib-current.txt",
@@ -571,6 +583,9 @@
     ],
     defaults: ["android-non-updatable_everything_from_text_defaults"],
     full_api_surface_stub: "android_stubs_current.from-text",
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.public.latest",
 }
 
 java_api_library {
@@ -582,6 +597,9 @@
     ],
     defaults: ["android-non-updatable_everything_from_text_defaults"],
     full_api_surface_stub: "android_system_stubs_current.from-text",
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.system.latest",
 }
 
 java_api_library {
@@ -594,6 +612,9 @@
     ],
     defaults: ["android-non-updatable_everything_from_text_defaults"],
     full_api_surface_stub: "android_test_stubs_current.from-text",
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.test.latest",
 }
 
 java_api_library {
@@ -606,6 +627,9 @@
     ],
     defaults: ["android-non-updatable_everything_from_text_defaults"],
     full_api_surface_stub: "android_module_lib_stubs_current_full.from-text",
+    // Use full Android API not just the non-updatable API as the latter is incomplete
+    // and can result in incorrect behavior.
+    previous_api: ":android.api.combined.module-lib.latest",
 }
 
 // This module generates a stub jar that is a union of the test and module lib
@@ -623,6 +647,8 @@
     defaults: ["android-non-updatable_everything_from_text_defaults"],
     full_api_surface_stub: "android_test_module_lib_stubs_current.from-text",
 
+    // No need to specify previous_api as this is not used for compiling against.
+
     // This module is only used for hiddenapi, and other modules should not
     // depend on this module.
     visibility: ["//visibility:private"],
@@ -922,7 +948,7 @@
         "i18n.module.public.api.stubs.source.system.api.contribution",
         "i18n.module.public.api.stubs.source.module_lib.api.contribution",
     ],
-    previous_api: ":android.api.public.latest",
+    previous_api: ":android.api.combined.module-lib.latest",
 }
 
 // Java API library definitions per API surface
diff --git a/api/api.go b/api/api.go
index 449fac6..d4db49e 100644
--- a/api/api.go
+++ b/api/api.go
@@ -478,7 +478,7 @@
 		props.Api_contributions = transformArray(
 			modules, "", fmt.Sprintf(".stubs.source%s.api.contribution", apiSuffix))
 		props.Defaults_visibility = []string{"//visibility:public"}
-		props.Previous_api = proptools.StringPtr(":android.api.public.latest")
+		props.Previous_api = proptools.StringPtr(":android.api.combined." + sdkKind.String() + ".latest")
 		ctx.CreateModule(java.DefaultsFactory, &props)
 	}
 }
diff --git a/core/api/current.txt b/core/api/current.txt
index 6e42fe3..69ead8f 100644
--- a/core/api/current.txt
+++ b/core/api/current.txt
@@ -2456,7 +2456,7 @@
     field public static final int config_longAnimTime = 17694722; // 0x10e0002
     field public static final int config_mediumAnimTime = 17694721; // 0x10e0001
     field public static final int config_shortAnimTime = 17694720; // 0x10e0000
-    field public static final int status_bar_notification_info_maxnum = 17694723; // 0x10e0003
+    field @Deprecated public static final int status_bar_notification_info_maxnum = 17694723; // 0x10e0003
   }
 
   public static final class R.interpolator {
@@ -2550,7 +2550,7 @@
     field public static final int search_go = 17039372; // 0x104000c
     field public static final int selectAll = 17039373; // 0x104000d
     field public static final int selectTextMode = 17039382; // 0x1040016
-    field public static final int status_bar_notification_info_overflow = 17039383; // 0x1040017
+    field @Deprecated public static final int status_bar_notification_info_overflow = 17039383; // 0x1040017
     field public static final int unknownName = 17039374; // 0x104000e
     field public static final int untitled = 17039375; // 0x104000f
     field @Deprecated public static final int yes = 17039379; // 0x1040013
diff --git a/core/java/android/os/SharedMemory.java b/core/java/android/os/SharedMemory.java
index d008034..cba4423 100644
--- a/core/java/android/os/SharedMemory.java
+++ b/core/java/android/os/SharedMemory.java
@@ -25,8 +25,6 @@
 
 import dalvik.system.VMRuntime;
 
-import libcore.io.IoUtils;
-
 import java.io.Closeable;
 import java.io.FileDescriptor;
 import java.io.IOException;
@@ -65,7 +63,7 @@
 
         mMemoryRegistration = new MemoryRegistration(mSize);
         mCleaner = Cleaner.create(mFileDescriptor,
-                new Closer(mFileDescriptor, mMemoryRegistration));
+                new Closer(mFileDescriptor.getInt$(), mMemoryRegistration));
     }
 
     /**
@@ -328,20 +326,21 @@
      * Cleaner that closes the FD
      */
     private static final class Closer implements Runnable {
-        private FileDescriptor mFd;
+        private int mFd;
         private MemoryRegistration mMemoryReference;
 
-        private Closer(FileDescriptor fd, MemoryRegistration memoryReference) {
+        private Closer(int fd, MemoryRegistration memoryReference) {
             mFd = fd;
-            IoUtils.setFdOwner(mFd, this);
             mMemoryReference = memoryReference;
         }
 
         @Override
         public void run() {
-            IoUtils.closeQuietly(mFd);
-            mFd = null;
-
+            try {
+                FileDescriptor fd = new FileDescriptor();
+                fd.setInt$(mFd);
+                Os.close(fd);
+            } catch (ErrnoException e) { /* swallow error */ }
             mMemoryReference.release();
             mMemoryReference = null;
         }
diff --git a/core/java/android/service/chooser/flags.aconfig b/core/java/android/service/chooser/flags.aconfig
index d6425c3..ed20207 100644
--- a/core/java/android/service/chooser/flags.aconfig
+++ b/core/java/android/service/chooser/flags.aconfig
@@ -32,3 +32,14 @@
   description: "Provides additional callbacks with information about user actions in ChooserResult"
   bug: "263474465"
 }
+
+flag {
+  name: "fix_resolver_memory_leak"
+  is_exported: true
+  namespace: "intentresolver"
+  description: "ResolverActivity memory leak (through the AppPredictor callback) fix"
+  bug: "346671041"
+  metadata {
+    purpose: PURPOSE_BUGFIX
+  }
+}
diff --git a/core/java/android/service/notification/ZenModeConfig.java b/core/java/android/service/notification/ZenModeConfig.java
index a3afcce..863a99a 100644
--- a/core/java/android/service/notification/ZenModeConfig.java
+++ b/core/java/android/service/notification/ZenModeConfig.java
@@ -262,15 +262,12 @@
     private static final String CONDITION_ATT_SOURCE = "source";
     private static final String CONDITION_ATT_FLAGS = "flags";
 
-    private static final String ZEN_POLICY_TAG = "zen_policy";
-
     private static final String MANUAL_TAG = "manual";
     private static final String AUTOMATIC_TAG = "automatic";
     private static final String AUTOMATIC_DELETED_TAG = "deleted";
 
     private static final String RULE_ATT_ID = "ruleId";
     private static final String RULE_ATT_ENABLED = "enabled";
-    private static final String RULE_ATT_SNOOZING = "snoozing";
     private static final String RULE_ATT_NAME = "name";
     private static final String RULE_ATT_PKG = "pkg";
     private static final String RULE_ATT_COMPONENT = "component";
@@ -286,6 +283,7 @@
     private static final String RULE_ATT_ICON = "rule_icon";
     private static final String RULE_ATT_TRIGGER_DESC = "triggerDesc";
     private static final String RULE_ATT_DELETION_INSTANT = "deletionInstant";
+    private static final String RULE_ATT_DISABLED_ORIGIN = "disabledOrigin";
 
     private static final String DEVICE_EFFECT_DISPLAY_GRAYSCALE = "zdeDisplayGrayscale";
     private static final String DEVICE_EFFECT_SUPPRESS_AMBIENT_DISPLAY =
@@ -1170,6 +1168,10 @@
             if (deletionInstant != null) {
                 rt.deletionInstant = Instant.ofEpochMilli(deletionInstant);
             }
+            if (Flags.modesUi()) {
+                rt.disabledOrigin = safeInt(parser, RULE_ATT_DISABLED_ORIGIN,
+                        UPDATE_ORIGIN_UNKNOWN);
+            }
         }
         return rt;
     }
@@ -1224,6 +1226,9 @@
                 out.attributeLong(null, RULE_ATT_DELETION_INSTANT,
                         rule.deletionInstant.toEpochMilli());
             }
+            if (Flags.modesUi()) {
+                out.attributeInt(null, RULE_ATT_DISABLED_ORIGIN, rule.disabledOrigin);
+            }
         }
     }
 
@@ -2514,6 +2519,8 @@
         @ZenPolicy.ModifiableField public int zenPolicyUserModifiedFields;
         @ZenDeviceEffects.ModifiableField public int zenDeviceEffectsUserModifiedFields;
         @Nullable public Instant deletionInstant; // Only set on deleted rules.
+        @FlaggedApi(Flags.FLAG_MODES_UI)
+        @ConfigChangeOrigin public int disabledOrigin = UPDATE_ORIGIN_UNKNOWN;
 
         public ZenRule() { }
 
@@ -2552,6 +2559,9 @@
                 if (source.readInt() == 1) {
                     deletionInstant = Instant.ofEpochMilli(source.readLong());
                 }
+                if (Flags.modesUi()) {
+                    disabledOrigin = source.readInt();
+                }
             }
         }
 
@@ -2626,6 +2636,9 @@
                 } else {
                     dest.writeInt(0);
                 }
+                if (Flags.modesUi()) {
+                    dest.writeInt(disabledOrigin);
+                }
             }
         }
 
@@ -2671,6 +2684,9 @@
                 if (deletionInstant != null) {
                     sb.append(",deletionInstant=").append(deletionInstant);
                 }
+                if (Flags.modesUi()) {
+                    sb.append(",disabledOrigin=").append(disabledOrigin);
+                }
             }
 
             return sb.append(']').toString();
@@ -2724,7 +2740,7 @@
                     && other.modified == modified;
 
             if (Flags.modesApi()) {
-                return finalEquals
+                finalEquals = finalEquals
                         && Objects.equals(other.zenDeviceEffects, zenDeviceEffects)
                         && other.allowManualInvocation == allowManualInvocation
                         && Objects.equals(other.iconResName, iconResName)
@@ -2735,6 +2751,11 @@
                         && other.zenDeviceEffectsUserModifiedFields
                             == zenDeviceEffectsUserModifiedFields
                         && Objects.equals(other.deletionInstant, deletionInstant);
+
+                if (Flags.modesUi()) {
+                    finalEquals = finalEquals
+                            && other.disabledOrigin == disabledOrigin;
+                }
             }
 
             return finalEquals;
@@ -2743,11 +2764,21 @@
         @Override
         public int hashCode() {
             if (Flags.modesApi()) {
-                return Objects.hash(enabled, snoozing, name, zenMode, conditionId, condition,
-                        component, configurationActivity, pkg, id, enabler, zenPolicy,
-                        zenDeviceEffects, modified, allowManualInvocation, iconResName,
-                        triggerDescription, type, userModifiedFields, zenPolicyUserModifiedFields,
-                        zenDeviceEffectsUserModifiedFields, deletionInstant);
+                if (Flags.modesUi()) {
+                    return Objects.hash(enabled, snoozing, name, zenMode, conditionId, condition,
+                            component, configurationActivity, pkg, id, enabler, zenPolicy,
+                            zenDeviceEffects, modified, allowManualInvocation, iconResName,
+                            triggerDescription, type, userModifiedFields,
+                            zenPolicyUserModifiedFields,
+                            zenDeviceEffectsUserModifiedFields, deletionInstant, disabledOrigin);
+                } else {
+                    return Objects.hash(enabled, snoozing, name, zenMode, conditionId, condition,
+                            component, configurationActivity, pkg, id, enabler, zenPolicy,
+                            zenDeviceEffects, modified, allowManualInvocation, iconResName,
+                            triggerDescription, type, userModifiedFields,
+                            zenPolicyUserModifiedFields,
+                            zenDeviceEffectsUserModifiedFields, deletionInstant);
+                }
             }
             return Objects.hash(enabled, snoozing, name, zenMode, conditionId, condition,
                     component, configurationActivity, pkg, id, enabler, zenPolicy, modified);
diff --git a/core/java/android/view/SurfaceControl.java b/core/java/android/view/SurfaceControl.java
index 0bdb4ad..405abd7 100644
--- a/core/java/android/view/SurfaceControl.java
+++ b/core/java/android/view/SurfaceControl.java
@@ -2812,6 +2812,10 @@
         private final ArrayMap<SurfaceControl, Point> mResizedSurfaces = new ArrayMap<>();
         private final ArrayMap<SurfaceControl, SurfaceControl> mReparentedSurfaces =
                  new ArrayMap<>();
+        // Only non-null if the SurfaceControlRegistry is enabled. This list tracks the set of calls
+        // made through this transaction object, and is dumped (and cleared) when the transaction is
+        // later applied.
+        ArrayList<String> mCalls;
 
         Runnable mFreeNativeResources;
         private static final float[] INVALID_COLOR = {-1, -1, -1};
@@ -2837,13 +2841,28 @@
         private Transaction(long nativeObject) {
             mNativeObject = nativeObject;
             mFreeNativeResources = sRegistry.registerNativeAllocation(this, mNativeObject);
-            if (!SurfaceControlRegistry.sCallStackDebuggingInitialized) {
-                SurfaceControlRegistry.initializeCallStackDebugging();
-            }
+            setUpForSurfaceControlRegistry();
         }
 
         private Transaction(Parcel in) {
             readFromParcel(in);
+            setUpForSurfaceControlRegistry();
+        }
+
+        /**
+         * Sets up this transaction for the SurfaceControlRegistry.
+         */
+        private void setUpForSurfaceControlRegistry() {
+            if (!SurfaceControlRegistry.sCallStackDebuggingInitialized) {
+                SurfaceControlRegistry.initializeCallStackDebugging();
+            }
+            mCalls = SurfaceControlRegistry.sLogAllTxCallsOnApply
+                    ? new ArrayList<>()
+                    : null;
+            if (SurfaceControlRegistry.sCallStackDebuggingEnabled) {
+                SurfaceControlRegistry.getProcessInstance().checkCallStackDebugging(
+                        "ctor", this, null, null);
+            }
         }
 
         /**
@@ -2893,6 +2912,9 @@
             if (mNativeObject != 0) {
                 nativeClearTransaction(mNativeObject);
             }
+            if (mCalls != null) {
+                mCalls.clear();
+            }
         }
 
         /**
@@ -2904,6 +2926,9 @@
             mReparentedSurfaces.clear();
             mFreeNativeResources.run();
             mNativeObject = 0;
+            if (mCalls != null) {
+                mCalls.clear();
+            }
         }
 
         /**
@@ -2921,7 +2946,10 @@
 
             if (SurfaceControlRegistry.sCallStackDebuggingEnabled) {
                 SurfaceControlRegistry.getProcessInstance().checkCallStackDebugging(
-                        "apply", this, null, null);
+                        SurfaceControlRegistry.APPLY, this, null, null);
+            }
+            if (mCalls != null) {
+                mCalls.clear();
             }
         }
 
@@ -4421,6 +4449,14 @@
             if (this == other) {
                 return this;
             }
+            if (SurfaceControlRegistry.sCallStackDebuggingEnabled) {
+                SurfaceControlRegistry.getProcessInstance().checkCallStackDebugging(
+                        "merge", this, null, "otherTx=" + other.getId());
+                if (mCalls != null) {
+                    mCalls.addAll(other.mCalls);
+                    other.mCalls.clear();
+                }
+            }
             mResizedSurfaces.putAll(other.mResizedSurfaces);
             other.mResizedSurfaces.clear();
             mReparentedSurfaces.putAll(other.mReparentedSurfaces);
diff --git a/core/java/android/view/SurfaceControlRegistry.java b/core/java/android/view/SurfaceControlRegistry.java
index aa3654d..117b200 100644
--- a/core/java/android/view/SurfaceControlRegistry.java
+++ b/core/java/android/view/SurfaceControlRegistry.java
@@ -44,6 +44,8 @@
  */
 public class SurfaceControlRegistry {
     private static final String TAG = "SurfaceControlRegistry";
+    // Special constant for identifying the Transaction#apply() calls
+    static final String APPLY = "apply";
 
     /**
      * An interface for processing the registered SurfaceControls when the threshold is exceeded.
@@ -134,6 +136,10 @@
     // sCallStackDebuggingEnabled is true.  Can be combined with the match name.
     private static String sCallStackDebuggingMatchCall;
 
+    // When set, all calls on a SurfaceControl.Transaction will be stored and logged when the
+    // transaction is applied.
+    static boolean sLogAllTxCallsOnApply;
+
     // Mapping of the active SurfaceControls to the elapsed time when they were registered
     @GuardedBy("sLock")
     private final WeakHashMap<SurfaceControl, Long> mSurfaceControls;
@@ -185,6 +191,7 @@
     public void setCallStackDebuggingParams(String matchName, String matchCall) {
         sCallStackDebuggingMatchName = matchName.toLowerCase();
         sCallStackDebuggingMatchCall = matchCall.toLowerCase();
+        sLogAllTxCallsOnApply = sCallStackDebuggingMatchCall.contains("apply");
     }
 
     /**
@@ -294,13 +301,15 @@
         sCallStackDebuggingMatchName =
                 SystemProperties.get("persist.wm.debug.sc.tx.log_match_name", null)
                         .toLowerCase();
+        sLogAllTxCallsOnApply = sCallStackDebuggingMatchCall.contains("apply");
         // Only enable stack debugging if any of the match filters are set
-        sCallStackDebuggingEnabled = (!sCallStackDebuggingMatchCall.isEmpty()
-                || !sCallStackDebuggingMatchName.isEmpty());
+        sCallStackDebuggingEnabled = !sCallStackDebuggingMatchCall.isEmpty()
+                || !sCallStackDebuggingMatchName.isEmpty();
         if (sCallStackDebuggingEnabled) {
             Log.d(TAG, "Enabling transaction call stack debugging:"
                     + " matchCall=" + sCallStackDebuggingMatchCall
-                    + " matchName=" + sCallStackDebuggingMatchName);
+                    + " matchName=" + sCallStackDebuggingMatchName
+                    + " logCallsWithApply=" + sLogAllTxCallsOnApply);
         }
     }
 
@@ -319,15 +328,31 @@
         if (!sCallStackDebuggingEnabled) {
             return;
         }
-        if (!matchesForCallStackDebugging(sc != null ? sc.getName() : null, call)) {
-            return;
-        }
-        final String txMsg = tx != null ? "tx=" + tx.getId() + " ": "";
-        final String scMsg = sc != null ? " sc=" + sc.getName() + "": "";
+
+        final String txMsg = tx != null ? "tx=" + tx.getId() + " " : "";
+        final String scMsg = sc != null ? " sc=" + sc.getName() + "" : "";
         final String msg = details != null
                 ? call + " (" + txMsg + scMsg + ") " + details
                 : call + " (" + txMsg + scMsg + ")";
-        Log.e(TAG, msg, new Throwable());
+        if (sLogAllTxCallsOnApply && tx != null) {
+            if (call == APPLY) {
+                // Log the apply and dump the calls on that transaction
+                Log.e(TAG, msg, new Throwable());
+                for (int i = 0; i < tx.mCalls.size(); i++) {
+                    Log.d(TAG, "        " + tx.mCalls.get(i));
+                }
+            } else if (matchesForCallStackDebugging(sc != null ? sc.getName() : null, call)) {
+                // Otherwise log this call to the transaction if it matches the tracked calls
+                Log.e(TAG, msg, new Throwable());
+                tx.mCalls.add(msg);
+            }
+        } else {
+            // Log this call if it matches the tracked calls
+            if (!matchesForCallStackDebugging(sc != null ? sc.getName() : null, call)) {
+                return;
+            }
+            Log.e(TAG, msg, new Throwable());
+        }
     }
 
     /**
@@ -388,6 +413,7 @@
                 pw.println("sCallStackDebuggingEnabled=" + sCallStackDebuggingEnabled);
                 pw.println("sCallStackDebuggingMatchName=" + sCallStackDebuggingMatchName);
                 pw.println("sCallStackDebuggingMatchCall=" + sCallStackDebuggingMatchCall);
+                pw.println("sLogAllTxCallsOnApply=" + sLogAllTxCallsOnApply);
             }
         }
     }
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index 3df72e8..7a1ab7b 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -33898,8 +33898,19 @@
     protected int calculateFrameRateCategory() {
         int category;
         switch (getViewRootImpl().intermittentUpdateState()) {
-            case ViewRootImpl.INTERMITTENT_STATE_INTERMITTENT -> category =
-                    FRAME_RATE_CATEGORY_NORMAL | FRAME_RATE_CATEGORY_REASON_INTERMITTENT;
+            case ViewRootImpl.INTERMITTENT_STATE_INTERMITTENT -> {
+                if (!sToolkitFrameRateBySizeReadOnlyFlagValue) {
+                    category = FRAME_RATE_CATEGORY_NORMAL;
+                } else {
+                    // The size based frame rate category can only be LOW or NORMAL. If the size
+                    // based frame rate category is LOW, we shouldn't vote for NORMAL for
+                    // intermittent.
+                    category = Math.min(
+                            mSizeBasedFrameRateCategoryAndReason & ~FRAME_RATE_CATEGORY_REASON_MASK,
+                            FRAME_RATE_CATEGORY_NORMAL);
+                }
+                category |= FRAME_RATE_CATEGORY_REASON_INTERMITTENT;
+            }
             case ViewRootImpl.INTERMITTENT_STATE_NOT_INTERMITTENT ->
                     category = mSizeBasedFrameRateCategoryAndReason;
             default -> category = mLastFrameRateCategory;
diff --git a/core/java/android/window/flags/windowing_frontend.aconfig b/core/java/android/window/flags/windowing_frontend.aconfig
index b714682..985dc10 100644
--- a/core/java/android/window/flags/windowing_frontend.aconfig
+++ b/core/java/android/window/flags/windowing_frontend.aconfig
@@ -192,4 +192,15 @@
   metadata {
     purpose: PURPOSE_BUGFIX
   }
-}
\ No newline at end of file
+}
+
+flag {
+  name: "ensure_wallpaper_in_transitions"
+  namespace: "windowing_frontend"
+  description: "Ensure that wallpaper window tokens are always present/available for collection in transitions"
+  bug: "347593088"
+  metadata {
+    purpose: PURPOSE_BUGFIX
+  }
+}
+
diff --git a/core/java/com/android/internal/app/ResolverActivity.java b/core/java/com/android/internal/app/ResolverActivity.java
index 920981e..a194535 100644
--- a/core/java/com/android/internal/app/ResolverActivity.java
+++ b/core/java/com/android/internal/app/ResolverActivity.java
@@ -1209,9 +1209,19 @@
         if (!isChangingConfigurations() && mPickOptionRequest != null) {
             mPickOptionRequest.cancel();
         }
-        if (mMultiProfilePagerAdapter != null
-                && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {
-            mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();
+        if (mMultiProfilePagerAdapter != null) {
+            ResolverListAdapter activeAdapter =
+                    mMultiProfilePagerAdapter.getActiveListAdapter();
+            if (activeAdapter != null) {
+                activeAdapter.onDestroy();
+            }
+            if (android.service.chooser.Flags.fixResolverMemoryLeak()) {
+                ResolverListAdapter inactiveAdapter =
+                        mMultiProfilePagerAdapter.getInactiveListAdapter();
+                if (inactiveAdapter != null) {
+                    inactiveAdapter.onDestroy();
+                }
+            }
         }
     }
 
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index 105b099..335b740 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -2625,10 +2625,7 @@
          If false, not supported. -->
     <bool name="config_duplicate_port_omadm_wappush">false</bool>
 
-    <!-- Maximum numerical value that will be shown in a status bar
-         notification icon or in the notification itself. Will be replaced
-         with @string/status_bar_notification_info_overflow when shown in the
-         UI. -->
+    <!-- @deprecated No longer used. -->
     <integer name="status_bar_notification_info_maxnum">999</integer>
 
     <!-- Path to an ISO image to be shared with via USB mass storage.
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index 05c46b9..9d1e86b 100644
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -788,11 +788,7 @@
     <!-- label for item that locks the phone and enforces that it can't be unlocked without strong authentication. [CHAR LIMIT=24] -->
     <string name="global_action_lockdown">Lockdown</string>
 
-    <!-- Text to use when the number in a notification info is too large
-         (greater than status_bar_notification_info_maxnum, defined in
-         values/config.xml) and must be truncated. May need to be localized
-         for most appropriate textual indicator of "more than X".
-         [CHAR LIMIT=4] -->
+    <!-- @deprecated No longer used. -->
     <string name="status_bar_notification_info_overflow">999+</string>
 
     <!-- The divider symbol between different parts of the notification header. not translatable [CHAR LIMIT=1] -->
@@ -3837,6 +3833,11 @@
     <!-- Message of notification shown when Test Harness Mode is enabled. [CHAR LIMIT=NONE] -->
     <string name="test_harness_mode_notification_message">Perform a factory reset to disable Test Harness Mode.</string>
 
+    <!-- Title of notification shown when device is in the wrong Headless System User Mode configuration. [CHAR LIMIT=NONE] -->
+    <string name="wrong_hsum_configuration_notification_title">Wrong HSUM build configuration</string>
+    <!-- Message of notification shown when device is in the wrong Headless System User Mode configuration. [CHAR LIMIT=NONE] -->
+    <string name="wrong_hsum_configuration_notification_message">The Headless System User Mode state of this device differs from its build configuration. Please factory reset the device.</string>
+
     <!-- Title of notification shown when serial console is enabled. [CHAR LIMIT=NONE] -->
     <string name="console_running_notification_title">Serial console enabled</string>
     <!-- Message of notification shown when serial console is enabled. [CHAR LIMIT=NONE] -->
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 0f54d89..8823894 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -2153,6 +2153,8 @@
   <java-symbol type="string" name="adbwifi_active_notification_title" />
   <java-symbol type="string" name="test_harness_mode_notification_title" />
   <java-symbol type="string" name="test_harness_mode_notification_message" />
+  <java-symbol type="string" name="wrong_hsum_configuration_notification_title" />
+  <java-symbol type="string" name="wrong_hsum_configuration_notification_message" />
   <java-symbol type="string" name="console_running_notification_title" />
   <java-symbol type="string" name="console_running_notification_message" />
   <java-symbol type="string" name="mte_override_notification_title" />
diff --git a/core/tests/coretests/src/android/content/ContentResolverTest.java b/core/tests/coretests/src/android/content/ContentResolverTest.java
index c8015d4..7b70b41 100644
--- a/core/tests/coretests/src/android/content/ContentResolverTest.java
+++ b/core/tests/coretests/src/android/content/ContentResolverTest.java
@@ -87,7 +87,7 @@
         bitmap.compress(Bitmap.CompressFormat.PNG, 90, mImage.getOutputStream());
 
         final AssetFileDescriptor afd = new AssetFileDescriptor(
-                ParcelFileDescriptor.dup(mImage.getFileDescriptor()), 0, mSize, null);
+                new ParcelFileDescriptor(mImage.getFileDescriptor()), 0, mSize, null);
         when(mProvider.openTypedAssetFile(any(), any(), any(), any(), any())).thenReturn(
                 afd);
     }
diff --git a/core/tests/coretests/src/android/view/ViewRootImplTest.java b/core/tests/coretests/src/android/view/ViewRootImplTest.java
index 06cb0ee..b153700 100644
--- a/core/tests/coretests/src/android/view/ViewRootImplTest.java
+++ b/core/tests/coretests/src/android/view/ViewRootImplTest.java
@@ -1250,6 +1250,81 @@
         });
     }
 
+    @Test
+    @RequiresFlagsEnabled({FLAG_TOOLKIT_SET_FRAME_RATE_READ_ONLY,
+            FLAG_TOOLKIT_FRAME_RATE_FUNCTION_ENABLING_READ_ONLY,
+            FLAG_TOOLKIT_FRAME_RATE_VIEW_ENABLING_READ_ONLY,
+            FLAG_TOOLKIT_FRAME_RATE_BY_SIZE_READ_ONLY})
+    public void votePreferredFrameRate_infrequentLayer_smallView_voteForLow() throws Throwable {
+        if (!ViewProperties.vrr_enabled().orElse(true)) {
+            return;
+        }
+        final long delay = 200L;
+
+        mView = new View(sContext);
+        WindowManager.LayoutParams wmlp = new WindowManager.LayoutParams(TYPE_APPLICATION_OVERLAY);
+        wmlp.token = new Binder(); // Set a fake token to bypass 'is your activity running' check
+        wmlp.width = 1;
+        wmlp.height = 1;
+
+        // The view is a small view, and it should vote for category low only.
+        int expected = FRAME_RATE_CATEGORY_LOW;
+
+        sInstrumentation.runOnMainSync(() -> {
+            WindowManager wm = sContext.getSystemService(WindowManager.class);
+            wm.addView(mView, wmlp);
+        });
+        sInstrumentation.waitForIdleSync();
+
+        mViewRootImpl = mView.getViewRootImpl();
+        waitForFrameRateCategoryToSettle(mView);
+
+        // In transition from frequent update to infrequent update
+        Thread.sleep(delay);
+        sInstrumentation.runOnMainSync(() -> {
+            mView.invalidate();
+            runAfterDraw(() -> assertEquals(expected,
+                    mViewRootImpl.getLastPreferredFrameRateCategory()));
+        });
+        waitForAfterDraw();
+
+        // In transition from frequent update to infrequent update
+        Thread.sleep(delay);
+        sInstrumentation.runOnMainSync(() -> {
+            mView.invalidate();
+            runAfterDraw(() -> assertEquals(expected,
+                    mViewRootImpl.getLastPreferredFrameRateCategory()));
+        });
+
+        // Infrequent update
+        Thread.sleep(delay);
+
+        // The view is small, the expected category is still low for intermittent.
+        int intermittentExpected = FRAME_RATE_CATEGORY_LOW;
+
+        sInstrumentation.runOnMainSync(() -> {
+            mView.invalidate();
+            runAfterDraw(() -> assertEquals(intermittentExpected,
+                    mViewRootImpl.getLastPreferredFrameRateCategory()));
+        });
+        waitForAfterDraw();
+
+        // When the View vote, it's still considered as intermittent update state
+        sInstrumentation.runOnMainSync(() -> {
+            mView.invalidate();
+            runAfterDraw(() -> assertEquals(intermittentExpected,
+                    mViewRootImpl.getLastPreferredFrameRateCategory()));
+        });
+        waitForAfterDraw();
+
+        // Becomes frequent update state
+        sInstrumentation.runOnMainSync(() -> {
+            mView.invalidate();
+            runAfterDraw(() -> assertEquals(expected,
+                    mViewRootImpl.getLastPreferredFrameRateCategory()));
+        });
+    }
+
     /**
      * Test the IsFrameRatePowerSavingsBalanced values are properly set
      */
diff --git a/libs/WindowManager/Shell/AndroidManifest.xml b/libs/WindowManager/Shell/AndroidManifest.xml
index bcb1d29..52ae93f 100644
--- a/libs/WindowManager/Shell/AndroidManifest.xml
+++ b/libs/WindowManager/Shell/AndroidManifest.xml
@@ -29,6 +29,7 @@
             android:name=".desktopmode.DesktopWallpaperActivity"
             android:excludeFromRecents="true"
             android:launchMode="singleInstance"
+            android:showForAllUsers="true"
             android:theme="@style/DesktopWallpaperTheme" />
 
         <activity
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
index 87bd840..1fcfa7f 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
@@ -23,6 +23,7 @@
 import android.os.UserManager;
 import android.view.Choreographer;
 import android.view.IWindowManager;
+import android.view.SurfaceControl;
 import android.view.WindowManager;
 
 import com.android.internal.jank.InteractionJankMonitor;
@@ -400,7 +401,8 @@
             Optional<RecentTasksController> recentTasksController,
             HomeTransitionObserver homeTransitionObserver) {
         return new RecentsTransitionHandler(shellInit, transitions,
-                recentTasksController.orElse(null), homeTransitionObserver);
+                recentTasksController.orElse(null), homeTransitionObserver,
+                SurfaceControl.Transaction::new);
     }
 
     //
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
index 9bf244e..81891ce 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
@@ -47,7 +47,7 @@
         val visibleTasks: ArraySet<Int> = ArraySet(),
         val minimizedTasks: ArraySet<Int> = ArraySet(),
         // Tasks that are closing, but are still visible
-        //  TODO(b/332682201): Remove when the repository state is updated via TransitionObserver
+        // TODO(b/332682201): Remove when the repository state is updated via TransitionObserver
         val closingTasks: ArraySet<Int> = ArraySet(),
         // Tasks currently in freeform mode, ordered from top to bottom (top is at index 0).
         val freeformTasksInZOrder: ArrayList<Int> = ArrayList(),
@@ -234,17 +234,16 @@
     }
 
     /**
-     * Check if a task with the given [taskId] is the only active, non-closing, not-minimized task
+     * Check if a task with the given [taskId] is the only visible, non-closing, not-minimized task
      * on its display
      */
-    fun isOnlyActiveNonClosingTask(taskId: Int): Boolean {
-        return displayData.valueIterator().asSequence().any { data ->
-            data.activeTasks
+    fun isOnlyVisibleNonClosingTask(taskId: Int): Boolean =
+        displayData.valueIterator().asSequence().any { data ->
+            data.visibleTasks
                 .subtract(data.closingTasks)
                 .subtract(data.minimizedTasks)
                 .singleOrNull() == taskId
         }
-    }
 
     /** Get a set of the active tasks for given [displayId] */
     fun getActiveTasks(displayId: Int): ArraySet<Int> {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
index d28cda3..14ae3a7 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
@@ -40,7 +40,6 @@
 import android.view.WindowManager.TRANSIT_CHANGE
 import android.view.WindowManager.TRANSIT_NONE
 import android.view.WindowManager.TRANSIT_OPEN
-import android.view.WindowManager.TRANSIT_TO_BACK
 import android.view.WindowManager.TRANSIT_TO_FRONT
 import android.window.RemoteTransition
 import android.window.TransitionInfo
@@ -76,6 +75,7 @@
 import com.android.wm.shell.shared.DesktopModeStatus
 import com.android.wm.shell.shared.DesktopModeStatus.DESKTOP_DENSITY_OVERRIDE
 import com.android.wm.shell.shared.DesktopModeStatus.useDesktopOverrideDensity
+import com.android.wm.shell.shared.TransitionUtil
 import com.android.wm.shell.shared.annotations.ExternalThread
 import com.android.wm.shell.shared.annotations.ShellMainThread
 import com.android.wm.shell.splitscreen.SplitScreenController
@@ -446,7 +446,7 @@
      * @param taskId task id of the window that's being closed
      */
     fun onDesktopWindowClose(wct: WindowContainerTransaction, displayId: Int, taskId: Int) {
-        if (desktopModeTaskRepository.isOnlyActiveNonClosingTask(taskId)) {
+        if (desktopModeTaskRepository.isOnlyVisibleNonClosingTask(taskId)) {
             removeWallpaperActivity(wct)
         }
         if (!desktopModeTaskRepository.addClosingTask(displayId, taskId)) {
@@ -879,8 +879,8 @@
                     reason = "recents animation is running"
                     false
                 }
-                // Handle back navigation for the last window if wallpaper available
-                shouldHandleBackNavigation(request) -> true
+                // Handle task closing for the last window if wallpaper is available
+                shouldHandleTaskClosing(request) -> true
                 // Only handle open or to front transitions
                 request.type != TRANSIT_OPEN && request.type != TRANSIT_TO_FRONT -> {
                     reason = "transition type not handled (${request.type})"
@@ -918,7 +918,8 @@
         val result =
             triggerTask?.let { task ->
                 when {
-                    request.type == TRANSIT_TO_BACK -> handleBackNavigation(task)
+                    // Check if the closing task needs to be handled
+                    TransitionUtil.isClosingType(request.type) -> handleTaskClosing(task)
                     // Check if the task has a top transparent activity
                     shouldLaunchAsModal(task) -> handleIncompatibleTaskLaunch(task)
                     // Check if the task has a top systemUI activity
@@ -960,9 +961,10 @@
     private fun shouldLaunchAsModal(task: TaskInfo) =
         Flags.enableDesktopWindowingModalsPolicy() && isSingleTopActivityTranslucent(task)
 
-    private fun shouldHandleBackNavigation(request: TransitionRequestInfo): Boolean {
+    private fun shouldHandleTaskClosing(request: TransitionRequestInfo): Boolean {
         return Flags.enableDesktopWindowingWallpaperActivity() &&
-            request.type == TRANSIT_TO_BACK
+            TransitionUtil.isClosingType(request.type) &&
+            request.triggerTask != null
     }
 
     private fun handleFreeformTaskLaunch(
@@ -1029,10 +1031,10 @@
         return WindowContainerTransaction().also { wct -> addMoveToFullscreenChanges(wct, task) }
     }
 
-    /** Handle back navigation by removing wallpaper activity if it's the last active task */
-    private fun handleBackNavigation(task: RunningTaskInfo): WindowContainerTransaction? {
+    /** Handle task closing by removing wallpaper activity if it's the last active task */
+    private fun handleTaskClosing(task: RunningTaskInfo): WindowContainerTransaction? {
         val wct = if (
-            desktopModeTaskRepository.isOnlyActiveNonClosingTask(task.taskId) &&
+            desktopModeTaskRepository.isOnlyVisibleNonClosingTask(task.taskId) &&
                 desktopModeTaskRepository.wallpaperActivityToken != null
         ) {
             // Remove wallpaper activity when the last active task is removed
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/ToggleResizeDesktopTaskTransitionHandler.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/ToggleResizeDesktopTaskTransitionHandler.kt
index 88d0554..5335c0b 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/ToggleResizeDesktopTaskTransitionHandler.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/ToggleResizeDesktopTaskTransitionHandler.kt
@@ -27,6 +27,8 @@
 import android.window.TransitionRequestInfo
 import android.window.WindowContainerTransaction
 import androidx.core.animation.addListener
+import com.android.internal.jank.Cuj
+import com.android.wm.shell.common.InteractionJankMonitorUtils
 import com.android.wm.shell.transition.Transitions
 import com.android.wm.shell.transition.Transitions.TRANSIT_DESKTOP_MODE_TOGGLE_RESIZE
 import com.android.wm.shell.windowdecor.OnTaskResizeAnimationListener
@@ -103,6 +105,8 @@
                             onTaskResizeAnimationListener.onAnimationEnd(taskId)
                             finishCallback.onTransitionFinished(null)
                             boundsAnimator = null
+                            InteractionJankMonitorUtils.endTracing(
+                                Cuj.CUJ_DESKTOP_MODE_MAXIMIZE_WINDOW)
                         }
                     )
                     addUpdateListener { anim ->
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/docs/debugging.md b/libs/WindowManager/Shell/src/com/android/wm/shell/docs/debugging.md
index 438aa76..b1cbe8d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/docs/debugging.md
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/docs/debugging.md
@@ -73,7 +73,7 @@
 following system properties for example:
 ```shell
 # Enabling
-adb shell setprop persist.wm.debug.sc.tx.log_match_call setAlpha  # matches the name of the SurfaceControlTransaction method
+adb shell setprop persist.wm.debug.sc.tx.log_match_call setAlpha,setPosition  # matches the name of the SurfaceControlTransaction methods
 adb shell setprop persist.wm.debug.sc.tx.log_match_name com.android.systemui # matches the name of the surface
 adb reboot
 adb logcat -s "SurfaceControlRegistry"
@@ -87,6 +87,16 @@
 It is not necessary to set both `log_match_call` and `log_match_name`, but note logs can be quite
 noisy if unfiltered.
 
+It can sometimes be useful to trace specific logs and when they are applied (sometimes we build
+transactions that can be applied later).  You can do this by adding the "merge" and "apply" calls to
+the set of requested calls:
+```shell
+# Enabling
+adb shell setprop persist.wm.debug.sc.tx.log_match_call setAlpha,merge,apply  # apply will dump logs of each setAlpha or merge call on that tx
+adb reboot
+adb logcat -s "SurfaceControlRegistry"
+```
+
 ## Tracing activity starts in the app process
 
 It's sometimes useful to know when to see a stack trace of when an activity starts in the app code
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
index b88afb9..b48aee5 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
@@ -122,10 +122,6 @@
             mDesktopModeTaskRepository.ifPresent(repository -> {
                 repository.removeFreeformTask(taskInfo.displayId, taskInfo.taskId);
                 repository.unminimizeTask(taskInfo.displayId, taskInfo.taskId);
-                if (repository.removeClosingTask(taskInfo.taskId)) {
-                    ProtoLog.v(ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
-                            "Removing closing freeform task: #%d", taskInfo.taskId);
-                }
                 if (repository.removeActiveTask(taskInfo.taskId)) {
                     ProtoLog.v(ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
                             "Removing active freeform task: #%d", taskInfo.taskId);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
index 03c8cf8..9f3c519 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
@@ -409,10 +409,6 @@
             if (DesktopModeStatus.canEnterDesktopMode(mContext)
                     && mDesktopModeTaskRepository.isPresent()
                     && mDesktopModeTaskRepository.get().isActiveTask(taskInfo.taskId)) {
-                if (mDesktopModeTaskRepository.get().isMinimizedTask(taskInfo.taskId)) {
-                    // Minimized freeform tasks should not be shown at all.
-                    continue;
-                }
                 // Freeform tasks will be added as a separate entry
                 if (mostRecentFreeformTaskIndex == Integer.MAX_VALUE) {
                     mostRecentFreeformTaskIndex = recentTasks.size();
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java
index 3a266d9..c67cf1d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java
@@ -74,6 +74,7 @@
 
 import java.util.ArrayList;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 
 /**
  * Handles the Recents (overview) animation. Only one of these can run at a time. A recents
@@ -84,6 +85,7 @@
 
     private final Transitions mTransitions;
     private final ShellExecutor mExecutor;
+    private final Supplier<SurfaceControl.Transaction> mTransactionSupplier;
     @Nullable
     private final RecentTasksController mRecentTasksController;
     private IApplicationThread mAnimApp = null;
@@ -101,11 +103,13 @@
 
     public RecentsTransitionHandler(ShellInit shellInit, Transitions transitions,
             @Nullable RecentTasksController recentTasksController,
-            HomeTransitionObserver homeTransitionObserver) {
+            HomeTransitionObserver homeTransitionObserver,
+            Supplier<SurfaceControl.Transaction> transactionSupplier) {
         mTransitions = transitions;
         mExecutor = transitions.getMainExecutor();
         mRecentTasksController = recentTasksController;
         mHomeTransitionObserver = homeTransitionObserver;
+        mTransactionSupplier = transactionSupplier;
         if (!Transitions.ENABLE_SHELL_TRANSITIONS) return;
         if (recentTasksController == null) return;
         shellInit.addInitCallback(() -> {
@@ -1056,7 +1060,7 @@
             final Transitions.TransitionFinishCallback finishCB = mFinishCB;
             mFinishCB = null;
 
-            final SurfaceControl.Transaction t = mFinishTransaction;
+            SurfaceControl.Transaction t = mFinishTransaction;
             final WindowContainerTransaction wct = new WindowContainerTransaction();
 
             if (mKeyguardLocked && mRecentsTask != null) {
@@ -1106,6 +1110,16 @@
                     }
                 }
                 ProtoLog.v(ShellProtoLogGroup.WM_SHELL_RECENTS_TRANSITION, "  normal finish");
+                if (toHome && !mOpeningTasks.isEmpty()) {
+                    // Attempting to start a task after swipe to home, don't show it,
+                    // move recents to top
+                    ProtoLog.v(ShellProtoLogGroup.WM_SHELL_RECENTS_TRANSITION,
+                            "  attempting to start a task after swipe to home");
+                    t = mTransactionSupplier.get();
+                    wct.reorder(mRecentsTask, true /*onTop*/);
+                    mClosingTasks.addAll(mOpeningTasks);
+                    mOpeningTasks.clear();
+                }
                 // The general case: committing to recents, going home, or switching tasks.
                 for (int i = 0; i < mOpeningTasks.size(); ++i) {
                     t.show(mOpeningTasks.get(i).mTaskSurface);
@@ -1174,6 +1188,10 @@
                     mPipTransaction = null;
                 }
             }
+            if (t != mFinishTransaction) {
+                // apply after merges because these changes are accounting for finishWCT changes.
+                mTransitions.setAfterMergeFinishTransaction(mTransition, t);
+            }
             cleanUp();
             finishCB.onTransitionFinished(wct.isEmpty() ? null : wct);
             if (runnerFinishCb != null) {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/splitscreen/StageCoordinator.java b/libs/WindowManager/Shell/src/com/android/wm/shell/splitscreen/StageCoordinator.java
index b6a18e5..45eff4a 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/splitscreen/StageCoordinator.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/splitscreen/StageCoordinator.java
@@ -2649,7 +2649,7 @@
             @Nullable TransitionRequestInfo request) {
         final ActivityManager.RunningTaskInfo triggerTask = request.getTriggerTask();
         if (triggerTask == null) {
-            if (isSplitActive()) {
+            if (isSplitScreenVisible()) {
                 ProtoLog.d(WM_SHELL_SPLIT_SCREEN, "handleRequest: transition=%d display rotation",
                         request.getDebugId());
                 // Check if the display is rotating.
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/CounterRotatorHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/CounterRotatorHelper.java
index b03daaa..35427b9 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/CounterRotatorHelper.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/CounterRotatorHelper.java
@@ -94,6 +94,11 @@
         return rotatedBounds;
     }
 
+    /** Returns true if the change is put on a surface in previous rotation. */
+    public boolean isRotated(@NonNull TransitionInfo.Change change) {
+        return mLastRotationDelta != 0 && mRotatorMap.containsKey(change.getParent());
+    }
+
     /**
      * Removes the counter rotation surface in the finish transaction. No need to reparent the
      * children as the finish transaction should have already taken care of that.
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 018c904..9412b2b 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
@@ -517,7 +517,8 @@
                     animRelOffset.y = Math.max(animRelOffset.y, change.getEndRelOffset().y);
                 }
 
-                if (change.getActivityComponent() != null && !isActivityLevel) {
+                if (change.getActivityComponent() != null && !isActivityLevel
+                        && !mRotator.isRotated(change)) {
                     // 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
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 f257e20..d2760ff 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
@@ -238,6 +238,13 @@
         /** Ordered list of transitions which have been merged into this one. */
         private ArrayList<ActiveTransition> mMerged;
 
+        /**
+         * @deprecated DO NOT USE THIS unless absolutely necessary. It will be removed once
+         * everything migrates off finishWCT.
+         */
+        @java.lang.Deprecated
+        SurfaceControl.Transaction mAfterMergeFinishT;
+
         ActiveTransition(IBinder token) {
             mToken = token;
         }
@@ -1018,6 +1025,20 @@
         return null;
     }
 
+    /** @deprecated */
+    @java.lang.Deprecated
+    public void setAfterMergeFinishTransaction(IBinder transition,
+            SurfaceControl.Transaction afterMergeFinishT) {
+        final ActiveTransition at = mKnownTransitions.get(transition);
+        if (at == null) return;
+        if (at.mAfterMergeFinishT != null) {
+            Log.e(TAG, "Setting after-merge-t >1 time on transition: " + at.mInfo.getDebugId());
+            at.mAfterMergeFinishT.merge(afterMergeFinishT);
+            return;
+        }
+        at.mAfterMergeFinishT = afterMergeFinishT;
+    }
+
     /** Aborts a transition. This will still queue it up to maintain order. */
     private void onAbort(ActiveTransition transition) {
         final Track track = mTracks.get(transition.getTrack());
@@ -1078,6 +1099,7 @@
         }
         // Merge all associated transactions together
         SurfaceControl.Transaction fullFinish = active.mFinishT;
+        SurfaceControl.Transaction afterMergeFinish = active.mAfterMergeFinishT;
         if (active.mMerged != null) {
             for (int iM = 0; iM < active.mMerged.size(); ++iM) {
                 final ActiveTransition toMerge = active.mMerged.get(iM);
@@ -1097,6 +1119,21 @@
                         fullFinish.merge(toMerge.mFinishT);
                     }
                 }
+                if (toMerge.mAfterMergeFinishT != null) {
+                    if (afterMergeFinish == null) {
+                        afterMergeFinish = toMerge.mAfterMergeFinishT;
+                    } else {
+                        afterMergeFinish.merge(toMerge.mAfterMergeFinishT);
+                    }
+                    toMerge.mAfterMergeFinishT = null;
+                }
+            }
+        }
+        if (afterMergeFinish != null) {
+            if (fullFinish == null) {
+                fullFinish = afterMergeFinish;
+            } else {
+                fullFinish.merge(afterMergeFinish);
             }
         }
         if (fullFinish != null) {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
index 5e7e5e6..e1009a0 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
@@ -73,6 +73,7 @@
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.jank.Cuj;
 import com.android.internal.protolog.common.ProtoLog;
 import com.android.window.flags.Flags;
 import com.android.wm.shell.R;
@@ -81,6 +82,7 @@
 import com.android.wm.shell.common.DisplayController;
 import com.android.wm.shell.common.DisplayInsetsController;
 import com.android.wm.shell.common.DisplayLayout;
+import com.android.wm.shell.common.InteractionJankMonitorUtils;
 import com.android.wm.shell.common.ShellExecutor;
 import com.android.wm.shell.common.SyncTransactionQueue;
 import com.android.wm.shell.common.desktopmode.DesktopModeTransitionSource;
@@ -470,11 +472,17 @@
             } else if (id == R.id.collapse_menu_button) {
                 decoration.closeHandleMenu();
             } else if (id == R.id.maximize_window) {
+                InteractionJankMonitorUtils.beginTracing(
+                        Cuj.CUJ_DESKTOP_MODE_MAXIMIZE_WINDOW, /* view= */ v,
+                        /* tag= */ "caption_bar_button");
                 final RunningTaskInfo taskInfo = decoration.mTaskInfo;
                 decoration.closeHandleMenu();
                 decoration.closeMaximizeMenu();
                 mDesktopTasksController.toggleDesktopTaskSize(taskInfo);
             } else if (id == R.id.maximize_menu_maximize_button) {
+                InteractionJankMonitorUtils.beginTracing(
+                        Cuj.CUJ_DESKTOP_MODE_MAXIMIZE_WINDOW, /* view= */ v,
+                        /* tag= */ "maximize_menu_option");
                 final RunningTaskInfo taskInfo = decoration.mTaskInfo;
                 mDesktopTasksController.toggleDesktopTaskSize(taskInfo);
                 decoration.closeHandleMenu();
@@ -712,6 +720,9 @@
                 return false;
             }
             final DesktopModeWindowDecoration decoration = mWindowDecorByTaskId.get(mTaskId);
+            InteractionJankMonitorUtils.beginTracing(
+                    Cuj.CUJ_DESKTOP_MODE_MAXIMIZE_WINDOW, mContext,
+                    /* surface= */ decoration.mTaskSurface, /* tag= */ "double_tap");
             mDesktopTasksController.toggleDesktopTaskSize(decoration.mTaskInfo);
             return true;
         }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtility.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtility.java
index fe1c9c3..d48ce53 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtility.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtility.java
@@ -28,6 +28,8 @@
 import android.util.DisplayMetrics;
 import android.view.SurfaceControl;
 
+import androidx.annotation.NonNull;
+
 import com.android.window.flags.Flags;
 import com.android.wm.shell.R;
 import com.android.wm.shell.common.DisplayController;
@@ -106,13 +108,15 @@
             repositionTaskBounds.bottom = (candidateBottom < stableBounds.bottom)
                     ? candidateBottom : oldBottom;
         }
-        // If width or height are negative or less than the minimum width or height, revert the
+        // If width or height are negative or exceeding the width or height constraints, revert the
         // respective bounds to use previous bound dimensions.
-        if (repositionTaskBounds.width() < getMinWidth(displayController, windowDecoration)) {
+        if (isExceedingWidthConstraint(repositionTaskBounds, stableBounds, displayController,
+                windowDecoration)) {
             repositionTaskBounds.right = oldRight;
             repositionTaskBounds.left = oldLeft;
         }
-        if (repositionTaskBounds.height() < getMinHeight(displayController, windowDecoration)) {
+        if (isExceedingHeightConstraint(repositionTaskBounds, stableBounds, displayController,
+                windowDecoration)) {
             repositionTaskBounds.top = oldTop;
             repositionTaskBounds.bottom = oldBottom;
         }
@@ -174,6 +178,30 @@
         return result;
     }
 
+    private static boolean isExceedingWidthConstraint(@NonNull Rect repositionTaskBounds,
+            Rect maxResizeBounds, DisplayController displayController,
+            WindowDecoration windowDecoration) {
+        // Check if width is less than the minimum width constraint.
+        if (repositionTaskBounds.width() < getMinWidth(displayController, windowDecoration)) {
+            return true;
+        }
+        // Check if width is more than the maximum resize bounds on desktop windowing mode.
+        return isSizeConstraintForDesktopModeEnabled(windowDecoration.mDecorWindowContext)
+                && repositionTaskBounds.width() > maxResizeBounds.width();
+    }
+
+    private static boolean isExceedingHeightConstraint(@NonNull Rect repositionTaskBounds,
+            Rect maxResizeBounds, DisplayController displayController,
+            WindowDecoration windowDecoration) {
+        // Check if height is less than the minimum height constraint.
+        if (repositionTaskBounds.height() < getMinHeight(displayController, windowDecoration)) {
+            return true;
+        }
+        // Check if height is more than the maximum resize bounds on desktop windowing mode.
+        return isSizeConstraintForDesktopModeEnabled(windowDecoration.mDecorWindowContext)
+                && repositionTaskBounds.height() > maxResizeBounds.height();
+    }
+
     private static float getMinWidth(DisplayController displayController,
             WindowDecoration windowDecoration) {
         return windowDecoration.mTaskInfo.minWidth < 0 ? getDefaultMinWidth(displayController,
@@ -210,7 +238,7 @@
 
     private static float getDefaultMinSize(DisplayController displayController,
             WindowDecoration windowDecoration) {
-        float density =  displayController.getDisplayLayout(windowDecoration.mTaskInfo.displayId)
+        float density = displayController.getDisplayLayout(windowDecoration.mTaskInfo.displayId)
                 .densityDpi() * DisplayMetrics.DENSITY_DEFAULT_SCALE;
         return windowDecoration.mTaskInfo.defaultMinSize * density;
     }
diff --git a/libs/WindowManager/Shell/tests/unittest/Android.bp b/libs/WindowManager/Shell/tests/unittest/Android.bp
index 13f95cc..92be4f9 100644
--- a/libs/WindowManager/Shell/tests/unittest/Android.bp
+++ b/libs/WindowManager/Shell/tests/unittest/Android.bp
@@ -46,6 +46,7 @@
         "androidx.dynamicanimation_dynamicanimation",
         "dagger2",
         "frameworks-base-testutils",
+        "kotlin-test",
         "kotlinx-coroutines-android",
         "kotlinx-coroutines-core",
         "mockito-kotlin2",
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
index 193d614..6612aee 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
@@ -119,86 +119,91 @@
     }
 
     @Test
-    fun isOnlyActiveNonClosingTask_noActiveNonClosingTasks() {
-        // Not an active task
-        assertThat(repo.isOnlyActiveNonClosingTask(1)).isFalse()
+    fun isOnlyVisibleNonClosingTask_noTasks() {
+        // No visible tasks
+        assertThat(repo.isOnlyVisibleNonClosingTask(1)).isFalse()
         assertThat(repo.isClosingTask(1)).isFalse()
     }
 
     @Test
-    fun isOnlyActiveNonClosingTask_singleActiveNonClosingTask() {
-        repo.addActiveTask(DEFAULT_DISPLAY, 1)
-        // The only active task
-        assertThat(repo.isActiveTask(1)).isTrue()
+    fun isOnlyVisibleNonClosingTask_singleVisibleNonClosingTask() {
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 1, visible = true)
+
+        // The only visible task
+        assertThat(repo.isVisibleTask(1)).isTrue()
         assertThat(repo.isClosingTask(1)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(1)).isTrue()
-        // Not an active task
-        assertThat(repo.isActiveTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(1)).isTrue()
+        // Not a visible task
+        assertThat(repo.isVisibleTask(99)).isFalse()
         assertThat(repo.isClosingTask(99)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(99)).isFalse()
     }
 
     @Test
-    fun isOnlyActiveNonClosingTask_singleActiveClosingTask() {
-        repo.addActiveTask(DEFAULT_DISPLAY, 1)
+    fun isOnlyVisibleNonClosingTask_singleVisibleClosingTask() {
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 1, visible = true)
         repo.addClosingTask(DEFAULT_DISPLAY, 1)
-        // The active task that's closing
-        assertThat(repo.isActiveTask(1)).isTrue()
+
+        // A visible task that's closing
+        assertThat(repo.isVisibleTask(1)).isTrue()
         assertThat(repo.isClosingTask(1)).isTrue()
-        assertThat(repo.isOnlyActiveNonClosingTask(1)).isFalse()
-        // Not an active task
-        assertThat(repo.isActiveTask(99)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(1)).isFalse()
+        // Not a visible task
+        assertThat(repo.isVisibleTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(99)).isFalse()
     }
 
     @Test
-    fun isOnlyActiveNonClosingTask_singleActiveMinimizedTask() {
-        repo.addActiveTask(DEFAULT_DISPLAY, 1)
+    fun isOnlyVisibleNonClosingTask_singleVisibleMinimizedTask() {
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 1, visible = true)
         repo.minimizeTask(DEFAULT_DISPLAY, 1)
-        // The active task that's closing
-        assertThat(repo.isActiveTask(1)).isTrue()
+
+        // The visible task that's closing
+        assertThat(repo.isVisibleTask(1)).isTrue()
         assertThat(repo.isMinimizedTask(1)).isTrue()
-        assertThat(repo.isOnlyActiveNonClosingTask(1)).isFalse()
-        // Not an active task
-        assertThat(repo.isActiveTask(99)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(1)).isFalse()
+        // Not a visible task
+        assertThat(repo.isVisibleTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(99)).isFalse()
     }
 
     @Test
-    fun isOnlyActiveNonClosingTask_multipleActiveNonClosingTasks() {
-        repo.addActiveTask(DEFAULT_DISPLAY, 1)
-        repo.addActiveTask(DEFAULT_DISPLAY, 2)
+    fun isOnlyVisibleNonClosingTask_multipleVisibleNonClosingTasks() {
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 1, visible = true)
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 2, visible = true)
+
         // Not the only task
-        assertThat(repo.isActiveTask(1)).isTrue()
+        assertThat(repo.isVisibleTask(1)).isTrue()
         assertThat(repo.isClosingTask(1)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(1)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(1)).isFalse()
         // Not the only task
-        assertThat(repo.isActiveTask(2)).isTrue()
+        assertThat(repo.isVisibleTask(2)).isTrue()
         assertThat(repo.isClosingTask(2)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(2)).isFalse()
-        // Not an active task
-        assertThat(repo.isActiveTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(2)).isFalse()
+        // Not a visible task
+        assertThat(repo.isVisibleTask(99)).isFalse()
         assertThat(repo.isClosingTask(99)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(99)).isFalse()
     }
 
     @Test
-    fun isOnlyActiveNonClosingTask_multipleDisplays() {
-        repo.addActiveTask(DEFAULT_DISPLAY, 1)
-        repo.addActiveTask(DEFAULT_DISPLAY, 2)
-        repo.addActiveTask(SECOND_DISPLAY, 3)
+    fun isOnlyVisibleNonClosingTask_multipleDisplays() {
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 1, visible = true)
+        repo.updateVisibleFreeformTasks(DEFAULT_DISPLAY, taskId = 2, visible = true)
+        repo.updateVisibleFreeformTasks(SECOND_DISPLAY, taskId = 3, visible = true)
+
         // Not the only task on DEFAULT_DISPLAY
-        assertThat(repo.isActiveTask(1)).isTrue()
-        assertThat(repo.isOnlyActiveNonClosingTask(1)).isFalse()
+        assertThat(repo.isVisibleTask(1)).isTrue()
+        assertThat(repo.isOnlyVisibleNonClosingTask(1)).isFalse()
         // Not the only task on DEFAULT_DISPLAY
-        assertThat(repo.isActiveTask(2)).isTrue()
-        assertThat(repo.isOnlyActiveNonClosingTask(2)).isFalse()
-        // The only active task on SECOND_DISPLAY
-        assertThat(repo.isActiveTask(3)).isTrue()
-        assertThat(repo.isOnlyActiveNonClosingTask(3)).isTrue()
-        // Not an active task
-        assertThat(repo.isActiveTask(99)).isFalse()
-        assertThat(repo.isOnlyActiveNonClosingTask(99)).isFalse()
+        assertThat(repo.isVisibleTask(2)).isTrue()
+        assertThat(repo.isOnlyVisibleNonClosingTask(2)).isFalse()
+        // The only visible task on SECOND_DISPLAY
+        assertThat(repo.isVisibleTask(3)).isTrue()
+        assertThat(repo.isOnlyVisibleNonClosingTask(3)).isTrue()
+        // Not a visible task
+        assertThat(repo.isVisibleTask(99)).isFalse()
+        assertThat(repo.isOnlyVisibleNonClosingTask(99)).isFalse()
     }
 
     @Test
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
index 392161f..a1a18a9 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
@@ -45,6 +45,7 @@
 import android.view.SurfaceControl
 import android.view.WindowManager
 import android.view.WindowManager.TRANSIT_CHANGE
+import android.view.WindowManager.TRANSIT_CLOSE
 import android.view.WindowManager.TRANSIT_OPEN
 import android.view.WindowManager.TRANSIT_TO_BACK
 import android.view.WindowManager.TRANSIT_TO_FRONT
@@ -102,6 +103,8 @@
 import java.util.Optional
 import junit.framework.Assert.assertFalse
 import junit.framework.Assert.assertTrue
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
 import org.junit.After
 import org.junit.Assume.assumeTrue
 import org.junit.Before
@@ -1254,72 +1257,205 @@
   }
 
   @Test
-  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_singleActiveTask_noToken() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_backTransition_singleActiveTaskNoTokenFlagDisabled_doesNotHandle() {
     val task = setUpFreeformTask()
+
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK))
-    // Doesn't handle request
-    assertThat(result).isNull()
+
+    assertNull(result, "Should not handle request")
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_backTransition_singleActiveTaskNoTokenFlagEnabled_doesNotHandle() {
+    val task = setUpFreeformTask()
+
+    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK))
+
+    assertNull(result, "Should not handle request")
   }
 
   @Test
   @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_singleActiveTask_hasToken_desktopWallpaperDisabled() {
-    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
-
+  fun handleRequest_backTransition_singleActiveTaskWithTokenFlagDisabled_doesNotHandle() {
     val task = setUpFreeformTask()
+
+    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK))
-    // Doesn't handle request
-    assertThat(result).isNull()
+
+    assertNull(result, "Should not handle request")
   }
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_singleActiveTask_hasToken_desktopWallpaperEnabled() {
+  fun handleRequest_backTransition_singleActiveTaskWithTokenFlagEnabled_handlesRequest() {
+    val task = setUpFreeformTask()
     val wallpaperToken = MockToken().token()
+
     desktopModeTaskRepository.wallpaperActivityToken = wallpaperToken
-
-    val task = setUpFreeformTask()
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK))
-    assertThat(result).isNotNull()
-    // Creates remove wallpaper transaction
-    result!!.assertRemoveAt(index = 0, wallpaperToken)
+
+    assertNotNull(result, "Should handle request")
+      // Should create remove wallpaper transaction
+      .assertRemoveAt(index = 0, wallpaperToken)
+  }
+
+  @Test
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_backTransition_multipleActiveTasksFlagDisabled_doesNotHandle() {
+    val task1 = setUpFreeformTask()
+    setUpFreeformTask()
+
+    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
+    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_TO_BACK))
+
+    assertNull(result, "Should not handle request")
   }
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_multipleActiveTasks() {
-    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
-
+  fun handleRequest_backTransition_multipleActiveTasksFlagEnabled_doesNotHandle() {
     val task1 = setUpFreeformTask()
     setUpFreeformTask()
+
+    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
     val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_TO_BACK))
-    // Doesn't handle request
-    assertThat(result).isNull()
+
+    assertNull(result, "Should not handle request")
   }
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_multipleActiveTasks_singleNonClosing() {
-    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
+  fun handleRequest_backTransition_multipleActiveTasksSingleNonClosing_handlesRequest() {
+    val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val wallpaperToken = MockToken().token()
 
-    val task1 = setUpFreeformTask()
-    setUpFreeformTask()
+    desktopModeTaskRepository.wallpaperActivityToken = wallpaperToken
+    desktopModeTaskRepository.addClosingTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
     val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_TO_BACK))
-    // Doesn't handle request
-    assertThat(result).isNull()
+
+    assertNotNull(result, "Should handle request")
+      // Should create remove wallpaper transaction
+      .assertRemoveAt(index = 0, wallpaperToken)
   }
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_multipleActiveTasks_singleNonMinimized() {
-    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
+  fun handleRequest_backTransition_multipleActiveTasksSingleNonMinimized_handlesRequest() {
+    val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val wallpaperToken = MockToken().token()
 
+    desktopModeTaskRepository.wallpaperActivityToken = wallpaperToken
+    desktopModeTaskRepository.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
+    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_TO_BACK))
+
+    assertNotNull(result, "Should handle request")
+      // Should create remove wallpaper transaction
+      .assertRemoveAt(index = 0, wallpaperToken)
+  }
+
+  @Test
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_singleActiveTaskNoTokenFlagDisabled_doesNotHandle() {
+    val task = setUpFreeformTask()
+
+    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
+
+    assertNull(result, "Should not handle request")
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_singleActiveTaskNoTokenFlagEnabled_doesNotHandle() {
+    val task = setUpFreeformTask()
+
+    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
+
+    assertNull(result, "Should not handle request")
+  }
+
+  @Test
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_singleActiveTaskWithTokenFlagDisabled_doesNotHandle() {
+    val task = setUpFreeformTask()
+
+    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
+    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
+
+    assertNull(result, "Should not handle request")
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_singleActiveTaskWithTokenFlagEnabled_handlesRequest() {
+    val task = setUpFreeformTask()
+    val wallpaperToken = MockToken().token()
+
+    desktopModeTaskRepository.wallpaperActivityToken = wallpaperToken
+    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
+
+    assertNotNull(result, "Should handle request")
+      // Should create remove wallpaper transaction
+      .assertRemoveAt(index = 0, wallpaperToken)
+  }
+
+  @Test
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_multipleActiveTasksFlagDisabled_doesNotHandle() {
     val task1 = setUpFreeformTask()
     setUpFreeformTask()
-    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_TO_BACK))
-    // Doesn't handle request
-    assertThat(result).isNull()
+
+    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
+    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
+
+    assertNull(result, "Should not handle request")
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_multipleActiveTasksFlagEnabled_doesNotHandle() {
+    val task1 = setUpFreeformTask()
+    setUpFreeformTask()
+
+    desktopModeTaskRepository.wallpaperActivityToken = MockToken().token()
+    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
+
+    assertNull(result, "Should not handle request")
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_multipleActiveTasksSingleNonClosing_handlesRequest() {
+    val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val wallpaperToken = MockToken().token()
+
+    desktopModeTaskRepository.wallpaperActivityToken = wallpaperToken
+    desktopModeTaskRepository.addClosingTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
+    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
+
+    assertNotNull(result, "Should handle request")
+      // Should create remove wallpaper transaction
+      .assertRemoveAt(index = 0, wallpaperToken)
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_multipleActiveTasksSingleNonMinimized_handlesRequest() {
+    val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+    val wallpaperToken = MockToken().token()
+
+    desktopModeTaskRepository.wallpaperActivityToken = wallpaperToken
+    desktopModeTaskRepository.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
+    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
+
+    assertNotNull(result, "Should handle request")
+      // Should create remove wallpaper transaction
+      .assertRemoveAt(index = 0, wallpaperToken)
   }
 
   @Test
@@ -1693,6 +1829,7 @@
     task.topActivityInfo = activityInfo
     whenever(shellTaskOrganizer.getRunningTaskInfo(task.taskId)).thenReturn(task)
     desktopModeTaskRepository.addActiveTask(displayId, task.taskId)
+    desktopModeTaskRepository.updateVisibleFreeformTasks(displayId, task.taskId, visible = true)
     desktopModeTaskRepository.addOrMoveFreeformTaskToTop(displayId, task.taskId)
     runningTasks.add(task)
     return task
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
index e291c0e..5c5a1a2 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
@@ -399,7 +399,7 @@
     }
 
     @Test
-    public void testGetRecentTasks_proto2Enabled_ignoresMinimizedFreeformTasks() {
+    public void testGetRecentTasks_proto2Enabled_includesMinimizedFreeformTasks() {
         ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
         ActivityManager.RecentTaskInfo t2 = makeTaskInfo(2);
         ActivityManager.RecentTaskInfo t3 = makeTaskInfo(3);
@@ -415,8 +415,7 @@
         ArrayList<GroupedRecentTaskInfo> recentTasks = mRecentTasksController.getRecentTasks(
                 MAX_VALUE, RECENT_IGNORE_UNAVAILABLE, 0);
 
-        // 2 freeform tasks should be grouped into one, 1 task should be skipped, 3 total recents
-        // entries
+        // 3 freeform tasks should be grouped into one, 2 single tasks, 3 total recents entries
         assertEquals(3, recentTasks.size());
         GroupedRecentTaskInfo freeformGroup = recentTasks.get(0);
         GroupedRecentTaskInfo singleGroup1 = recentTasks.get(1);
@@ -428,9 +427,10 @@
         assertEquals(GroupedRecentTaskInfo.TYPE_SINGLE, singleGroup2.getType());
 
         // Check freeform group entries
-        assertEquals(2, freeformGroup.getTaskInfoList().size());
+        assertEquals(3, freeformGroup.getTaskInfoList().size());
         assertEquals(t1, freeformGroup.getTaskInfoList().get(0));
-        assertEquals(t5, freeformGroup.getTaskInfoList().get(1));
+        assertEquals(t3, freeformGroup.getTaskInfoList().get(1));
+        assertEquals(t5, freeformGroup.getTaskInfoList().get(2));
 
         // Check single entries
         assertEquals(t2, singleGroup1.getTaskInfo1());
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/ShellTransitionTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/ShellTransitionTests.java
index 964d86e..69a61ea 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/ShellTransitionTests.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/ShellTransitionTests.java
@@ -1192,7 +1192,8 @@
                         mMainHandler, mAnimExecutor, mock(HomeTransitionObserver.class));
         final RecentsTransitionHandler recentsHandler =
                 new RecentsTransitionHandler(shellInit, transitions,
-                        mock(RecentTasksController.class), mock(HomeTransitionObserver.class));
+                        mock(RecentTasksController.class), mock(HomeTransitionObserver.class),
+                        () -> mock(SurfaceControl.Transaction.class));
         transitions.replaceDefaultHandlerForTest(mDefaultHandler);
         shellInit.init();
 
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtilityTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtilityTest.kt
index f750e6b..86aded7 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtilityTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DragPositioningCallbackUtilityTest.kt
@@ -21,7 +21,9 @@
 import android.graphics.PointF
 import android.graphics.Rect
 import android.os.IBinder
+import android.platform.test.annotations.DisableFlags
 import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
 import android.testing.AndroidTestingRunner
 import android.view.Display
 import android.window.WindowContainerToken
@@ -36,6 +38,7 @@
 import com.google.common.truth.Truth.assertThat
 import junit.framework.Assert.assertTrue
 import org.junit.Before
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mock
@@ -53,21 +56,32 @@
 class DragPositioningCallbackUtilityTest {
     @Mock
     private lateinit var mockWindowDecoration: WindowDecoration<*>
+
     @Mock
     private lateinit var taskToken: WindowContainerToken
+
     @Mock
     private lateinit var taskBinder: IBinder
+
     @Mock
     private lateinit var mockDisplayController: DisplayController
+
     @Mock
     private lateinit var mockDisplayLayout: DisplayLayout
+
     @Mock
     private lateinit var mockDisplay: Display
+
     @Mock
     private lateinit var mockContext: Context
+
     @Mock
     private lateinit var mockResources: Resources
 
+    @JvmField
+    @Rule
+    val setFlagsRule = SetFlagsRule()
+
     @Before
     fun setup() {
         MockitoAnnotations.initMocks(this)
@@ -323,6 +337,49 @@
         assertThat(repositionTaskBounds.bottom).isEqualTo(STARTING_BOUNDS.bottom - 50)
     }
 
+    @Test
+    @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_SIZE_CONSTRAINTS)
+    fun testChangeBounds_windowSizeExceedsStableBounds_shouldBeAllowedToChangeBounds() {
+        val startingPoint =
+            PointF(OFF_CENTER_STARTING_BOUNDS.right.toFloat(),
+                OFF_CENTER_STARTING_BOUNDS.bottom.toFloat())
+        val repositionTaskBounds = Rect(OFF_CENTER_STARTING_BOUNDS)
+        // Increase height and width by STABLE_BOUNDS. Subtract by 5px so that it doesn't reach
+        // the disallowed drag area.
+        val offset = 5
+        val newX = STABLE_BOUNDS.right.toFloat() - offset
+        val newY = STABLE_BOUNDS.bottom.toFloat() - offset
+        val delta = DragPositioningCallbackUtility.calculateDelta(newX, newY, startingPoint)
+
+        DragPositioningCallbackUtility.changeBounds(CTRL_TYPE_RIGHT or CTRL_TYPE_BOTTOM,
+            repositionTaskBounds, OFF_CENTER_STARTING_BOUNDS, STABLE_BOUNDS, delta,
+            mockDisplayController, mockWindowDecoration)
+        assertThat(repositionTaskBounds.width()).isGreaterThan(STABLE_BOUNDS.right)
+        assertThat(repositionTaskBounds.height()).isGreaterThan(STABLE_BOUNDS.bottom)
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_SIZE_CONSTRAINTS)
+    fun testChangeBoundsInDesktopMode_windowSizeExceedsStableBounds_shouldBeLimitedToDisplaySize() {
+        whenever(DesktopModeStatus.canEnterDesktopMode(mockContext)).thenReturn(true)
+        val startingPoint =
+            PointF(OFF_CENTER_STARTING_BOUNDS.right.toFloat(),
+                OFF_CENTER_STARTING_BOUNDS.bottom.toFloat())
+        val repositionTaskBounds = Rect(OFF_CENTER_STARTING_BOUNDS)
+        // Increase height and width by STABLE_BOUNDS. Subtract by 5px so that it doesn't reach
+        // the disallowed drag area.
+        val offset = 5
+        val newX = STABLE_BOUNDS.right.toFloat() - offset
+        val newY = STABLE_BOUNDS.bottom.toFloat() - offset
+        val delta = DragPositioningCallbackUtility.calculateDelta(newX, newY, startingPoint)
+
+        DragPositioningCallbackUtility.changeBounds(CTRL_TYPE_RIGHT or CTRL_TYPE_BOTTOM,
+            repositionTaskBounds, OFF_CENTER_STARTING_BOUNDS, STABLE_BOUNDS, delta,
+            mockDisplayController, mockWindowDecoration)
+        assertThat(repositionTaskBounds.width()).isLessThan(STABLE_BOUNDS.right)
+        assertThat(repositionTaskBounds.height()).isLessThan(STABLE_BOUNDS.bottom)
+    }
+
     private fun initializeTaskInfo(taskMinWidth: Int = MIN_WIDTH, taskMinHeight: Int = MIN_HEIGHT) {
         mockWindowDecoration.mTaskInfo = ActivityManager.RunningTaskInfo().apply {
             taskId = TASK_ID
@@ -347,6 +404,7 @@
         private const val NAVBAR_HEIGHT = 50
         private val DISPLAY_BOUNDS = Rect(0, 0, 2400, 1600)
         private val STARTING_BOUNDS = Rect(0, 0, 100, 100)
+        private val OFF_CENTER_STARTING_BOUNDS = Rect(-100, -100, 10, 10)
         private val DISALLOWED_RESIZE_AREA = Rect(
             DISPLAY_BOUNDS.left,
             DISPLAY_BOUNDS.bottom - NAVBAR_HEIGHT,
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 48ac1e5..901ca90 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,8 @@
 
 import android.app.ActivityManager
 import android.app.WindowConfiguration
+import android.content.Context
+import android.content.res.Resources
 import android.graphics.Point
 import android.graphics.Rect
 import android.os.IBinder
@@ -98,6 +100,10 @@
     private lateinit var mockFinishCallback: TransitionFinishCallback
     @Mock
     private lateinit var mockTransitions: Transitions
+    @Mock
+    private lateinit var mockContext: Context
+    @Mock
+    private lateinit var mockResources: Resources
 
     private lateinit var taskPositioner: VeiledResizeTaskPositioner
 
@@ -105,6 +111,9 @@
     fun setUp() {
         MockitoAnnotations.initMocks(this)
 
+        mockDesktopWindowDecoration.mDisplay = mockDisplay
+        mockDesktopWindowDecoration.mDecorWindowContext = mockContext
+        whenever(mockContext.getResources()).thenReturn(mockResources)
         whenever(taskToken.asBinder()).thenReturn(taskBinder)
         whenever(mockDisplayController.getDisplayLayout(DISPLAY_ID)).thenReturn(mockDisplayLayout)
         whenever(mockDisplayLayout.densityDpi()).thenReturn(DENSITY_DPI)
diff --git a/libs/nativehelper_jvm/Android.bp b/libs/nativehelper_jvm/Android.bp
new file mode 100644
index 0000000..b5b7028
--- /dev/null
+++ b/libs/nativehelper_jvm/Android.bp
@@ -0,0 +1,19 @@
+package {
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+cc_library_host_static {
+    name: "libnativehelper_jvm",
+    srcs: [
+        "JNIPlatformHelp.c",
+        "JniConstants.c",
+        "file_descriptor_jni.c",
+    ],
+    whole_static_libs: ["libnativehelper_any_vm"],
+    export_static_lib_headers: ["libnativehelper_any_vm"],
+    target: {
+        windows: {
+            enabled: true,
+        },
+    },
+}
diff --git a/libs/nativehelper_jvm/JNIPlatformHelp.c b/libs/nativehelper_jvm/JNIPlatformHelp.c
new file mode 100644
index 0000000..9df31a8
--- /dev/null
+++ b/libs/nativehelper_jvm/JNIPlatformHelp.c
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+#include <nativehelper/JNIPlatformHelp.h>
+
+#include <stddef.h>
+
+#include "JniConstants.h"
+
+static int GetBufferPosition(JNIEnv* env, jobject nioBuffer) {
+    return(*env)->GetIntField(env, nioBuffer, JniConstants_NioBuffer_position(env));
+}
+
+static int GetBufferLimit(JNIEnv* env, jobject nioBuffer) {
+    return(*env)->GetIntField(env, nioBuffer, JniConstants_NioBuffer_limit(env));
+}
+
+static int GetBufferElementSizeShift(JNIEnv* env, jobject nioBuffer) {
+    jclass byteBufferClass = JniConstants_NioByteBufferClass(env);
+    jclass shortBufferClass = JniConstants_NioShortBufferClass(env);
+    jclass charBufferClass = JniConstants_NioCharBufferClass(env);
+    jclass intBufferClass = JniConstants_NioIntBufferClass(env);
+    jclass floatBufferClass = JniConstants_NioFloatBufferClass(env);
+    jclass longBufferClass = JniConstants_NioLongBufferClass(env);
+    jclass doubleBufferClass = JniConstants_NioDoubleBufferClass(env);
+
+    // Check the type of the Buffer
+    if ((*env)->IsInstanceOf(env, nioBuffer, byteBufferClass)) {
+        return 0;
+    } else if ((*env)->IsInstanceOf(env, nioBuffer, shortBufferClass) ||
+               (*env)->IsInstanceOf(env, nioBuffer, charBufferClass)) {
+        return 1;
+    } else if ((*env)->IsInstanceOf(env, nioBuffer, intBufferClass) ||
+               (*env)->IsInstanceOf(env, nioBuffer, floatBufferClass)) {
+        return 2;
+    } else if ((*env)->IsInstanceOf(env, nioBuffer, longBufferClass) ||
+               (*env)->IsInstanceOf(env, nioBuffer, doubleBufferClass)) {
+        return 3;
+    }
+    return 0;
+}
+
+jarray jniGetNioBufferBaseArray(JNIEnv* env, jobject nioBuffer) {
+    jmethodID hasArrayMethod = JniConstants_NioBuffer_hasArray(env);
+    jboolean hasArray = (*env)->CallBooleanMethod(env, nioBuffer, hasArrayMethod);
+    if (hasArray) {
+        jmethodID arrayMethod = JniConstants_NioBuffer_array(env);
+        return (*env)->CallObjectMethod(env, nioBuffer, arrayMethod);
+    } else {
+        return NULL;
+    }
+}
+
+int jniGetNioBufferBaseArrayOffset(JNIEnv* env, jobject nioBuffer) {
+    jmethodID hasArrayMethod = JniConstants_NioBuffer_hasArray(env);
+    jboolean hasArray = (*env)->CallBooleanMethod(env, nioBuffer, hasArrayMethod);
+    if (hasArray) {
+        jmethodID arrayOffsetMethod = JniConstants_NioBuffer_arrayOffset(env);
+        jint arrayOffset = (*env)->CallIntMethod(env, nioBuffer, arrayOffsetMethod);
+        const int position = GetBufferPosition(env, nioBuffer);
+        jint elementSizeShift = GetBufferElementSizeShift(env, nioBuffer);
+        return (arrayOffset + position) << elementSizeShift;
+    } else {
+        return 0;
+    }
+}
+
+jlong jniGetNioBufferPointer(JNIEnv* env, jobject nioBuffer) {
+    // in Java 11, the address field of a HeapByteBuffer contains a non-zero value despite
+    // HeapByteBuffer being a non-direct buffer. In that case, this should still return 0.
+    jmethodID isDirectMethod = JniConstants_NioBuffer_isDirect(env);
+    jboolean isDirect = (*env)->CallBooleanMethod(env, nioBuffer, isDirectMethod);
+    if (isDirect == JNI_FALSE) {
+        return 0L;
+    }
+    jlong baseAddress = (*env)->GetLongField(env, nioBuffer, JniConstants_NioBuffer_address(env));
+    if (baseAddress != 0) {
+        const int position = GetBufferPosition(env, nioBuffer);
+        const int shift = GetBufferElementSizeShift(env, nioBuffer);
+        baseAddress += position << shift;
+    }
+    return baseAddress;
+}
+
+jlong jniGetNioBufferFields(JNIEnv* env, jobject nioBuffer,
+                            jint* position, jint* limit, jint* elementSizeShift) {
+    *position = GetBufferPosition(env, nioBuffer);
+    *limit = GetBufferLimit(env, nioBuffer);
+    *elementSizeShift = GetBufferElementSizeShift(env, nioBuffer);
+    return (*env)->GetLongField(env, nioBuffer, JniConstants_NioBuffer_address(env));
+}
diff --git a/libs/nativehelper_jvm/JniConstants.c b/libs/nativehelper_jvm/JniConstants.c
new file mode 100644
index 0000000..ca58f61
--- /dev/null
+++ b/libs/nativehelper_jvm/JniConstants.c
@@ -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.
+ */
+
+#include "JniConstants.h"
+
+#include <pthread.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <string.h>
+
+#define LOG_TAG "JniConstants"
+#include <log/log.h>
+
+// jclass constants list:
+//   <class, signature, androidOnly>
+#define JCLASS_CONSTANTS_LIST(V)                                            \
+  V(FileDescriptor, "java/io/FileDescriptor", false)                        \
+  V(NioBuffer, "java/nio/Buffer", false)                                    \
+  V(NioByteBuffer, "java/nio/ByteBuffer", false)                            \
+  V(NioShortBuffer, "java/nio/ShortBuffer", false)                          \
+  V(NioCharBuffer, "java/nio/CharBuffer", false)                            \
+  V(NioIntBuffer, "java/nio/IntBuffer", false)                              \
+  V(NioFloatBuffer, "java/nio/FloatBuffer", false)                          \
+  V(NioLongBuffer, "java/nio/LongBuffer", false)                            \
+  V(NioDoubleBuffer, "java/nio/DoubleBuffer", false)
+
+// jmethodID's of public methods constants list:
+//   <Class, method, method-string, signature, is_static>
+#define JMETHODID_CONSTANTS_LIST(V)                                                         \
+  V(FileDescriptor, init, "<init>", "()V", false)                                           \
+  V(NioBuffer, array, "array", "()Ljava/lang/Object;", false)                               \
+  V(NioBuffer, hasArray, "hasArray", "()Z", false)                                          \
+  V(NioBuffer, isDirect, "isDirect", "()Z", false)                                          \
+  V(NioBuffer, arrayOffset, "arrayOffset", "()I", false)
+
+// jfieldID constants list:
+//   <Class, field, signature, is_static>
+#define JFIELDID_CONSTANTS_LIST(V)                                          \
+  V(FileDescriptor, fd, "I", false)                                         \
+  V(NioBuffer, address, "J", false)                                         \
+  V(NioBuffer, limit, "I", false)                                           \
+  V(NioBuffer, position, "I", false)
+
+#define CLASS_NAME(cls)             g_ ## cls
+#define METHOD_NAME(cls, method)    g_ ## cls ## _ ## method
+#define FIELD_NAME(cls, field)      g_ ## cls ## _ ## field
+
+//
+// Declare storage for cached classes, methods and fields.
+//
+
+#define JCLASS_DECLARE_STORAGE(cls, ...)                                    \
+  static jclass CLASS_NAME(cls) = NULL;
+JCLASS_CONSTANTS_LIST(JCLASS_DECLARE_STORAGE)
+#undef JCLASS_DECLARE_STORAGE
+
+#define JMETHODID_DECLARE_STORAGE(cls, method, ...)                         \
+  static jmethodID METHOD_NAME(cls, method) = NULL;
+JMETHODID_CONSTANTS_LIST(JMETHODID_DECLARE_STORAGE)
+#undef JMETHODID_DECLARE_STORAGE
+
+#define JFIELDID_DECLARE_STORAGE(cls, field, ...)                           \
+  static jfieldID FIELD_NAME(cls, field) = NULL;
+JFIELDID_CONSTANTS_LIST(JFIELDID_DECLARE_STORAGE)
+#undef JFIELDID_DECLARE_STORAGE
+
+//
+// Helper methods
+//
+
+static jclass FindClass(JNIEnv* env, const char* signature, bool androidOnly) {
+    jclass cls = (*env)->FindClass(env, signature);
+    if (cls == NULL) {
+        LOG_ALWAYS_FATAL_IF(!androidOnly, "Class not found: %s", signature);
+        return NULL;
+    }
+    return (*env)->NewGlobalRef(env, cls);
+}
+
+static jmethodID FindMethod(JNIEnv* env, jclass cls,
+                            const char* name, const char* signature, bool isStatic) {
+    jmethodID method;
+    if (isStatic) {
+        method = (*env)->GetStaticMethodID(env, cls, name, signature);
+    } else {
+        method = (*env)->GetMethodID(env, cls, name, signature);
+    }
+    LOG_ALWAYS_FATAL_IF(method == NULL, "Method not found: %s:%s", name, signature);
+    return method;
+}
+
+static jfieldID FindField(JNIEnv* env, jclass cls,
+                          const char* name, const char* signature, bool isStatic) {
+    jfieldID field;
+    if (isStatic) {
+        field = (*env)->GetStaticFieldID(env, cls, name, signature);
+    } else {
+        field = (*env)->GetFieldID(env, cls, name, signature);
+    }
+    LOG_ALWAYS_FATAL_IF(field == NULL, "Field not found: %s:%s", name, signature);
+    return field;
+}
+
+static pthread_once_t g_initialized = PTHREAD_ONCE_INIT;
+static JNIEnv* g_init_env;
+
+static void InitializeConstants() {
+    // Initialize cached classes.
+#define JCLASS_INITIALIZE(cls, signature, androidOnly)                      \
+    CLASS_NAME(cls) = FindClass(g_init_env, signature, androidOnly);
+    JCLASS_CONSTANTS_LIST(JCLASS_INITIALIZE)
+#undef JCLASS_INITIALIZE
+
+    // Initialize cached methods.
+#define JMETHODID_INITIALIZE(cls, method, name, signature, isStatic)        \
+    METHOD_NAME(cls, method) =                                              \
+        FindMethod(g_init_env, CLASS_NAME(cls), name, signature, isStatic);
+    JMETHODID_CONSTANTS_LIST(JMETHODID_INITIALIZE)
+#undef JMETHODID_INITIALIZE
+
+    // Initialize cached fields.
+#define JFIELDID_INITIALIZE(cls, field, signature, isStatic)                \
+    FIELD_NAME(cls, field) =                                                \
+        FindField(g_init_env, CLASS_NAME(cls), #field, signature, isStatic);
+    JFIELDID_CONSTANTS_LIST(JFIELDID_INITIALIZE)
+#undef JFIELDID_INITIALIZE
+}
+
+void EnsureInitialized(JNIEnv* env) {
+    // This method has to be called in every cache accesses because library can be built
+    // 2 different ways and existing usage for compat version doesn't have a good hook for
+    // initialization and is widely used.
+    g_init_env = env;
+    pthread_once(&g_initialized, InitializeConstants);
+}
+
+// API exported by libnativehelper_api.h.
+
+void jniUninitializeConstants() {
+    // Uninitialize cached classes, methods and fields.
+    //
+    // NB we assume the runtime is stopped at this point and do not delete global
+    // references.
+#define JCLASS_INVALIDATE(cls, ...) CLASS_NAME(cls) = NULL;
+    JCLASS_CONSTANTS_LIST(JCLASS_INVALIDATE);
+#undef JCLASS_INVALIDATE
+
+#define JMETHODID_INVALIDATE(cls, method, ...) METHOD_NAME(cls, method) = NULL;
+    JMETHODID_CONSTANTS_LIST(JMETHODID_INVALIDATE);
+#undef JMETHODID_INVALIDATE
+
+#define JFIELDID_INVALIDATE(cls, field, ...) FIELD_NAME(cls, field) = NULL;
+    JFIELDID_CONSTANTS_LIST(JFIELDID_INVALIDATE);
+#undef JFIELDID_INVALIDATE
+
+    // If jniConstantsUninitialize is called, runtime has shutdown. Reset
+    // state as some tests re-start the runtime.
+    pthread_once_t o = PTHREAD_ONCE_INIT;
+    memcpy(&g_initialized, &o, sizeof(o));
+}
+
+//
+// Accessors
+//
+
+#define JCLASS_ACCESSOR_IMPL(cls, ...)                                      \
+jclass JniConstants_ ## cls ## Class(JNIEnv* env) {                         \
+    EnsureInitialized(env);                                                 \
+    return CLASS_NAME(cls);                                                 \
+}
+JCLASS_CONSTANTS_LIST(JCLASS_ACCESSOR_IMPL)
+#undef JCLASS_ACCESSOR_IMPL
+
+#define JMETHODID_ACCESSOR_IMPL(cls, method, ...)                           \
+jmethodID JniConstants_ ## cls ## _ ## method(JNIEnv* env) {                \
+    EnsureInitialized(env);                                                 \
+    return METHOD_NAME(cls, method);                                        \
+}
+JMETHODID_CONSTANTS_LIST(JMETHODID_ACCESSOR_IMPL)
+
+#define JFIELDID_ACCESSOR_IMPL(cls, field, ...)                             \
+jfieldID JniConstants_ ## cls ## _ ## field(JNIEnv* env) {                  \
+    EnsureInitialized(env);                                                 \
+    return FIELD_NAME(cls, field);                                          \
+}
+JFIELDID_CONSTANTS_LIST(JFIELDID_ACCESSOR_IMPL)
diff --git a/libs/nativehelper_jvm/JniConstants.h b/libs/nativehelper_jvm/JniConstants.h
new file mode 100644
index 0000000..e7a266d
--- /dev/null
+++ b/libs/nativehelper_jvm/JniConstants.h
@@ -0,0 +1,63 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <sys/cdefs.h>
+
+#include <jni.h>
+
+__BEGIN_DECLS
+
+//
+// Classes in constants cache.
+//
+// NB The implementations of these methods are generated by the JCLASS_ACCESSOR_IMPL macro in
+// JniConstants.c.
+//
+jclass JniConstants_FileDescriptorClass(JNIEnv* env);
+jclass JniConstants_NioByteBufferClass(JNIEnv* env);
+jclass JniConstants_NioShortBufferClass(JNIEnv* env);
+jclass JniConstants_NioCharBufferClass(JNIEnv* env);
+jclass JniConstants_NioIntBufferClass(JNIEnv* env);
+jclass JniConstants_NioFloatBufferClass(JNIEnv* env);
+jclass JniConstants_NioLongBufferClass(JNIEnv* env);
+jclass JniConstants_NioDoubleBufferClass(JNIEnv* env);
+
+//
+// Methods in the constants cache.
+//
+// NB The implementations of these methods are generated by the JMETHODID_ACCESSOR_IMPL macro in
+// JniConstants.c.
+//
+jmethodID JniConstants_FileDescriptor_init(JNIEnv* env);
+jmethodID JniConstants_NioBuffer_array(JNIEnv* env);
+jmethodID JniConstants_NioBuffer_arrayOffset(JNIEnv* env);
+jmethodID JniConstants_NioBuffer_hasArray(JNIEnv* env);
+jmethodID JniConstants_NioBuffer_isDirect(JNIEnv* env);
+
+//
+// Fields in the constants cache.
+//
+// NB The implementations of these methods are generated by the JFIELDID_ACCESSOR_IMPL macro in
+// JniConstants.c.
+//
+jfieldID JniConstants_FileDescriptor_fd(JNIEnv* env);
+jfieldID JniConstants_NioBuffer_address(JNIEnv* env);
+jfieldID JniConstants_NioBuffer_limit(JNIEnv* env);
+jfieldID JniConstants_NioBuffer_position(JNIEnv* env);
+
+__END_DECLS
diff --git a/libs/nativehelper_jvm/OWNERS b/libs/nativehelper_jvm/OWNERS
new file mode 100644
index 0000000..5d55f6e
--- /dev/null
+++ b/libs/nativehelper_jvm/OWNERS
@@ -0,0 +1,7 @@
+# Bug component: 326772
+
+include /libs/hwui/OWNERS
+include platform/libnativehelper:/OWNERS
+
+diegoperez@google.com
+jgaillard@google.com
diff --git a/libs/nativehelper_jvm/README b/libs/nativehelper_jvm/README
new file mode 100644
index 0000000..755c422
--- /dev/null
+++ b/libs/nativehelper_jvm/README
@@ -0,0 +1,2 @@
+libnativehelper_jvm is a JVM-compatible version of libnativehelper.
+It should be used instead of libnativehelper whenever a host library is meant to run on a JVM.
\ No newline at end of file
diff --git a/libs/nativehelper_jvm/file_descriptor_jni.c b/libs/nativehelper_jvm/file_descriptor_jni.c
new file mode 100644
index 0000000..36880cd
--- /dev/null
+++ b/libs/nativehelper_jvm/file_descriptor_jni.c
@@ -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.
+ */
+
+#include <android/file_descriptor_jni.h>
+
+#include <stddef.h>
+
+#define LOG_TAG "file_descriptor_jni"
+#include <log/log.h>
+
+#include "JniConstants.h"
+
+static void EnsureArgumentIsFileDescriptor(JNIEnv* env, jobject instance) {
+    LOG_ALWAYS_FATAL_IF(instance == NULL, "FileDescriptor is NULL");
+    jclass jifd = JniConstants_FileDescriptorClass(env);
+    LOG_ALWAYS_FATAL_IF(!(*env)->IsInstanceOf(env, instance, jifd),
+                         "Argument is not a FileDescriptor");
+}
+
+JNIEXPORT _Nullable jobject AFileDescriptor_create(JNIEnv* env) {
+    return (*env)->NewObject(env,
+                             JniConstants_FileDescriptorClass(env),
+                             JniConstants_FileDescriptor_init(env));
+}
+
+JNIEXPORT int AFileDescriptor_getFd(JNIEnv* env, jobject fileDescriptor) {
+    EnsureArgumentIsFileDescriptor(env, fileDescriptor);
+    return (*env)->GetIntField(env, fileDescriptor, JniConstants_FileDescriptor_fd(env));
+}
+
+JNIEXPORT void AFileDescriptor_setFd(JNIEnv* env, jobject fileDescriptor, int fd) {
+    EnsureArgumentIsFileDescriptor(env, fileDescriptor);
+    (*env)->SetIntField(env, fileDescriptor, JniConstants_FileDescriptor_fd(env), fd);
+}
diff --git a/packages/SettingsLib/aconfig/settingslib.aconfig b/packages/SettingsLib/aconfig/settingslib.aconfig
index a158756..4ac3e67 100644
--- a/packages/SettingsLib/aconfig/settingslib.aconfig
+++ b/packages/SettingsLib/aconfig/settingslib.aconfig
@@ -89,3 +89,13 @@
         purpose: PURPOSE_BUGFIX
     }
 }
+
+flag {
+    name: "volume_panel_broadcast_fix"
+    namespace: "systemui"
+    description: "Make the volume panel's repository listen for the new ACTION_CONSOLIDATED_NOTIFICATION_POLICY_CHANGED broadcast instead of ACTION_NOTIFICATION_POLICY_CHANGED"
+    bug: "347707024"
+    metadata {
+        purpose: PURPOSE_BUGFIX
+    }
+}
diff --git a/packages/SettingsLib/res/drawable/ic_do_not_disturb_on_24dp.xml b/packages/SettingsLib/res/drawable/ic_do_not_disturb_on_24dp.xml
new file mode 100644
index 0000000..06c0d8c
--- /dev/null
+++ b/packages/SettingsLib/res/drawable/ic_do_not_disturb_on_24dp.xml
@@ -0,0 +1,28 @@
+<!--
+    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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?android:attr/colorControlNormal">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10c5.52,0 10,-4.48 10,-10C22,6.48 17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8c0,-4.41 3.59,-8 8,-8c4.41,0 8,3.59 8,8C20,16.41 16.41,20 12,20z"/>
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M7,11h10v2h-10z"/>
+</vector>
diff --git a/packages/SettingsLib/res/values/strings.xml b/packages/SettingsLib/res/values/strings.xml
index d373201..27c386e 100644
--- a/packages/SettingsLib/res/values/strings.xml
+++ b/packages/SettingsLib/res/values/strings.xml
@@ -1361,6 +1361,9 @@
     <!-- Keywords for setting screen for controlling apps that can schedule alarms [CHAR LIMIT=100] -->
     <string name="keywords_alarms_and_reminders">schedule, alarm, reminder, clock</string>
 
+    <!-- Sound: Title for the Do not Disturb option and associated settings page. [CHAR LIMIT=50]-->
+    <string name="zen_mode_settings_title">Do Not Disturb</string>
+
     <!--  Do not disturb: Label for button in enable zen dialog that will turn on zen mode. [CHAR LIMIT=30] -->
     <string name="zen_mode_enable_dialog_turn_on">Turn on</string>
     <!-- Do not disturb: Title for the Do not Disturb dialog to turn on Do not disturb. [CHAR LIMIT=50]-->
@@ -1387,6 +1390,9 @@
     <!-- Do not disturb: Duration option to always have DND on until it is manually turned off [CHAR LIMIT=60] -->
     <string name="zen_mode_forever">Until you turn off</string>
 
+    <!-- [CHAR LIMIT=50] Zen mode settings: placeholder for a Contact name when the name is empty -->
+    <string name="zen_mode_starred_contacts_empty_name">(No name)</string>
+
     <!-- time label for event have that happened very recently [CHAR LIMIT=60] -->
     <string name="time_unit_just_now">Just now</string>
 
diff --git a/packages/SettingsLib/src/com/android/settingslib/RestrictedLockUtilsInternal.java b/packages/SettingsLib/src/com/android/settingslib/RestrictedLockUtilsInternal.java
index 1597a4b..fc163ce 100644
--- a/packages/SettingsLib/src/com/android/settingslib/RestrictedLockUtilsInternal.java
+++ b/packages/SettingsLib/src/com/android/settingslib/RestrictedLockUtilsInternal.java
@@ -430,13 +430,21 @@
         return null;
     }
 
+    /**
+     * Retrieves the user ID of a managed profile associated with a specific user.
+     *
+     * <p>This method iterates over the users in the profile group associated with the given user ID
+     * and returns the ID of the user that is identified as a managed profile user.
+     * If no managed profile is found, it returns {@link UserHandle#USER_NULL}.
+     *
+     * @param context The context used to obtain the {@link UserManager} system service.
+     * @param userId  The ID of the user for whom to find the managed profile.
+     * @return The user ID of the managed profile, or {@link UserHandle#USER_NULL} if none exists.
+     */
     private static int getManagedProfileId(Context context, int userId) {
         UserManager um = (UserManager) context.getSystemService(Context.USER_SERVICE);
         List<UserInfo> userProfiles = um.getProfiles(userId);
         for (UserInfo uInfo : userProfiles) {
-            if (uInfo.id == userId) {
-                continue;
-            }
             if (uInfo.isManagedProfile()) {
                 return uInfo.id;
             }
@@ -821,11 +829,11 @@
         }
         EnforcedAdmin admin =
                 RestrictedLockUtils.getProfileOrDeviceOwner(
-                        context, UserHandle.of(UserHandle.USER_SYSTEM));
+                        context, context.getUser());
         if (admin != null) {
             return admin;
         }
-        int profileId = getManagedProfileId(context, UserHandle.USER_SYSTEM);
+        int profileId = getManagedProfileId(context, context.getUserId());
         return RestrictedLockUtils.getProfileOrDeviceOwner(context, UserHandle.of(profileId));
     }
 
@@ -848,7 +856,7 @@
         if (admin != null) {
             return admin;
         }
-        int profileId = getManagedProfileId(context, UserHandle.USER_SYSTEM);
+        int profileId = getManagedProfileId(context, context.getUserId());
         return RestrictedLockUtils.getProfileOrDeviceOwner(context, UserHandle.of(profileId));
     }
 
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenIconLoader.java b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenIconLoader.java
new file mode 100644
index 0000000..3f19830
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenIconLoader.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.notification.modes;
+
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.Nullable;
+import android.app.AutomaticZenRule;
+import android.content.Context;
+import android.graphics.drawable.AdaptiveIconDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.InsetDrawable;
+import android.service.notification.SystemZenRules;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.LruCache;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.appcompat.content.res.AppCompatResources;
+
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class ZenIconLoader {
+
+    private static final String TAG = "ZenIconLoader";
+
+    private static final Drawable MISSING = new ColorDrawable();
+
+    @Nullable // Until first usage
+    private static ZenIconLoader sInstance;
+
+    private final LruCache<String, Drawable> mCache;
+    private final ListeningExecutorService mBackgroundExecutor;
+
+    public static ZenIconLoader getInstance() {
+        if (sInstance == null) {
+            sInstance = new ZenIconLoader();
+        }
+        return sInstance;
+    }
+
+    private ZenIconLoader() {
+        this(Executors.newFixedThreadPool(4));
+    }
+
+    @VisibleForTesting
+    ZenIconLoader(ExecutorService backgroundExecutor) {
+        mCache = new LruCache<>(50);
+        mBackgroundExecutor =
+                MoreExecutors.listeningDecorator(backgroundExecutor);
+    }
+
+    @NonNull
+    ListenableFuture<Drawable> getIcon(Context context, @NonNull AutomaticZenRule rule) {
+        if (rule.getIconResId() == 0) {
+            return Futures.immediateFuture(getFallbackIcon(context, rule.getType()));
+        }
+
+        return FluentFuture.from(loadIcon(context, rule.getPackageName(), rule.getIconResId()))
+                .transform(icon ->
+                                icon != null ? icon : getFallbackIcon(context, rule.getType()),
+                        MoreExecutors.directExecutor());
+    }
+
+    @NonNull
+    private ListenableFuture</* @Nullable */ Drawable> loadIcon(Context context, String pkg,
+            int iconResId) {
+        String cacheKey = pkg + ":" + iconResId;
+        synchronized (mCache) {
+            Drawable cachedValue = mCache.get(cacheKey);
+            if (cachedValue != null) {
+                return immediateFuture(cachedValue != MISSING ? cachedValue : null);
+            }
+        }
+
+        return FluentFuture.from(mBackgroundExecutor.submit(() -> {
+            if (TextUtils.isEmpty(pkg) || SystemZenRules.PACKAGE_ANDROID.equals(pkg)) {
+                return context.getDrawable(iconResId);
+            } else {
+                Context appContext = context.createPackageContext(pkg, 0);
+                Drawable appDrawable = AppCompatResources.getDrawable(appContext, iconResId);
+                return getMonochromeIconIfPresent(appDrawable);
+            }
+        })).catching(Exception.class, ex -> {
+            // If we cannot resolve the icon, then store MISSING in the cache below, so
+            // we don't try again.
+            Log.e(TAG, "Error while loading icon " + cacheKey, ex);
+            return null;
+        }, MoreExecutors.directExecutor()).transform(drawable -> {
+            synchronized (mCache) {
+                mCache.put(cacheKey, drawable != null ? drawable : MISSING);
+            }
+            return drawable;
+        }, MoreExecutors.directExecutor());
+    }
+
+    private static Drawable getFallbackIcon(Context context, int ruleType) {
+        int iconResIdFromType = switch (ruleType) {
+            case AutomaticZenRule.TYPE_UNKNOWN ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_unknown;
+            case AutomaticZenRule.TYPE_OTHER ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_other;
+            case AutomaticZenRule.TYPE_SCHEDULE_TIME ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_schedule_time;
+            case AutomaticZenRule.TYPE_SCHEDULE_CALENDAR ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_schedule_calendar;
+            case AutomaticZenRule.TYPE_BEDTIME ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_bedtime;
+            case AutomaticZenRule.TYPE_DRIVING ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_driving;
+            case AutomaticZenRule.TYPE_IMMERSIVE ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_immersive;
+            case AutomaticZenRule.TYPE_THEATER ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_theater;
+            case AutomaticZenRule.TYPE_MANAGED ->
+                    com.android.internal.R.drawable.ic_zen_mode_type_managed;
+            default -> com.android.internal.R.drawable.ic_zen_mode_type_unknown;
+        };
+        return requireNonNull(context.getDrawable(iconResIdFromType));
+    }
+
+    private static Drawable getMonochromeIconIfPresent(Drawable icon) {
+        // For created rules, the app should've provided a monochrome Drawable. However, implicit
+        // rules have the app's icon, which is not -- but might have a monochrome layer. Thus
+        // we choose it, if present.
+        if (icon instanceof AdaptiveIconDrawable adaptiveIcon) {
+            if (adaptiveIcon.getMonochrome() != null) {
+                // Wrap with negative inset => scale icon (inspired from BaseIconFactory)
+                return new InsetDrawable(adaptiveIcon.getMonochrome(),
+                        -2.0f * AdaptiveIconDrawable.getExtraInsetFraction());
+            }
+        }
+        return icon;
+    }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java
new file mode 100644
index 0000000..33d39f0
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java
@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.notification.modes;
+
+import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL;
+import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
+import static android.service.notification.SystemZenRules.getTriggerDescriptionForScheduleEvent;
+import static android.service.notification.SystemZenRules.getTriggerDescriptionForScheduleTime;
+import static android.service.notification.ZenModeConfig.tryParseEventConditionId;
+import static android.service.notification.ZenModeConfig.tryParseScheduleConditionId;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.SuppressLint;
+import android.app.AutomaticZenRule;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.service.notification.SystemZenRules;
+import android.service.notification.ZenDeviceEffects;
+import android.service.notification.ZenModeConfig;
+import android.service.notification.ZenPolicy;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settingslib.R;
+
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.Objects;
+
+/**
+ * Represents either an {@link AutomaticZenRule} or the manual DND rule in a unified way.
+ *
+ * <p>It also adapts other rule features that we don't want to expose in the UI, such as
+ * interruption filters other than {@code PRIORITY}, rules without specific icons, etc.
+ */
+public class ZenMode {
+
+    private static final String TAG = "ZenMode";
+
+    static final String MANUAL_DND_MODE_ID = "manual_dnd";
+
+    // Must match com.android.server.notification.ZenModeHelper#applyCustomPolicy.
+    private static final ZenPolicy POLICY_INTERRUPTION_FILTER_ALARMS =
+            new ZenPolicy.Builder()
+                    .disallowAllSounds()
+                    .allowAlarms(true)
+                    .allowMedia(true)
+                    .allowPriorityChannels(false)
+                    .build();
+
+    // Must match com.android.server.notification.ZenModeHelper#applyCustomPolicy.
+    private static final ZenPolicy POLICY_INTERRUPTION_FILTER_NONE =
+            new ZenPolicy.Builder()
+                    .disallowAllSounds()
+                    .hideAllVisualEffects()
+                    .allowPriorityChannels(false)
+                    .build();
+
+    public enum Status {
+        ENABLED,
+        ENABLED_AND_ACTIVE,
+        DISABLED_BY_USER,
+        DISABLED_BY_OTHER
+    }
+
+    private final String mId;
+    private final AutomaticZenRule mRule;
+    private final Status mStatus;
+    private final boolean mIsManualDnd;
+
+    /**
+     * Initializes a {@link ZenMode}, mainly based on the information from the
+     * {@link AutomaticZenRule}.
+     *
+     * <p>Some pieces which are not part of the public API (such as whether the mode is currently
+     * active, or the reason it was disabled) are read from the {@link ZenModeConfig.ZenRule} --
+     * see {@link #computeStatus}.
+     */
+    public ZenMode(String id, @NonNull AutomaticZenRule rule,
+            @NonNull ZenModeConfig.ZenRule zenRuleExtraData) {
+        this(id, rule, computeStatus(zenRuleExtraData), false);
+    }
+
+    private static Status computeStatus(@NonNull ZenModeConfig.ZenRule zenRuleExtraData) {
+        if (zenRuleExtraData.enabled) {
+            if (zenRuleExtraData.isAutomaticActive()) {
+                return Status.ENABLED_AND_ACTIVE;
+            } else {
+                return Status.ENABLED;
+            }
+        } else {
+            if (zenRuleExtraData.disabledOrigin == ZenModeConfig.UPDATE_ORIGIN_USER) {
+                return Status.DISABLED_BY_USER;
+            } else {
+                return Status.DISABLED_BY_OTHER; // by APP, SYSTEM, UNKNOWN.
+            }
+        }
+    }
+
+    public static ZenMode manualDndMode(AutomaticZenRule manualRule, boolean isActive) {
+        return new ZenMode(MANUAL_DND_MODE_ID, manualRule,
+                isActive ? Status.ENABLED_AND_ACTIVE : Status.ENABLED, true);
+    }
+
+    private ZenMode(String id, @NonNull AutomaticZenRule rule, Status status, boolean isManualDnd) {
+        mId = id;
+        mRule = rule;
+        mStatus = status;
+        mIsManualDnd = isManualDnd;
+    }
+
+    @NonNull
+    public String getId() {
+        return mId;
+    }
+
+    @NonNull
+    public AutomaticZenRule getRule() {
+        return mRule;
+    }
+
+    @NonNull
+    public String getName() {
+        return Strings.nullToEmpty(mRule.getName());
+    }
+
+    @NonNull
+    public Status getStatus() {
+        return mStatus;
+    }
+
+    @AutomaticZenRule.Type
+    public int getType() {
+        return mRule.getType();
+    }
+
+    @Nullable
+    public String getTriggerDescription() {
+        return mRule.getTriggerDescription();
+    }
+
+    @NonNull
+    public ListenableFuture<Drawable> getIcon(@NonNull Context context,
+            @NonNull ZenIconLoader iconLoader) {
+        if (mIsManualDnd) {
+            return Futures.immediateFuture(requireNonNull(
+                    context.getDrawable(R.drawable.ic_do_not_disturb_on_24dp)));
+        }
+
+        return iconLoader.getIcon(context, mRule);
+    }
+
+    @NonNull
+    public ZenPolicy getPolicy() {
+        switch (mRule.getInterruptionFilter()) {
+            case INTERRUPTION_FILTER_PRIORITY:
+            case NotificationManager.INTERRUPTION_FILTER_ALL:
+                return requireNonNull(mRule.getZenPolicy());
+
+            case NotificationManager.INTERRUPTION_FILTER_ALARMS:
+                return POLICY_INTERRUPTION_FILTER_ALARMS;
+
+            case NotificationManager.INTERRUPTION_FILTER_NONE:
+                return POLICY_INTERRUPTION_FILTER_NONE;
+
+            case NotificationManager.INTERRUPTION_FILTER_UNKNOWN:
+            default:
+                Log.wtf(TAG, "Rule " + mId + " with unexpected interruptionFilter "
+                        + mRule.getInterruptionFilter());
+                return requireNonNull(mRule.getZenPolicy());
+        }
+    }
+
+    /**
+     * Updates the {@link ZenPolicy} of the associated {@link AutomaticZenRule} based on the
+     * supplied policy. In some cases this involves conversions, so that the following call
+     * to {@link #getPolicy} might return a different policy from the one supplied here.
+     */
+    @SuppressLint("WrongConstant")
+    public void setPolicy(@NonNull ZenPolicy policy) {
+        ZenPolicy currentPolicy = getPolicy();
+        if (currentPolicy.equals(policy)) {
+            return;
+        }
+
+        if (mRule.getInterruptionFilter() == INTERRUPTION_FILTER_ALL) {
+            Log.wtf(TAG, "Able to change policy without filtering being enabled");
+        }
+
+        // If policy is customized from any of the "special" ones, make the rule PRIORITY.
+        if (mRule.getInterruptionFilter() != INTERRUPTION_FILTER_PRIORITY) {
+            mRule.setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY);
+        }
+        mRule.setZenPolicy(policy);
+    }
+
+    @NonNull
+    public ZenDeviceEffects getDeviceEffects() {
+        return mRule.getDeviceEffects() != null
+                ? mRule.getDeviceEffects()
+                : new ZenDeviceEffects.Builder().build();
+    }
+
+    public void setCustomModeConditionId(Context context, Uri conditionId) {
+        checkState(SystemZenRules.PACKAGE_ANDROID.equals(mRule.getPackageName()),
+                "Trying to change condition of non-system-owned rule %s (to %s)",
+                mRule, conditionId);
+
+        Uri oldCondition = mRule.getConditionId();
+        mRule.setConditionId(conditionId);
+
+        ZenModeConfig.ScheduleInfo scheduleInfo = tryParseScheduleConditionId(conditionId);
+        if (scheduleInfo != null) {
+            mRule.setType(AutomaticZenRule.TYPE_SCHEDULE_TIME);
+            mRule.setOwner(ZenModeConfig.getScheduleConditionProvider());
+            mRule.setTriggerDescription(
+                    getTriggerDescriptionForScheduleTime(context, scheduleInfo));
+            return;
+        }
+
+        ZenModeConfig.EventInfo eventInfo = tryParseEventConditionId(conditionId);
+        if (eventInfo != null) {
+            mRule.setType(AutomaticZenRule.TYPE_SCHEDULE_CALENDAR);
+            mRule.setOwner(ZenModeConfig.getEventConditionProvider());
+            mRule.setTriggerDescription(getTriggerDescriptionForScheduleEvent(context, eventInfo));
+            return;
+        }
+
+        if (ZenModeConfig.isValidCustomManualConditionId(conditionId)) {
+            mRule.setType(AutomaticZenRule.TYPE_OTHER);
+            mRule.setOwner(ZenModeConfig.getCustomManualConditionProvider());
+            mRule.setTriggerDescription("");
+            return;
+        }
+
+        Log.wtf(TAG, String.format(
+                "Changed condition of rule %s (%s -> %s) but cannot recognize which kind of "
+                        + "condition it was!",
+                mRule, oldCondition, conditionId));
+    }
+
+    public boolean canEditName() {
+        return !isManualDnd();
+    }
+
+    public boolean canEditIcon() {
+        return !isManualDnd();
+    }
+
+    public boolean canBeDeleted() {
+        return !isManualDnd();
+    }
+
+    public boolean isManualDnd() {
+        return mIsManualDnd;
+    }
+
+    public boolean isActive() {
+        return mStatus == Status.ENABLED_AND_ACTIVE;
+    }
+
+    public boolean isSystemOwned() {
+        return SystemZenRules.PACKAGE_ANDROID.equals(mRule.getPackageName());
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        return obj instanceof ZenMode other
+                && mId.equals(other.mId)
+                && mRule.equals(other.mRule)
+                && mStatus.equals(other.mStatus)
+                && mIsManualDnd == other.mIsManualDnd;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mId, mRule, mStatus, mIsManualDnd);
+    }
+
+    @Override
+    public String toString() {
+        return mId + " (" + mStatus + ") -> " + mRule;
+    }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenModesBackend.java b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenModesBackend.java
new file mode 100644
index 0000000..5529da0
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenModesBackend.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.notification.modes;
+
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.app.AutomaticZenRule;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.net.Uri;
+import android.provider.Settings;
+import android.service.notification.Condition;
+import android.service.notification.ZenModeConfig;
+import android.util.Log;
+
+import com.android.settingslib.R;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Class used for Settings-NMS interactions related to Mode management.
+ *
+ * <p>This class converts {@link AutomaticZenRule} instances, as well as the manual zen mode,
+ * into the unified {@link ZenMode} format.
+ */
+public class ZenModesBackend {
+
+    private static final String TAG = "ZenModeBackend";
+
+    @Nullable // Until first usage
+    private static ZenModesBackend sInstance;
+
+    private final NotificationManager mNotificationManager;
+
+    private final Context mContext;
+
+    public static ZenModesBackend getInstance(Context context) {
+        if (sInstance == null) {
+            sInstance = new ZenModesBackend(context.getApplicationContext());
+        }
+        return sInstance;
+    }
+
+    ZenModesBackend(Context context) {
+        mContext = context;
+        mNotificationManager = context.getSystemService(NotificationManager.class);
+    }
+
+    public List<ZenMode> getModes() {
+        Map<String, AutomaticZenRule> zenRules = mNotificationManager.getAutomaticZenRules();
+        ZenModeConfig currentConfig = mNotificationManager.getZenModeConfig();
+
+        ArrayList<ZenMode> modes = new ArrayList<>();
+        modes.add(getManualDndMode(currentConfig));
+
+        for (Map.Entry<String, AutomaticZenRule> zenRuleEntry : zenRules.entrySet()) {
+            String ruleId = zenRuleEntry.getKey();
+            ZenModeConfig.ZenRule extraData = currentConfig.automaticRules.get(ruleId);
+            if (extraData != null) {
+                modes.add(new ZenMode(ruleId, zenRuleEntry.getValue(), extraData));
+            } else {
+                Log.w(TAG, "Found AZR " + zenRuleEntry.getValue()
+                        + " but no corresponding entry in ZenModeConfig (" + currentConfig
+                        + "). Skipping");
+            }
+        }
+
+        // Manual DND first, then alphabetically.
+        modes.sort(Comparator.comparing(ZenMode::isManualDnd).reversed()
+                .thenComparing(ZenMode::getName));
+
+        return modes;
+    }
+
+    @Nullable
+    public ZenMode getMode(String id) {
+        ZenModeConfig currentConfig = mNotificationManager.getZenModeConfig();
+        if (ZenMode.MANUAL_DND_MODE_ID.equals(id)) {
+            return getManualDndMode(currentConfig);
+        } else {
+            AutomaticZenRule rule = mNotificationManager.getAutomaticZenRule(id);
+            ZenModeConfig.ZenRule extraData = currentConfig.automaticRules.get(id);
+            if (rule == null || extraData == null) {
+                return null;
+            }
+            return new ZenMode(id, rule, extraData);
+        }
+    }
+
+    private ZenMode getManualDndMode(ZenModeConfig config) {
+        ZenModeConfig.ZenRule manualRule = config.manualRule;
+        // TODO: b/333682392 - Replace with final strings for name & trigger description
+        AutomaticZenRule manualDndRule = new AutomaticZenRule.Builder(
+                mContext.getString(R.string.zen_mode_settings_title), manualRule.conditionId)
+                .setType(manualRule.type)
+                .setZenPolicy(manualRule.zenPolicy)
+                .setDeviceEffects(manualRule.zenDeviceEffects)
+                .setManualInvocationAllowed(manualRule.allowManualInvocation)
+                .setConfigurationActivity(null) // No further settings
+                .setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
+                .build();
+
+        return ZenMode.manualDndMode(manualDndRule, config != null && config.isManualActive());
+    }
+
+    public void updateMode(ZenMode mode) {
+        if (mode.isManualDnd()) {
+            try {
+                NotificationManager.Policy dndPolicy =
+                        new ZenModeConfig().toNotificationPolicy(mode.getPolicy());
+                mNotificationManager.setNotificationPolicy(dndPolicy, /* fromUser= */ true);
+
+                mNotificationManager.setManualZenRuleDeviceEffects(
+                        mode.getRule().getDeviceEffects());
+            } catch (Exception e) {
+                Log.w(TAG, "Error updating manual mode", e);
+            }
+        } else {
+            mNotificationManager.updateAutomaticZenRule(mode.getId(), mode.getRule(),
+                    /* fromUser= */ true);
+        }
+    }
+
+    public void activateMode(ZenMode mode, @Nullable Duration forDuration) {
+        if (mode.isManualDnd()) {
+            Uri durationConditionId = null;
+            if (forDuration != null) {
+                durationConditionId = ZenModeConfig.toTimeCondition(mContext,
+                        (int) forDuration.toMinutes(), ActivityManager.getCurrentUser(), true).id;
+            }
+            mNotificationManager.setZenMode(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS,
+                    durationConditionId, TAG, /* fromUser= */ true);
+
+        } else {
+            if (forDuration != null) {
+                throw new IllegalArgumentException(
+                        "Only the manual DND mode can be activated for a specific duration");
+            }
+            mNotificationManager.setAutomaticZenRuleState(mode.getId(),
+                    new Condition(mode.getRule().getConditionId(), "", Condition.STATE_TRUE,
+                            Condition.SOURCE_USER_ACTION));
+        }
+    }
+
+    public void deactivateMode(ZenMode mode) {
+        if (mode.isManualDnd()) {
+            // When calling with fromUser=true this will not snooze other modes.
+            mNotificationManager.setZenMode(Settings.Global.ZEN_MODE_OFF, null, TAG,
+                    /* fromUser= */ true);
+        } else {
+            // TODO: b/333527800 - This should (potentially) snooze the rule if it was active.
+            mNotificationManager.setAutomaticZenRuleState(mode.getId(),
+                    new Condition(mode.getRule().getConditionId(), "", Condition.STATE_FALSE,
+                            Condition.SOURCE_USER_ACTION));
+        }
+    }
+
+    public void removeMode(ZenMode mode) {
+        if (!mode.canBeDeleted()) {
+            throw new IllegalArgumentException("Mode " + mode + " cannot be deleted!");
+        }
+        mNotificationManager.removeAutomaticZenRule(mode.getId(), /* fromUser= */ true);
+    }
+
+    /**
+     * Creates a new custom mode with the provided {@code name}. The mode will be "manual" (i.e.
+     * not have a schedule), this can be later updated by the user in the mode settings page.
+     *
+     * @return the created mode. Only {@code null} if creation failed due to an internal error
+     */
+    @Nullable
+    public ZenMode addCustomMode(String name) {
+        AutomaticZenRule rule = new AutomaticZenRule.Builder(name,
+                ZenModeConfig.toCustomManualConditionId())
+                .setPackage(ZenModeConfig.getCustomManualConditionProvider().getPackageName())
+                .setType(AutomaticZenRule.TYPE_OTHER)
+                .setOwner(ZenModeConfig.getCustomManualConditionProvider())
+                .setManualInvocationAllowed(true)
+                .build();
+
+        String ruleId = mNotificationManager.addAutomaticZenRule(rule);
+        return getMode(ruleId);
+    }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/model/ZenMode.kt b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/model/ZenMode.kt
deleted file mode 100644
index a696f8c..0000000
--- a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/model/ZenMode.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- *  Licensed under the Apache License, Version 2.0 (the "License");
- *  you may not use this file except in compliance with the License.
- *  You may obtain a copy of the License at
- *
- *       http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
- */
-
-package com.android.settingslib.statusbar.notification.data.model
-
-import android.provider.Settings.Global
-
-/** Validating wrapper for [android.app.NotificationManager.getZenMode] values. */
-@JvmInline
-value class ZenMode(val zenMode: Int) {
-
-    init {
-        require(zenMode in supportedModes) { "Unsupported zenMode=$zenMode" }
-    }
-
-    private companion object {
-
-        val supportedModes =
-            listOf(
-                Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS,
-                Global.ZEN_MODE_NO_INTERRUPTIONS,
-                Global.ZEN_MODE_ALARMS,
-                Global.ZEN_MODE_OFF,
-            )
-    }
-}
diff --git a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/FakeNotificationsSoundPolicyRepository.kt b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/FakeZenModeRepository.kt
similarity index 80%
rename from packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/FakeNotificationsSoundPolicyRepository.kt
rename to packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/FakeZenModeRepository.kt
index a939ed1..775e2fc 100644
--- a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/FakeNotificationsSoundPolicyRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/FakeZenModeRepository.kt
@@ -18,19 +18,18 @@
 
 import android.app.NotificationManager
 import android.provider.Settings
-import com.android.settingslib.statusbar.notification.data.model.ZenMode
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 
-class FakeNotificationsSoundPolicyRepository : NotificationsSoundPolicyRepository {
+class FakeZenModeRepository : ZenModeRepository {
 
     private val mutableNotificationPolicy = MutableStateFlow<NotificationManager.Policy?>(null)
-    override val notificationPolicy: StateFlow<NotificationManager.Policy?>
+    override val consolidatedNotificationPolicy: StateFlow<NotificationManager.Policy?>
         get() = mutableNotificationPolicy.asStateFlow()
 
-    private val mutableZenMode = MutableStateFlow<ZenMode?>(ZenMode(Settings.Global.ZEN_MODE_OFF))
-    override val zenMode: StateFlow<ZenMode?>
+    private val mutableZenMode = MutableStateFlow(Settings.Global.ZEN_MODE_OFF)
+    override val globalZenMode: StateFlow<Int>
         get() = mutableZenMode.asStateFlow()
 
     init {
@@ -41,12 +40,12 @@
         mutableNotificationPolicy.value = policy
     }
 
-    fun updateZenMode(zenMode: ZenMode?) {
+    fun updateZenMode(zenMode: Int) {
         mutableZenMode.value = zenMode
     }
 }
 
-fun FakeNotificationsSoundPolicyRepository.updateNotificationPolicy(
+fun FakeZenModeRepository.updateNotificationPolicy(
     priorityCategories: Int = 0,
     priorityCallSenders: Int = NotificationManager.Policy.PRIORITY_SENDERS_ANY,
     priorityMessageSenders: Int = NotificationManager.Policy.CONVERSATION_SENDERS_NONE,
diff --git a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/NotificationsSoundPolicyRepository.kt b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/NotificationsSoundPolicyRepository.kt
deleted file mode 100644
index 0fb8c3f..0000000
--- a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/NotificationsSoundPolicyRepository.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- *  Licensed under the Apache License, Version 2.0 (the "License");
- *  you may not use this file except in compliance with the License.
- *  You may obtain a copy of the License at
- *
- *       http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
- */
-
-package com.android.settingslib.statusbar.notification.data.repository
-
-import android.app.NotificationManager
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import com.android.settingslib.statusbar.notification.data.model.ZenMode
-import kotlin.coroutines.CoroutineContext
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onStart
-import kotlinx.coroutines.flow.shareIn
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
-
-/** Provides state of volume policy and restrictions imposed by notifications. */
-interface NotificationsSoundPolicyRepository {
-
-    /** @see NotificationManager.getNotificationPolicy */
-    val notificationPolicy: StateFlow<NotificationManager.Policy?>
-
-    /** @see NotificationManager.getZenMode */
-    val zenMode: StateFlow<ZenMode?>
-}
-
-class NotificationsSoundPolicyRepositoryImpl(
-    private val context: Context,
-    private val notificationManager: NotificationManager,
-    scope: CoroutineScope,
-    backgroundCoroutineContext: CoroutineContext,
-) : NotificationsSoundPolicyRepository {
-
-    private val notificationBroadcasts =
-        callbackFlow {
-                val receiver =
-                    object : BroadcastReceiver() {
-                        override fun onReceive(context: Context?, intent: Intent?) {
-                            intent?.action?.let { action -> launch { send(action) } }
-                        }
-                    }
-
-                context.registerReceiver(
-                    receiver,
-                    IntentFilter().apply {
-                        addAction(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED)
-                        addAction(NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED)
-                    }
-                )
-
-                awaitClose { context.unregisterReceiver(receiver) }
-            }
-            .shareIn(
-                started = SharingStarted.WhileSubscribed(),
-                scope = scope,
-            )
-
-    override val notificationPolicy: StateFlow<NotificationManager.Policy?> =
-        notificationBroadcasts
-            .filter { NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED == it }
-            .map { notificationManager.consolidatedNotificationPolicy }
-            .onStart { emit(notificationManager.consolidatedNotificationPolicy) }
-            .flowOn(backgroundCoroutineContext)
-            .stateIn(scope, SharingStarted.WhileSubscribed(), null)
-
-    override val zenMode: StateFlow<ZenMode?> =
-        notificationBroadcasts
-            .filter { NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED == it }
-            .map { ZenMode(notificationManager.zenMode) }
-            .onStart { emit(ZenMode(notificationManager.zenMode)) }
-            .flowOn(backgroundCoroutineContext)
-            .stateIn(scope, SharingStarted.WhileSubscribed(), null)
-}
diff --git a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/ZenModeRepository.kt b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/ZenModeRepository.kt
new file mode 100644
index 0000000..4d25237
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/data/repository/ZenModeRepository.kt
@@ -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.settingslib.statusbar.notification.data.repository
+
+import android.app.NotificationManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.android.settingslib.flags.Flags
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/** Provides state of volume policy and restrictions imposed by notifications. */
+interface ZenModeRepository {
+    /** @see NotificationManager.getConsolidatedNotificationPolicy */
+    val consolidatedNotificationPolicy: StateFlow<NotificationManager.Policy?>
+
+    /** @see NotificationManager.getZenMode */
+    val globalZenMode: StateFlow<Int?>
+}
+
+class ZenModeRepositoryImpl(
+    private val context: Context,
+    private val notificationManager: NotificationManager,
+    val scope: CoroutineScope,
+    val backgroundCoroutineContext: CoroutineContext,
+) : ZenModeRepository {
+
+    private val notificationBroadcasts =
+        callbackFlow {
+                val receiver =
+                    object : BroadcastReceiver() {
+                        override fun onReceive(context: Context?, intent: Intent?) {
+                            intent?.action?.let { action -> launch { send(action) } }
+                        }
+                    }
+
+                context.registerReceiver(
+                    receiver,
+                    IntentFilter().apply {
+                        addAction(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED)
+                        addAction(NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED)
+                        if (Flags.volumePanelBroadcastFix() && android.app.Flags.modesApi())
+                            addAction(
+                                NotificationManager.ACTION_CONSOLIDATED_NOTIFICATION_POLICY_CHANGED)
+                    })
+
+                awaitClose { context.unregisterReceiver(receiver) }
+            }
+            .apply {
+                if (Flags.volumePanelBroadcastFix()) {
+                    flowOn(backgroundCoroutineContext)
+                    stateIn(scope, SharingStarted.WhileSubscribed(), null)
+                } else {
+                    shareIn(
+                        started = SharingStarted.WhileSubscribed(),
+                        scope = scope,
+                    )
+                }
+            }
+
+    override val consolidatedNotificationPolicy: StateFlow<NotificationManager.Policy?> =
+        if (Flags.volumePanelBroadcastFix() && android.app.Flags.modesApi())
+            flowFromBroadcast(NotificationManager.ACTION_CONSOLIDATED_NOTIFICATION_POLICY_CHANGED) {
+                notificationManager.consolidatedNotificationPolicy
+            }
+        else
+            flowFromBroadcast(NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED) {
+                notificationManager.consolidatedNotificationPolicy
+            }
+
+    override val globalZenMode: StateFlow<Int?> =
+        flowFromBroadcast(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED) {
+            notificationManager.zenMode
+        }
+
+    private fun <T> flowFromBroadcast(intentAction: String, mapper: () -> T) =
+        notificationBroadcasts
+            .filter { intentAction == it }
+            .map { mapper() }
+            .onStart { emit(mapper()) }
+            .flowOn(backgroundCoroutineContext)
+            .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractor.kt b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractor.kt
index 7719c4b..953c90d 100644
--- a/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractor.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractor.kt
@@ -20,8 +20,7 @@
 import android.media.AudioManager
 import android.provider.Settings
 import android.service.notification.ZenModeConfig
-import com.android.settingslib.statusbar.notification.data.model.ZenMode
-import com.android.settingslib.statusbar.notification.data.repository.NotificationsSoundPolicyRepository
+import com.android.settingslib.statusbar.notification.data.repository.ZenModeRepository
 import com.android.settingslib.volume.shared.model.AudioStream
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.StateFlow
@@ -30,17 +29,15 @@
 import kotlinx.coroutines.flow.map
 
 /** Determines notification sounds state and limitations. */
-class NotificationsSoundPolicyInteractor(
-    private val repository: NotificationsSoundPolicyRepository
-) {
+class NotificationsSoundPolicyInteractor(private val repository: ZenModeRepository) {
 
     /** @see NotificationManager.getNotificationPolicy */
-    val notificationPolicy: StateFlow<NotificationManager.Policy?>
-        get() = repository.notificationPolicy
+    private val notificationPolicy: StateFlow<NotificationManager.Policy?>
+        get() = repository.consolidatedNotificationPolicy
 
     /** @see NotificationManager.getZenMode */
-    val zenMode: StateFlow<ZenMode?>
-        get() = repository.zenMode
+    val zenMode: StateFlow<Int?>
+        get() = repository.globalZenMode
 
     /** Checks if [notificationPolicy] allows alarms. */
     val areAlarmsAllowed: Flow<Boolean?> = notificationPolicy.map { it?.allowAlarms() }
@@ -67,7 +64,7 @@
             isRingerAllowed.filterNotNull(),
             isSystemAllowed.filterNotNull(),
         ) { zenMode, areAlarmsAllowed, isMediaAllowed, isRingerAllowed, isSystemAllowed ->
-            when (zenMode.zenMode) {
+            when (zenMode) {
                 // Everything is muted
                 Settings.Global.ZEN_MODE_NO_INTERRUPTIONS -> return@combine true
                 Settings.Global.ZEN_MODE_ALARMS ->
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/statusbar/notification/data/repository/NotificationsSoundPolicyRepositoryTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/statusbar/notification/data/repository/ZenModeRepositoryTest.kt
similarity index 66%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/statusbar/notification/data/repository/NotificationsSoundPolicyRepositoryTest.kt
rename to packages/SettingsLib/tests/integ/src/com/android/settingslib/statusbar/notification/data/repository/ZenModeRepositoryTest.kt
index dfc4c0a..688bebb 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/statusbar/notification/data/repository/NotificationsSoundPolicyRepositoryTest.kt
+++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/statusbar/notification/data/repository/ZenModeRepositoryTest.kt
@@ -20,10 +20,12 @@
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
 import android.provider.Settings.Global
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.settingslib.statusbar.notification.data.model.ZenMode
+import com.android.settingslib.flags.Flags
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.launchIn
@@ -45,13 +47,15 @@
 @OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
-class NotificationsSoundPolicyRepositoryTest {
+class ZenModeRepositoryTest {
 
     @Mock private lateinit var context: Context
+
     @Mock private lateinit var notificationManager: NotificationManager
+
     @Captor private lateinit var receiverCaptor: ArgumentCaptor<BroadcastReceiver>
 
-    private lateinit var underTest: NotificationsSoundPolicyRepository
+    private lateinit var underTest: ZenModeRepository
 
     private val testScope: TestScope = TestScope()
 
@@ -60,7 +64,7 @@
         MockitoAnnotations.initMocks(this)
 
         underTest =
-            NotificationsSoundPolicyRepositoryImpl(
+            ZenModeRepositoryImpl(
                 context,
                 notificationManager,
                 testScope.backgroundScope,
@@ -68,15 +72,18 @@
             )
     }
 
+    @DisableFlags(android.app.Flags.FLAG_MODES_API, Flags.FLAG_VOLUME_PANEL_BROADCAST_FIX)
     @Test
-    fun policyChanges_repositoryEmits() {
+    fun consolidatedPolicyChanges_repositoryEmits_flagsOff() {
         testScope.runTest {
             val values = mutableListOf<NotificationManager.Policy?>()
-            `when`(notificationManager.notificationPolicy).thenReturn(testPolicy1)
-            underTest.notificationPolicy.onEach { values.add(it) }.launchIn(backgroundScope)
+            `when`(notificationManager.consolidatedNotificationPolicy).thenReturn(testPolicy1)
+            underTest.consolidatedNotificationPolicy
+                .onEach { values.add(it) }
+                .launchIn(backgroundScope)
             runCurrent()
 
-            `when`(notificationManager.notificationPolicy).thenReturn(testPolicy2)
+            `when`(notificationManager.consolidatedNotificationPolicy).thenReturn(testPolicy2)
             triggerIntent(NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED)
             runCurrent()
 
@@ -86,12 +93,33 @@
         }
     }
 
+    @EnableFlags(android.app.Flags.FLAG_MODES_API, Flags.FLAG_VOLUME_PANEL_BROADCAST_FIX)
+    @Test
+    fun consolidatedPolicyChanges_repositoryEmits_flagsOn() {
+        testScope.runTest {
+            val values = mutableListOf<NotificationManager.Policy?>()
+            `when`(notificationManager.consolidatedNotificationPolicy).thenReturn(testPolicy1)
+            underTest.consolidatedNotificationPolicy
+                .onEach { values.add(it) }
+                .launchIn(backgroundScope)
+            runCurrent()
+
+            `when`(notificationManager.consolidatedNotificationPolicy).thenReturn(testPolicy2)
+            triggerIntent(NotificationManager.ACTION_CONSOLIDATED_NOTIFICATION_POLICY_CHANGED)
+            runCurrent()
+
+            assertThat(values)
+                .containsExactlyElementsIn(listOf(null, testPolicy1, testPolicy2))
+                .inOrder()
+        }
+    }
+
     @Test
     fun zenModeChanges_repositoryEmits() {
         testScope.runTest {
-            val values = mutableListOf<ZenMode?>()
+            val values = mutableListOf<Int?>()
             `when`(notificationManager.zenMode).thenReturn(Global.ZEN_MODE_OFF)
-            underTest.zenMode.onEach { values.add(it) }.launchIn(backgroundScope)
+            underTest.globalZenMode.onEach { values.add(it) }.launchIn(backgroundScope)
             runCurrent()
 
             `when`(notificationManager.zenMode).thenReturn(Global.ZEN_MODE_ALARMS)
@@ -100,8 +128,7 @@
 
             assertThat(values)
                 .containsExactlyElementsIn(
-                    listOf(null, ZenMode(Global.ZEN_MODE_OFF), ZenMode(Global.ZEN_MODE_ALARMS))
-                )
+                    listOf(null, Global.ZEN_MODE_OFF, Global.ZEN_MODE_ALARMS))
                 .inOrder()
         }
     }
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenIconLoaderTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenIconLoaderTest.java
new file mode 100644
index 0000000..20461e3
--- /dev/null
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenIconLoaderTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.notification.modes;
+
+import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.AutomaticZenRule;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.service.notification.ZenPolicy;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(RobolectricTestRunner.class)
+public class ZenIconLoaderTest {
+
+    private Context mContext;
+    private ZenIconLoader mLoader;
+
+    @Before
+    public void setUp() {
+        mContext = RuntimeEnvironment.application;
+        mLoader = new ZenIconLoader(MoreExecutors.newDirectExecutorService());
+    }
+
+    @Test
+    public void getIcon_systemOwnedRuleWithIcon_loads() throws Exception {
+        AutomaticZenRule systemRule = newRuleBuilder()
+                .setPackage("android")
+                .setIconResId(android.R.drawable.ic_media_play)
+                .build();
+
+        ListenableFuture<Drawable> loadFuture = mLoader.getIcon(mContext, systemRule);
+        assertThat(loadFuture.isDone()).isTrue();
+        assertThat(loadFuture.get()).isNotNull();
+    }
+
+    @Test
+    public void getIcon_ruleWithoutSpecificIcon_loadsFallback() throws Exception {
+        AutomaticZenRule rule = newRuleBuilder()
+                .setType(AutomaticZenRule.TYPE_DRIVING)
+                .setPackage("com.blah")
+                .build();
+
+        ListenableFuture<Drawable> loadFuture = mLoader.getIcon(mContext, rule);
+        assertThat(loadFuture.isDone()).isTrue();
+        assertThat(loadFuture.get()).isNotNull();
+    }
+
+    @Test
+    public void getIcon_ruleWithAppIconWithLoadFailure_loadsFallback() throws Exception {
+        AutomaticZenRule rule = newRuleBuilder()
+                .setType(AutomaticZenRule.TYPE_DRIVING)
+                .setPackage("com.blah")
+                .setIconResId(-123456)
+                .build();
+
+        ListenableFuture<Drawable> loadFuture = mLoader.getIcon(mContext, rule);
+        assertThat(loadFuture.get()).isNotNull();
+    }
+
+    private static AutomaticZenRule.Builder newRuleBuilder() {
+        return new AutomaticZenRule.Builder("Driving", Uri.parse("drive"))
+                .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                .setZenPolicy(new ZenPolicy.Builder().build());
+    }
+}
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java
new file mode 100644
index 0000000..32cdb98
--- /dev/null
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.notification.modes;
+
+import static android.app.NotificationManager.INTERRUPTION_FILTER_ALARMS;
+import static android.app.NotificationManager.INTERRUPTION_FILTER_NONE;
+import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.AutomaticZenRule;
+import android.net.Uri;
+import android.service.notification.Condition;
+import android.service.notification.ZenModeConfig;
+import android.service.notification.ZenPolicy;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class ZenModeTest {
+
+    private static final ZenPolicy ZEN_POLICY = new ZenPolicy.Builder().allowAllSounds().build();
+
+    private static final AutomaticZenRule ZEN_RULE =
+            new AutomaticZenRule.Builder("Driving", Uri.parse("drive"))
+                    .setPackage("com.some.driving.thing")
+                    .setType(AutomaticZenRule.TYPE_DRIVING)
+                    .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                    .setZenPolicy(ZEN_POLICY)
+                    .build();
+
+    @Test
+    public void testBasicMethods() {
+        ZenMode zenMode = new ZenMode("id", ZEN_RULE, zenConfigRuleFor(ZEN_RULE, true));
+
+        assertThat(zenMode.getId()).isEqualTo("id");
+        assertThat(zenMode.getRule()).isEqualTo(ZEN_RULE);
+        assertThat(zenMode.isManualDnd()).isFalse();
+        assertThat(zenMode.canBeDeleted()).isTrue();
+        assertThat(zenMode.isActive()).isTrue();
+
+        ZenMode manualMode = ZenMode.manualDndMode(ZEN_RULE, false);
+        assertThat(manualMode.getId()).isEqualTo(ZenMode.MANUAL_DND_MODE_ID);
+        assertThat(manualMode.isManualDnd()).isTrue();
+        assertThat(manualMode.canBeDeleted()).isFalse();
+        assertThat(manualMode.isActive()).isFalse();
+    }
+
+    @Test
+    public void constructor_enabledRule_statusEnabled() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder(ZEN_RULE).setEnabled(true).build();
+        ZenModeConfig.ZenRule configZenRule = zenConfigRuleFor(azr, false);
+
+        ZenMode mode = new ZenMode("id", azr, configZenRule);
+        assertThat(mode.getStatus()).isEqualTo(ZenMode.Status.ENABLED);
+        assertThat(mode.isActive()).isFalse();
+    }
+
+    @Test
+    public void constructor_activeRule_statusActive() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder(ZEN_RULE).setEnabled(true).build();
+        ZenModeConfig.ZenRule configZenRule = zenConfigRuleFor(azr, true);
+
+        ZenMode mode = new ZenMode("id", azr, configZenRule);
+        assertThat(mode.getStatus()).isEqualTo(ZenMode.Status.ENABLED_AND_ACTIVE);
+        assertThat(mode.isActive()).isTrue();
+    }
+
+    @Test
+    public void constructor_disabledRuleByUser_statusDisabledByUser() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder(ZEN_RULE).setEnabled(false).build();
+        ZenModeConfig.ZenRule configZenRule = zenConfigRuleFor(azr, false);
+        configZenRule.disabledOrigin = ZenModeConfig.UPDATE_ORIGIN_USER;
+
+        ZenMode mode = new ZenMode("id", azr, configZenRule);
+        assertThat(mode.getStatus()).isEqualTo(ZenMode.Status.DISABLED_BY_USER);
+    }
+
+    @Test
+    public void constructor_disabledRuleByOther_statusDisabledByOther() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder(ZEN_RULE).setEnabled(false).build();
+        ZenModeConfig.ZenRule configZenRule = zenConfigRuleFor(azr, false);
+        configZenRule.disabledOrigin = ZenModeConfig.UPDATE_ORIGIN_APP;
+
+        ZenMode mode = new ZenMode("id", azr, configZenRule);
+        assertThat(mode.getStatus()).isEqualTo(ZenMode.Status.DISABLED_BY_OTHER);
+    }
+
+    @Test
+    public void getPolicy_interruptionFilterPriority_returnsZenPolicy() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                .setZenPolicy(ZEN_POLICY)
+                .build();
+        ZenMode zenMode = new ZenMode("id", azr, zenConfigRuleFor(azr, false));
+
+        assertThat(zenMode.getPolicy()).isEqualTo(ZEN_POLICY);
+    }
+
+    @Test
+    public void getPolicy_interruptionFilterAlarms_returnsPolicyAllowingAlarms() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setInterruptionFilter(INTERRUPTION_FILTER_ALARMS)
+                .setZenPolicy(ZEN_POLICY) // should be ignored
+                .build();
+        ZenMode zenMode = new ZenMode("id", azr, zenConfigRuleFor(azr, false));
+
+        assertThat(zenMode.getPolicy()).isEqualTo(
+                new ZenPolicy.Builder()
+                        .disallowAllSounds()
+                        .allowAlarms(true)
+                        .allowMedia(true)
+                        .allowPriorityChannels(false)
+                        .build());
+    }
+
+    @Test
+    public void getPolicy_interruptionFilterNone_returnsPolicyAllowingNothing() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setInterruptionFilter(INTERRUPTION_FILTER_NONE)
+                .setZenPolicy(ZEN_POLICY) // should be ignored
+                .build();
+        ZenMode zenMode = new ZenMode("id", azr, zenConfigRuleFor(azr, false));
+
+        assertThat(zenMode.getPolicy()).isEqualTo(
+                new ZenPolicy.Builder()
+                        .disallowAllSounds()
+                        .hideAllVisualEffects()
+                        .allowPriorityChannels(false)
+                        .build());
+    }
+
+    @Test
+    public void setPolicy_setsInterruptionFilterPriority() {
+        AutomaticZenRule azr = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setInterruptionFilter(INTERRUPTION_FILTER_ALARMS)
+                .build();
+        ZenMode zenMode = new ZenMode("id", azr, zenConfigRuleFor(azr, false));
+
+        zenMode.setPolicy(ZEN_POLICY);
+
+        assertThat(zenMode.getRule().getInterruptionFilter()).isEqualTo(
+                INTERRUPTION_FILTER_PRIORITY);
+        assertThat(zenMode.getPolicy()).isEqualTo(ZEN_POLICY);
+        assertThat(zenMode.getRule().getZenPolicy()).isEqualTo(ZEN_POLICY);
+    }
+
+    private static ZenModeConfig.ZenRule zenConfigRuleFor(AutomaticZenRule azr, boolean isActive) {
+        ZenModeConfig.ZenRule zenRule = new ZenModeConfig.ZenRule();
+        zenRule.pkg = azr.getPackageName();
+        zenRule.conditionId = azr.getConditionId();
+        zenRule.enabled = azr.isEnabled();
+        if (isActive) {
+            zenRule.condition = new Condition(azr.getConditionId(), "active", Condition.STATE_TRUE);
+        }
+        return zenRule;
+    }
+}
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModesBackendTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModesBackendTest.java
new file mode 100644
index 0000000..00c7ae3
--- /dev/null
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModesBackendTest.java
@@ -0,0 +1,382 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.notification.modes;
+
+import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
+import static android.provider.Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS;
+import static android.provider.Settings.Global.ZEN_MODE_OFF;
+import static android.service.notification.Condition.SOURCE_UNKNOWN;
+import static android.service.notification.Condition.STATE_FALSE;
+import static android.service.notification.Condition.STATE_TRUE;
+import static android.service.notification.ZenAdapters.notificationPolicyToZenPolicy;
+import static android.service.notification.ZenPolicy.STATE_ALLOW;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.AutomaticZenRule;
+import android.app.Flags;
+import android.app.NotificationManager;
+import android.app.NotificationManager.Policy;
+import android.content.Context;
+import android.net.Uri;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.provider.Settings;
+import android.service.notification.Condition;
+import android.service.notification.ZenDeviceEffects;
+import android.service.notification.ZenModeConfig;
+import android.service.notification.ZenPolicy;
+
+import com.android.settingslib.R;
+
+import com.google.common.collect.ImmutableMap;
+
+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;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.ShadowApplication;
+
+import java.time.Duration;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@EnableFlags(Flags.FLAG_MODES_UI)
+public class ZenModesBackendTest {
+
+    private static final String ZEN_RULE_ID = "rule";
+    private static final AutomaticZenRule ZEN_RULE =
+            new AutomaticZenRule.Builder("Driving", Uri.parse("drive"))
+                    .setType(AutomaticZenRule.TYPE_DRIVING)
+                    .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                    .setZenPolicy(new ZenPolicy.Builder().allowAllSounds().build())
+                    .build();
+
+    private static final AutomaticZenRule MANUAL_DND_RULE =
+            new AutomaticZenRule.Builder("Do Not Disturb", Uri.EMPTY)
+                    .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                    .setZenPolicy(new ZenPolicy.Builder().allowAllSounds().build())
+                    .build();
+
+    @Mock
+    private NotificationManager mNm;
+
+    private Context mContext;
+    private ZenModesBackend mBackend;
+
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(
+            SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT);
+
+    // Helper methods to add active/inactive rule state to a config. Returns a copy.
+    private static ZenModeConfig configWithManualRule(ZenModeConfig base, boolean active) {
+        ZenModeConfig out = base.copy();
+
+        if (active) {
+            out.manualRule.zenMode = ZEN_MODE_IMPORTANT_INTERRUPTIONS;
+            out.manualRule.condition =
+                    new Condition(out.manualRule.conditionId, "", STATE_TRUE, SOURCE_UNKNOWN);
+        } else {
+            out.manualRule.zenMode = ZEN_MODE_OFF;
+            out.manualRule.condition =
+                    new Condition(out.manualRule.conditionId, "", STATE_FALSE, SOURCE_UNKNOWN);
+        }
+        return out;
+    }
+
+    private static ZenModeConfig configWithRule(ZenModeConfig base, String ruleId,
+            AutomaticZenRule rule, boolean active) {
+        ZenModeConfig out = base.copy();
+        out.automaticRules.put(ruleId, zenConfigRuleForRule(ruleId, rule, active));
+        return out;
+    }
+
+    private static ZenModeConfig.ZenRule zenConfigRuleForRule(String id, AutomaticZenRule azr,
+            boolean active) {
+        // Note that there are many other fields of zenRule, but here we only set the ones
+        // relevant to determining whether or not it is active.
+        ZenModeConfig.ZenRule zenRule = new ZenModeConfig.ZenRule();
+        zenRule.id = id;
+        zenRule.pkg = "package";
+        zenRule.enabled = azr.isEnabled();
+        zenRule.snoozing = false;
+        zenRule.conditionId = azr.getConditionId();
+        zenRule.condition = new Condition(azr.getConditionId(), "",
+                active ? Condition.STATE_TRUE : Condition.STATE_FALSE,
+                Condition.SOURCE_USER_ACTION);
+        return zenRule;
+    }
+
+    private static ZenMode newZenMode(String id, AutomaticZenRule azr, boolean active) {
+        return new ZenMode(id, azr, zenConfigRuleForRule(id, azr, active));
+    }
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+        ShadowApplication shadowApplication = ShadowApplication.getInstance();
+        shadowApplication.setSystemService(Context.NOTIFICATION_SERVICE, mNm);
+
+        mContext = RuntimeEnvironment.application;
+        mBackend = new ZenModesBackend(mContext);
+
+        // Default catch-all case with no data. This isn't realistic, but tests below that rely
+        // on the config to get data on rules active will create those individually.
+        when(mNm.getZenModeConfig()).thenReturn(new ZenModeConfig());
+    }
+
+    @Test
+    public void getModes_containsManualDndAndZenRules() {
+        AutomaticZenRule rule2 = new AutomaticZenRule.Builder("Bedtime", Uri.parse("bed"))
+                .setType(AutomaticZenRule.TYPE_BEDTIME)
+                .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                .setZenPolicy(new ZenPolicy.Builder().disallowAllSounds().build())
+                .build();
+        Policy dndPolicy = new Policy(Policy.PRIORITY_CATEGORY_ALARMS,
+                Policy.PRIORITY_SENDERS_CONTACTS, Policy.PRIORITY_SENDERS_CONTACTS);
+        when(mNm.getAutomaticZenRules()).thenReturn(
+                ImmutableMap.of("rule1", ZEN_RULE, "rule2", rule2));
+
+        ZenModeConfig config = new ZenModeConfig();
+        config.applyNotificationPolicy(dndPolicy);
+        config = configWithRule(config, "rule1", ZEN_RULE, false);
+        config = configWithRule(config, "rule2", rule2, false);
+        assertThat(config.manualRule.zenPolicy.getPriorityCategoryAlarms()).isEqualTo(STATE_ALLOW);
+        when(mNm.getZenModeConfig()).thenReturn(config);
+
+        List<ZenMode> modes = mBackend.getModes();
+
+        // all modes exist, but none of them are currently active
+        assertThat(modes).containsExactly(
+                        ZenMode.manualDndMode(
+                                new AutomaticZenRule.Builder(
+                                        mContext.getString(R.string.zen_mode_settings_title),
+                                        Uri.EMPTY)
+                                        .setType(AutomaticZenRule.TYPE_OTHER)
+                                        .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                                        .setZenPolicy(notificationPolicyToZenPolicy(dndPolicy))
+                                        .setManualInvocationAllowed(true)
+                                        .build(),
+                                false),
+                        newZenMode("rule2", rule2, false),
+                        newZenMode("rule1", ZEN_RULE, false))
+                .inOrder();
+    }
+
+    @Test
+    public void getMode_manualDnd_returnsMode() {
+        Policy dndPolicy = new Policy(Policy.PRIORITY_CATEGORY_ALARMS,
+                Policy.PRIORITY_SENDERS_CONTACTS, Policy.PRIORITY_SENDERS_CONTACTS);
+        ZenModeConfig config = new ZenModeConfig();
+        config.applyNotificationPolicy(dndPolicy);
+        when(mNm.getZenModeConfig()).thenReturn(config);
+
+        ZenMode mode = mBackend.getMode(ZenMode.MANUAL_DND_MODE_ID);
+
+        assertThat(mode).isEqualTo(
+                ZenMode.manualDndMode(
+                        new AutomaticZenRule.Builder(
+                                mContext.getString(R.string.zen_mode_settings_title), Uri.EMPTY)
+                                .setType(AutomaticZenRule.TYPE_OTHER)
+                                .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
+                                .setZenPolicy(notificationPolicyToZenPolicy(dndPolicy))
+                                .setManualInvocationAllowed(true)
+                                .build(), false));
+    }
+
+    @Test
+    public void getMode_zenRule_returnsMode() {
+        when(mNm.getAutomaticZenRule(eq(ZEN_RULE_ID))).thenReturn(ZEN_RULE);
+        when(mNm.getZenModeConfig()).thenReturn(
+                configWithRule(new ZenModeConfig(), ZEN_RULE_ID, ZEN_RULE, false));
+
+        ZenMode mode = mBackend.getMode(ZEN_RULE_ID);
+
+        assertThat(mode).isEqualTo(newZenMode(ZEN_RULE_ID, ZEN_RULE, false));
+    }
+
+    @Test
+    public void getMode_missingRule_returnsNull() {
+        when(mNm.getAutomaticZenRule(any())).thenReturn(null);
+
+        ZenMode mode = mBackend.getMode(ZEN_RULE_ID);
+
+        assertThat(mode).isNull();
+        verify(mNm).getAutomaticZenRule(eq(ZEN_RULE_ID));
+    }
+
+    @Test
+    public void getMode_manualDnd_returnsCorrectActiveState() {
+        // Set up a base config with an active rule to make sure we're looking at the correct info
+        ZenModeConfig configWithActiveRule = configWithRule(new ZenModeConfig(), ZEN_RULE_ID,
+                ZEN_RULE, true);
+
+        // Equivalent to disallowAllSounds()
+        Policy dndPolicy = new Policy(0, 0, 0);
+        configWithActiveRule.applyNotificationPolicy(dndPolicy);
+        when(mNm.getZenModeConfig()).thenReturn(configWithActiveRule);
+
+        ZenMode mode = mBackend.getMode(ZenMode.MANUAL_DND_MODE_ID);
+
+        // By default, manual rule is inactive
+        assertThat(mode).isNotNull();
+        assertThat(mode.isActive()).isFalse();
+
+        // Now the returned config will represent the manual rule being active
+        when(mNm.getZenModeConfig()).thenReturn(configWithManualRule(configWithActiveRule, true));
+        ZenMode activeMode = mBackend.getMode(ZenMode.MANUAL_DND_MODE_ID);
+        assertThat(activeMode).isNotNull();
+        assertThat(activeMode.isActive()).isTrue();
+    }
+
+    @Test
+    public void getMode_zenRule_returnsCorrectActiveState() {
+        // Set up a base config that has an active manual rule and "rule2", to make sure we're
+        // looking at the correct rule's info.
+        ZenModeConfig configWithActiveRules = configWithRule(
+                configWithManualRule(new ZenModeConfig(), true),  // active manual rule
+                "rule2", ZEN_RULE, true);  // active rule 2
+
+        when(mNm.getAutomaticZenRule(eq(ZEN_RULE_ID))).thenReturn(ZEN_RULE);
+        when(mNm.getZenModeConfig()).thenReturn(
+                configWithRule(configWithActiveRules, ZEN_RULE_ID, ZEN_RULE, false));
+
+        // Round 1: the current config should indicate that the rule is not active
+        ZenMode mode = mBackend.getMode(ZEN_RULE_ID);
+        assertThat(mode).isNotNull();
+        assertThat(mode.isActive()).isFalse();
+
+        when(mNm.getZenModeConfig()).thenReturn(
+                configWithRule(configWithActiveRules, ZEN_RULE_ID, ZEN_RULE, true));
+        ZenMode activeMode = mBackend.getMode(ZEN_RULE_ID);
+        assertThat(activeMode).isNotNull();
+        assertThat(activeMode.isActive()).isTrue();
+    }
+
+    @Test
+    public void updateMode_manualDnd_setsDeviceEffects() throws Exception {
+        ZenMode manualDnd = ZenMode.manualDndMode(
+                new AutomaticZenRule.Builder("DND", Uri.EMPTY)
+                        .setZenPolicy(new ZenPolicy())
+                        .setDeviceEffects(new ZenDeviceEffects.Builder()
+                                .setShouldDimWallpaper(true)
+                                .build())
+                        .build(), false);
+
+        mBackend.updateMode(manualDnd);
+
+        verify(mNm).setManualZenRuleDeviceEffects(new ZenDeviceEffects.Builder()
+                .setShouldDimWallpaper(true)
+                .build());
+    }
+
+    @Test
+    public void updateMode_manualDnd_setsNotificationPolicy() {
+        ZenMode manualDnd = ZenMode.manualDndMode(
+                new AutomaticZenRule.Builder("DND", Uri.EMPTY)
+                        .setZenPolicy(new ZenPolicy.Builder().allowAllSounds().build())
+                        .build(), false);
+
+        mBackend.updateMode(manualDnd);
+
+        verify(mNm).setNotificationPolicy(eq(new ZenModeConfig().toNotificationPolicy(
+                new ZenPolicy.Builder().allowAllSounds().build())), eq(true));
+    }
+
+    @Test
+    public void updateMode_zenRule_updatesRule() {
+        ZenMode ruleMode = newZenMode("rule", ZEN_RULE, false);
+
+        mBackend.updateMode(ruleMode);
+
+        verify(mNm).updateAutomaticZenRule(eq("rule"), eq(ZEN_RULE), eq(true));
+    }
+
+    @Test
+    public void activateMode_manualDnd_setsZenModeImportant() {
+        mBackend.activateMode(ZenMode.manualDndMode(MANUAL_DND_RULE, false), null);
+
+        verify(mNm).setZenMode(eq(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS), eq(null),
+                any(), eq(true));
+    }
+
+    @Test
+    public void activateMode_manualDndWithDuration_setsZenModeImportantWithCondition() {
+        mBackend.activateMode(ZenMode.manualDndMode(MANUAL_DND_RULE, false),
+                Duration.ofMinutes(30));
+
+        verify(mNm).setZenMode(eq(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS),
+                eq(ZenModeConfig.toTimeCondition(mContext, 30, 0, true).id),
+                any(),
+                eq(true));
+    }
+
+    @Test
+    public void activateMode_zenRule_setsRuleStateActive() {
+        mBackend.activateMode(newZenMode(ZEN_RULE_ID, ZEN_RULE, false), null);
+
+        verify(mNm).setAutomaticZenRuleState(eq(ZEN_RULE_ID),
+                eq(new Condition(ZEN_RULE.getConditionId(), "", Condition.STATE_TRUE,
+                        Condition.SOURCE_USER_ACTION)));
+    }
+
+    @Test
+    public void activateMode_zenRuleWithDuration_fails() {
+        assertThrows(IllegalArgumentException.class,
+                () -> mBackend.activateMode(newZenMode(ZEN_RULE_ID, ZEN_RULE, false),
+                        Duration.ofMinutes(30)));
+    }
+
+    @Test
+    public void deactivateMode_manualDnd_setsZenModeOff() {
+        mBackend.deactivateMode(ZenMode.manualDndMode(MANUAL_DND_RULE, true));
+
+        verify(mNm).setZenMode(eq(ZEN_MODE_OFF), eq(null), any(), eq(true));
+    }
+
+    @Test
+    public void deactivateMode_zenRule_setsRuleStateInactive() {
+        mBackend.deactivateMode(newZenMode(ZEN_RULE_ID, ZEN_RULE, false));
+
+        verify(mNm).setAutomaticZenRuleState(eq(ZEN_RULE_ID),
+                eq(new Condition(ZEN_RULE.getConditionId(), "", Condition.STATE_FALSE,
+                        Condition.SOURCE_USER_ACTION)));
+    }
+
+    @Test
+    public void removeMode_zenRule_deletesRule() {
+        mBackend.removeMode(newZenMode(ZEN_RULE_ID, ZEN_RULE, false));
+
+        verify(mNm).removeAutomaticZenRule(ZEN_RULE_ID, true);
+    }
+
+    @Test
+    public void removeMode_manualDnd_fails() {
+        assertThrows(IllegalArgumentException.class,
+                () -> mBackend.removeMode(ZenMode.manualDndMode(MANUAL_DND_RULE, false)));
+    }
+}
diff --git a/packages/SystemUI/TEST_MAPPING b/packages/SystemUI/TEST_MAPPING
index deab818..16dd4e5 100644
--- a/packages/SystemUI/TEST_MAPPING
+++ b/packages/SystemUI/TEST_MAPPING
@@ -81,7 +81,7 @@
       ]
     }
   ],
-  
+
   "postsubmit": [
     {
       // Permission indicators
@@ -93,7 +93,7 @@
       ]
     }
   ],
-  
+
   // v2/sysui/suite/test-mapping-sysui-screenshot-test
   "sysui-screenshot-test": [
     {
@@ -131,7 +131,7 @@
       ]
     }
   ],
-  
+
   // v2/sysui/suite/test-mapping-sysui-screenshot-test-staged
   "sysui-screenshot-test-staged": [
     {
@@ -156,5 +156,13 @@
         }
       ]
     }
+  ],
+  "sysui-robo-test": [
+    {
+      "name": "SystemUIGoogleRoboRNGTests"
+    },
+    {
+      "name": "SystemUIGoogleRobo2RNGTests"
+    }
   ]
 }
diff --git a/packages/SystemUI/aconfig/accessibility.aconfig b/packages/SystemUI/aconfig/accessibility.aconfig
index f63a896..0861454 100644
--- a/packages/SystemUI/aconfig/accessibility.aconfig
+++ b/packages/SystemUI/aconfig/accessibility.aconfig
@@ -73,13 +73,10 @@
 }
 
 flag {
-    name: "redesign_magnifier_window_size"
+    name: "redesign_magnification_window_size"
     namespace: "accessibility"
     description: "Redesigns the window magnification magnifier sizes provided in the settings panel."
     bug: "288056772"
-    metadata {
-      purpose: PURPOSE_BUGFIX
-    }
 }
 
 flag {
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt
index 7062489..9ea435e 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt
@@ -49,6 +49,7 @@
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
@@ -129,7 +130,6 @@
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
 import androidx.compose.ui.unit.times
 import androidx.compose.ui.viewinterop.AndroidView
 import androidx.compose.ui.window.Popup
@@ -994,7 +994,8 @@
         shape = RoundedCornerShape(68.dp, 34.dp, 68.dp, 34.dp)
     ) {
         Column(
-            modifier = Modifier.fillMaxSize().padding(vertical = 38.dp, horizontal = 70.dp),
+            modifier = Modifier.fillMaxSize().padding(vertical = 32.dp, horizontal = 50.dp),
+            verticalArrangement = Arrangement.Center,
             horizontalAlignment = Alignment.CenterHorizontally,
         ) {
             Icon(
@@ -1005,41 +1006,43 @@
             Spacer(modifier = Modifier.size(6.dp))
             Text(
                 text = stringResource(R.string.cta_label_to_edit_widget),
-                style = MaterialTheme.typography.titleMedium,
-                textAlign = TextAlign.Center,
+                style = MaterialTheme.typography.titleLarge,
+                fontSize = nonScalableTextSize(22.dp),
+                lineHeight = nonScalableTextSize(28.dp),
             )
-            Spacer(modifier = Modifier.size(20.dp))
+            Spacer(modifier = Modifier.size(16.dp))
             Row(
-                modifier = Modifier.fillMaxWidth(),
-                horizontalArrangement = Arrangement.Center,
+                modifier = Modifier.fillMaxWidth().height(56.dp),
+                horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
             ) {
                 OutlinedButton(
+                    modifier = Modifier.fillMaxHeight(),
                     colors =
                         ButtonDefaults.buttonColors(
                             contentColor = colors.onPrimary,
                         ),
                     border = BorderStroke(width = 1.0.dp, color = colors.primaryContainer),
-                    contentPadding = Dimensions.ButtonPadding,
+                    contentPadding = PaddingValues(26.dp, 8.dp),
                     onClick = viewModel::onDismissCtaTile,
                 ) {
                     Text(
                         text = stringResource(R.string.cta_tile_button_to_dismiss),
-                        fontSize = 12.sp,
+                        fontSize = nonScalableTextSize(14.dp),
                     )
                 }
-                Spacer(modifier = Modifier.size(14.dp))
                 Button(
+                    modifier = Modifier.fillMaxHeight(),
                     colors =
                         ButtonDefaults.buttonColors(
                             containerColor = colors.primaryContainer,
                             contentColor = colors.onPrimaryContainer,
                         ),
-                    contentPadding = Dimensions.ButtonPadding,
+                    contentPadding = PaddingValues(26.dp, 8.dp),
                     onClick = viewModel::onOpenWidgetEditor
                 ) {
                     Text(
                         text = stringResource(R.string.cta_tile_button_to_open_widget_editor),
-                        fontSize = 12.sp,
+                        fontSize = nonScalableTextSize(14.dp),
                     )
                 }
             }
@@ -1352,6 +1355,13 @@
 }
 
 /**
+ * Text size converted from dp value to the equivalent sp value using the current screen density,
+ * ensuring it does not scale with the font size setting.
+ */
+@Composable
+private fun nonScalableTextSize(sizeInDp: Dp) = with(LocalDensity.current) { sizeInDp.toSp() }
+
+/**
  * Returns the `contentPadding` of the grid. Use the vertical padding to push the grid content area
  * below the toolbar and let the grid take the max size. This ensures the item can be dragged
  * outside the grid over the toolbar, without part of it getting clipped by the container.
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeScene.kt
index 899b256..db98bc8f 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeScene.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeScene.kt
@@ -96,6 +96,12 @@
                     shadeMode = ShadeMode.Dual,
                     modifier = Modifier.fillMaxWidth(),
                 )
+
+                // Communicates the bottom position of the drawable area within the shade to NSSL.
+                NotificationStackCutoffGuideline(
+                    stackScrollView = stackScrollView.get(),
+                    viewModel = notificationsPlaceholderViewModel,
+                )
             }
         }
     }
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt
index 4914aea..c066ae5 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt
@@ -154,6 +154,7 @@
             viewModel = viewModel.tileGridViewModel,
             modifier =
                 Modifier.fillMaxWidth().heightIn(max = QuickSettingsShade.Dimensions.GridMaxHeight),
+            viewModel.editModeViewModel::startEditing,
         )
         Button(
             onClick = { viewModel.editModeViewModel.startEditing() },
@@ -168,7 +169,7 @@
     object Dimensions {
         val Padding = 16.dp
         val BrightnessSliderHeight = 64.dp
-        val GridMaxHeight = 400.dp
+        val GridMaxHeight = 800.dp
     }
 
     object Transitions {
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt
index dbf6cd3..e433d32 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt
@@ -50,30 +50,22 @@
     from(Scenes.Gone, to = Scenes.NotificationsShade, key = OpenBottomShade) {
         goneToNotificationsShadeTransition(Edge.Bottom)
     }
-    from(Scenes.Gone, to = Scenes.Shade) { goneToShadeTransition() }
-    from(
-        Scenes.Gone,
-        to = Scenes.Shade,
-        key = ToSplitShade,
-    ) {
-        goneToSplitShadeTransition()
+    from(Scenes.Gone, to = Scenes.QuickSettingsShade) {
+        goneToQuickSettingsShadeTransition(Edge.Top)
     }
-    from(
-        Scenes.Gone,
-        to = Scenes.Shade,
-        key = SlightlyFasterShadeCollapse,
-    ) {
+    from(Scenes.Gone, to = Scenes.QuickSettingsShade, key = OpenBottomShade) {
+        goneToQuickSettingsShadeTransition(Edge.Bottom)
+    }
+    from(Scenes.Gone, to = Scenes.Shade) { goneToShadeTransition() }
+    from(Scenes.Gone, to = Scenes.Shade, key = ToSplitShade) { goneToSplitShadeTransition() }
+    from(Scenes.Gone, to = Scenes.Shade, key = SlightlyFasterShadeCollapse) {
         goneToShadeTransition(durationScale = 0.9)
     }
     from(Scenes.Gone, to = Scenes.QuickSettings) { goneToQuickSettingsTransition() }
-    from(
-        Scenes.Gone,
-        to = Scenes.QuickSettings,
-        key = SlightlyFasterShadeCollapse,
-    ) {
+    from(Scenes.Gone, to = Scenes.QuickSettings, key = SlightlyFasterShadeCollapse) {
         goneToQuickSettingsTransition(durationScale = 0.9)
     }
-    from(Scenes.Gone, to = Scenes.QuickSettingsShade) { goneToQuickSettingsShadeTransition() }
+
     from(Scenes.Lockscreen, to = Scenes.Bouncer) { lockscreenToBouncerTransition() }
     from(Scenes.Lockscreen, to = Scenes.Communal) { lockscreenToCommunalTransition() }
     from(Scenes.Lockscreen, to = Scenes.NotificationsShade) {
@@ -83,18 +75,10 @@
         lockscreenToQuickSettingsShadeTransition()
     }
     from(Scenes.Lockscreen, to = Scenes.Shade) { lockscreenToShadeTransition() }
-    from(
-        Scenes.Lockscreen,
-        to = Scenes.Shade,
-        key = ToSplitShade,
-    ) {
+    from(Scenes.Lockscreen, to = Scenes.Shade, key = ToSplitShade) {
         lockscreenToSplitShadeTransition()
     }
-    from(
-        Scenes.Lockscreen,
-        to = Scenes.Shade,
-        key = SlightlyFasterShadeCollapse,
-    ) {
+    from(Scenes.Lockscreen, to = Scenes.Shade, key = SlightlyFasterShadeCollapse) {
         lockscreenToShadeTransition(durationScale = 0.9)
     }
     from(Scenes.Lockscreen, to = Scenes.QuickSettings) { lockscreenToQuickSettingsTransition() }
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToQuickSettingsShadeTransition.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToQuickSettingsShadeTransition.kt
index 225ca4e..8a03e29 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToQuickSettingsShadeTransition.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromGoneToQuickSettingsShadeTransition.kt
@@ -16,10 +16,12 @@
 
 package com.android.systemui.scene.ui.composable.transitions
 
+import com.android.compose.animation.scene.Edge
 import com.android.compose.animation.scene.TransitionBuilder
 
 fun TransitionBuilder.goneToQuickSettingsShadeTransition(
+    edge: Edge = Edge.Top,
     durationScale: Double = 1.0,
 ) {
-    toQuickSettingsShadeTransition(durationScale)
+    toQuickSettingsShadeTransition(edge, durationScale)
 }
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToQuickSettingsShadeTransition.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToQuickSettingsShadeTransition.kt
index ce24f5e..19aa3a7 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToQuickSettingsShadeTransition.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToQuickSettingsShadeTransition.kt
@@ -16,10 +16,11 @@
 
 package com.android.systemui.scene.ui.composable.transitions
 
+import com.android.compose.animation.scene.Edge
 import com.android.compose.animation.scene.TransitionBuilder
 
 fun TransitionBuilder.lockscreenToQuickSettingsShadeTransition(
     durationScale: Double = 1.0,
 ) {
-    toQuickSettingsShadeTransition(durationScale)
+    toQuickSettingsShadeTransition(Edge.Top, durationScale)
 }
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/ToQuickSettingsShadeTransition.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/ToQuickSettingsShadeTransition.kt
index ec2f14f..9d13647 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/ToQuickSettingsShadeTransition.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/ToQuickSettingsShadeTransition.kt
@@ -19,17 +19,15 @@
 import androidx.compose.animation.core.Spring
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.tween
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.ui.unit.IntSize
 import com.android.compose.animation.scene.Edge
 import com.android.compose.animation.scene.TransitionBuilder
 import com.android.compose.animation.scene.UserActionDistance
-import com.android.compose.animation.scene.UserActionDistanceScope
 import com.android.systemui.shade.ui.composable.OverlayShade
 import com.android.systemui.shade.ui.composable.Shade
 import kotlin.time.Duration.Companion.milliseconds
 
 fun TransitionBuilder.toQuickSettingsShadeTransition(
+    edge: Edge = Edge.Top,
     durationScale: Double = 1.0,
 ) {
     spec = tween(durationMillis = (DefaultDuration * durationScale).inWholeMilliseconds.toInt())
@@ -38,17 +36,9 @@
             stiffness = Spring.StiffnessMediumLow,
             visibilityThreshold = Shade.Dimensions.ScrimVisibilityThreshold,
         )
-    distance =
-        object : UserActionDistance {
-            override fun UserActionDistanceScope.absoluteDistance(
-                fromSceneSize: IntSize,
-                orientation: Orientation,
-            ): Float {
-                return fromSceneSize.height.toFloat() * 2 / 3f
-            }
-        }
+    distance = UserActionDistance { fromSceneSize, _ -> fromSceneSize.height.toFloat() * 2 / 3f }
 
-    translate(OverlayShade.Elements.Panel, Edge.Top)
+    translate(OverlayShade.Elements.Panel, edge)
 
     fractionRange(end = .5f) { fade(OverlayShade.Elements.Scrim) }
 }
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
index e313aa5..e8fdfc8 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
@@ -913,7 +913,6 @@
     private val topOrLeftBehavior: NestedScrollBehavior,
     private val bottomOrRightBehavior: NestedScrollBehavior,
     private val isExternalOverscrollGesture: () -> Boolean,
-    private val pointersInfo: () -> PointersInfo,
 ) {
     private val layoutState = layoutImpl.state
     private val draggableHandler = layoutImpl.draggableHandler(orientation)
@@ -925,36 +924,34 @@
         // moving on to the next scene.
         var canChangeScene = false
 
+        val actionUpOrLeft =
+            Swipe(
+                direction =
+                    when (orientation) {
+                        Orientation.Horizontal -> SwipeDirection.Left
+                        Orientation.Vertical -> SwipeDirection.Up
+                    },
+                pointerCount = 1,
+            )
+
+        val actionDownOrRight =
+            Swipe(
+                direction =
+                    when (orientation) {
+                        Orientation.Horizontal -> SwipeDirection.Right
+                        Orientation.Vertical -> SwipeDirection.Down
+                    },
+                pointerCount = 1,
+            )
+
         fun hasNextScene(amount: Float): Boolean {
             val transitionState = layoutState.transitionState
             val scene = transitionState.currentScene
             val fromScene = layoutImpl.scene(scene)
             val nextScene =
                 when {
-                    amount < 0f -> {
-                        val actionUpOrLeft =
-                            Swipe(
-                                direction =
-                                    when (orientation) {
-                                        Orientation.Horizontal -> SwipeDirection.Left
-                                        Orientation.Vertical -> SwipeDirection.Up
-                                    },
-                                pointerCount = pointersInfo().pointersDown,
-                            )
-                        fromScene.userActions[actionUpOrLeft]
-                    }
-                    amount > 0f -> {
-                        val actionDownOrRight =
-                            Swipe(
-                                direction =
-                                    when (orientation) {
-                                        Orientation.Horizontal -> SwipeDirection.Right
-                                        Orientation.Vertical -> SwipeDirection.Down
-                                    },
-                                pointerCount = pointersInfo().pointersDown,
-                            )
-                        fromScene.userActions[actionDownOrRight]
-                    }
+                    amount < 0f -> fromScene.userActions[actionUpOrLeft]
+                    amount > 0f -> fromScene.userActions[actionDownOrRight]
                     else -> null
                 }
             if (nextScene != null) return true
@@ -1052,11 +1049,10 @@
             canContinueScroll = { true },
             canScrollOnFling = false,
             onStart = { offsetAvailable ->
-                val pointers = pointersInfo()
                 dragController =
                     draggableHandler.onDragStarted(
-                        pointersDown = pointers.pointersDown,
-                        startedPosition = pointers.startedPosition,
+                        pointersDown = 1,
+                        startedPosition = null,
                         overSlop = if (isIntercepting) 0f else offsetAvailable,
                     )
             },
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
index dd795cd..1fa6b3f7 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
@@ -18,21 +18,12 @@
 
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.PointerInputChange
-import androidx.compose.ui.input.pointer.PointerInputScope
-import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
 import androidx.compose.ui.node.DelegatableNode
 import androidx.compose.ui.node.DelegatingNode
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.util.fastMap
-import androidx.compose.ui.util.fastReduce
 import com.android.compose.nestedscroll.PriorityNestedScrollConnection
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.isActive
 
 /**
  * Defines the behavior of the [SceneTransitionLayout] when a scrollable component is scrolled.
@@ -130,11 +121,6 @@
     }
 }
 
-internal data class PointersInfo(
-    val pointersDown: Int,
-    val startedPosition: Offset,
-)
-
 private class NestedScrollToSceneNode(
     layoutImpl: SceneTransitionLayoutImpl,
     orientation: Orientation,
@@ -149,49 +135,23 @@
             topOrLeftBehavior = topOrLeftBehavior,
             bottomOrRightBehavior = bottomOrRightBehavior,
             isExternalOverscrollGesture = isExternalOverscrollGesture,
-            pointersInfo = pointerInfo()
         )
 
-    private var lastPointers: List<PointerInputChange>? = null
-
-    private fun pointerInfo(): () -> PointersInfo = {
-        val pointers =
-            requireNotNull(lastPointers) { "NestedScroll API was called before PointerInput API" }
-        PointersInfo(
-            pointersDown = pointers.size,
-            startedPosition = pointers.fastMap { it.position }.fastReduce { a, b -> (a + b) / 2f },
-        )
-    }
-
-    private val pointerInputHandler: suspend PointerInputScope.() -> Unit = {
-        coroutineScope {
-            awaitPointerEventScope {
-                // Await this scope to guarantee that the PointerInput API receives touch events
-                // before the NestedScroll API.
-                delegate(nestedScrollNode)
-
-                try {
-                    while (isActive) {
-                        // During the initial phase, we receive the event after our ancestors.
-                        lastPointers = awaitPointerEvent(PointerEventPass.Initial).changes
-                    }
-                } finally {
-                    // Clean up the nested scroll connection
-                    priorityNestedScrollConnection.reset()
-                    undelegate(nestedScrollNode)
-                }
-            }
-        }
-    }
-
-    private val pointerInputNode = delegate(SuspendingPointerInputModifierNode(pointerInputHandler))
-
     private var nestedScrollNode: DelegatableNode =
         nestedScrollModifierNode(
             connection = priorityNestedScrollConnection,
             dispatcher = null,
         )
 
+    override fun onAttach() {
+        delegate(nestedScrollNode)
+    }
+
+    override fun onDetach() {
+        // Make sure we reset the scroll connection when this modifier is removed from composition
+        priorityNestedScrollConnection.reset()
+    }
+
     fun update(
         layoutImpl: SceneTransitionLayoutImpl,
         orientation: Orientation,
@@ -201,7 +161,7 @@
     ) {
         // Clean up the old nested scroll connection
         priorityNestedScrollConnection.reset()
-        pointerInputNode.resetPointerInputHandler()
+        undelegate(nestedScrollNode)
 
         // Create a new nested scroll connection
         priorityNestedScrollConnection =
@@ -211,13 +171,13 @@
                 topOrLeftBehavior = topOrLeftBehavior,
                 bottomOrRightBehavior = bottomOrRightBehavior,
                 isExternalOverscrollGesture = isExternalOverscrollGesture,
-                pointersInfo = pointerInfo(),
             )
         nestedScrollNode =
             nestedScrollModifierNode(
                 connection = priorityNestedScrollConnection,
                 dispatcher = null,
             )
+        delegate(nestedScrollNode)
     }
 }
 
@@ -227,7 +187,6 @@
     topOrLeftBehavior: NestedScrollBehavior,
     bottomOrRightBehavior: NestedScrollBehavior,
     isExternalOverscrollGesture: () -> Boolean,
-    pointersInfo: () -> PointersInfo,
 ) =
     NestedScrollHandlerImpl(
             layoutImpl = layoutImpl,
@@ -235,6 +194,5 @@
             topOrLeftBehavior = topOrLeftBehavior,
             bottomOrRightBehavior = bottomOrRightBehavior,
             isExternalOverscrollGesture = isExternalOverscrollGesture,
-            pointersInfo = pointersInfo,
         )
         .connection
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
index 55a342c..65b388f 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
@@ -113,8 +113,7 @@
                     orientation = draggableHandler.orientation,
                     topOrLeftBehavior = nestedScrollBehavior,
                     bottomOrRightBehavior = nestedScrollBehavior,
-                    isExternalOverscrollGesture = { isExternalOverscrollGesture },
-                    pointersInfo = { PointersInfo(pointersDown = 1, startedPosition = Offset.Zero) }
+                    isExternalOverscrollGesture = { isExternalOverscrollGesture }
                 )
                 .connection
 
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
index 9646bc2..fcdf76e 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
@@ -839,80 +839,6 @@
     }
 
     @Test
-    fun elementTransitionDuringNestedScrollWith2Pointers() {
-        // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
-        // detected as a drag event.
-        var touchSlop = 0f
-        val translateY = 10.dp
-        val layoutWidth = 200.dp
-        val layoutHeight = 400.dp
-
-        val state =
-            rule.runOnUiThread {
-                MutableSceneTransitionLayoutState(
-                    initialScene = SceneA,
-                    transitions =
-                        transitions {
-                            from(SceneA, to = SceneB) {
-                                translate(TestElements.Foo, y = translateY)
-                            }
-                        },
-                )
-                    as MutableSceneTransitionLayoutStateImpl
-            }
-
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            SceneTransitionLayout(
-                state = state,
-                modifier = Modifier.size(layoutWidth, layoutHeight)
-            ) {
-                scene(
-                    SceneA,
-                    userActions = mapOf(Swipe(SwipeDirection.Down, pointerCount = 2) to SceneB)
-                ) {
-                    Box(
-                        Modifier
-                            // Unconsumed scroll gesture will be intercepted by STL
-                            .verticalNestedScrollToScene()
-                            // A scrollable that does not consume the scroll gesture
-                            .scrollable(
-                                rememberScrollableState(consumeScrollDelta = { 0f }),
-                                Orientation.Vertical
-                            )
-                            .fillMaxSize()
-                    ) {
-                        Spacer(Modifier.element(TestElements.Foo).fillMaxSize())
-                    }
-                }
-                scene(SceneB) { Spacer(Modifier.fillMaxSize()) }
-            }
-        }
-
-        assertThat(state.transitionState).isIdle()
-        val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag)
-        fooElement.assertTopPositionInRootIsEqualTo(0.dp)
-
-        // Swipe down with 2 pointers by half of verticalSwipeDistance.
-        rule.onRoot().performTouchInput {
-            val middleTop = Offset((layoutWidth / 2).toPx(), 0f)
-            repeat(2) { i -> down(pointerId = i, middleTop) }
-            repeat(2) { i ->
-                // Scroll 50%
-                moveBy(
-                    pointerId = i,
-                    delta = Offset(0f, touchSlop + layoutHeight.toPx() * 0.5f),
-                    delayMillis = 1_000,
-                )
-            }
-        }
-
-        val transition = assertThat(state.transitionState).isTransition()
-        assertThat(transition).hasProgress(0.5f)
-        fooElement.assertTopPositionInRootIsEqualTo(translateY * 0.5f)
-    }
-
-    @Test
     fun elementTransitionWithDistanceDuringOverscroll() {
         val layoutWidth = 200.dp
         val layoutHeight = 400.dp
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepository.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepository.kt
index e39d7ed..9e857deb 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepository.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepository.kt
@@ -21,16 +21,16 @@
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.withContext
 
 /** Provides access to state related to notification settings. */
 class NotificationSettingsRepository(
-    scope: CoroutineScope,
+    private val scope: CoroutineScope,
     private val backgroundDispatcher: CoroutineDispatcher,
     private val secureSettingsRepository: SecureSettingsRepository,
 ) {
@@ -41,16 +41,15 @@
             .distinctUntilChanged()
 
     /** The current state of the notification setting. */
-    val isShowNotificationsOnLockScreenEnabled: StateFlow<Boolean> =
+    suspend fun isShowNotificationsOnLockScreenEnabled(): StateFlow<Boolean> =
         secureSettingsRepository
             .intSetting(
                 name = Settings.Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS,
             )
             .map { it == 1 }
+            .flowOn(backgroundDispatcher)
             .stateIn(
                 scope = scope,
-                started = SharingStarted.WhileSubscribed(),
-                initialValue = false,
             )
 
     suspend fun setShowNotificationsOnLockscreenEnabled(enabled: Boolean) {
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/domain/interactor/NotificationSettingsInteractor.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/domain/interactor/NotificationSettingsInteractor.kt
index 04e8090..b4105bd 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/domain/interactor/NotificationSettingsInteractor.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/notifications/domain/interactor/NotificationSettingsInteractor.kt
@@ -26,8 +26,8 @@
     val isNotificationHistoryEnabled = repository.isNotificationHistoryEnabled
 
     /** Should notifications be visible on the lockscreen? */
-    val isShowNotificationsOnLockScreenEnabled: StateFlow<Boolean> =
-        repository.isShowNotificationsOnLockScreenEnabled
+    suspend fun isShowNotificationsOnLockScreenEnabled(): StateFlow<Boolean> =
+        repository.isShowNotificationsOnLockScreenEnabled()
 
     suspend fun setShowNotificationsOnLockscreenEnabled(enabled: Boolean) {
         repository.setShowNotificationsOnLockscreenEnabled(enabled)
@@ -35,7 +35,7 @@
 
     /** Toggles the setting to show or hide notifications on the lock screen. */
     suspend fun toggleShowNotificationsOnLockscreenEnabled() {
-        val current = repository.isShowNotificationsOnLockScreenEnabled.value
+        val current = repository.isShowNotificationsOnLockScreenEnabled().value
         repository.setShowNotificationsOnLockscreenEnabled(!current)
     }
 }
diff --git a/packages/SystemUI/lint-baseline.xml b/packages/SystemUI/lint-baseline.xml
index 4def93f..2fd7f1b 100644
--- a/packages/SystemUI/lint-baseline.xml
+++ b/packages/SystemUI/lint-baseline.xml
@@ -27088,17 +27088,6 @@
 
     <issue
         id="UselessParent"
-        message="This `FrameLayout` layout or its `LinearLayout` parent is unnecessary"
-        errorLine1="    &lt;FrameLayout"
-        errorLine2="     ~~~~~~~~~~~">
-        <location
-            file="frameworks/base/packages/SystemUI/res/layout/media_output_list_item.xml"
-            line="24"
-            column="6"/>
-    </issue>
-
-    <issue
-        id="UselessParent"
         message="This `LinearLayout` layout or its `FrameLayout` parent is possibly unnecessary; transfer the `background` attribute to the other view"
         errorLine1="    &lt;LinearLayout"
         errorLine2="     ~~~~~~~~~~~~">
@@ -30587,17 +30576,6 @@
     <issue
         id="ContentDescription"
         message="Missing `contentDescription` attribute on image"
-        errorLine1="            &lt;ImageView"
-        errorLine2="             ~~~~~~~~~">
-        <location
-            file="frameworks/base/packages/SystemUI/res/layout/media_output_list_item.xml"
-            line="54"
-            column="14"/>
-    </issue>
-
-    <issue
-        id="ContentDescription"
-        message="Missing `contentDescription` attribute on image"
         errorLine1="    &lt;ImageView"
         errorLine2="     ~~~~~~~~~">
         <location
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
index a5acf72..ccddc9c 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt
@@ -38,7 +38,7 @@
 import com.android.systemui.flags.Flags
 import com.android.systemui.flags.fakeFeatureFlagsClassic
 import com.android.systemui.kosmos.testScope
-import com.android.systemui.scene.domain.interactor.sceneContainerStartable
+import com.android.systemui.scene.domain.startable.sceneContainerStartable
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.scene.shared.model.fakeSceneDataSource
 import com.android.systemui.testKosmos
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepositoryTest.kt
index 4587ea6..c5ba02d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepositoryTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepositoryTest.kt
@@ -528,6 +528,30 @@
     }
 
     @Test
+    fun userChange_isFingerprintEnrolledAndEnabledUpdated() =
+        testScope.runTest {
+            createBiometricSettingsRepository()
+            whenever(authController.isFingerprintEnrolled(ANOTHER_USER_ID)).thenReturn(false)
+            whenever(authController.isFingerprintEnrolled(PRIMARY_USER_ID)).thenReturn(true)
+
+            verify(biometricManager)
+                .registerEnabledOnKeyguardCallback(biometricManagerCallback.capture())
+            val isFingerprintEnrolledAndEnabled =
+                collectLastValue(underTest.isFingerprintEnrolledAndEnabled)
+            biometricManagerCallback.value.onChanged(true, ANOTHER_USER_ID)
+            runCurrent()
+            userRepository.setSelectedUserInfo(ANOTHER_USER)
+            runCurrent()
+            assertThat(isFingerprintEnrolledAndEnabled()).isFalse()
+
+            biometricManagerCallback.value.onChanged(true, PRIMARY_USER_ID)
+            runCurrent()
+            userRepository.setSelectedUserInfo(PRIMARY_USER)
+            runCurrent()
+            assertThat(isFingerprintEnrolledAndEnabled()).isTrue()
+        }
+
+    @Test
     fun userChange_biometricEnabledChange_handlesRaceCondition() =
         testScope.runTest {
             createBiometricSettingsRepository()
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/TrustRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/TrustRepositoryTest.kt
index 5f0f24d..2d12150 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/TrustRepositoryTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/TrustRepositoryTest.kt
@@ -30,6 +30,7 @@
 import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
@@ -61,7 +62,8 @@
     @Before
     fun setUp() {
         MockitoAnnotations.initMocks(this)
-        testScope = TestScope()
+        val testDispatcher = StandardTestDispatcher()
+        testScope = TestScope(testDispatcher)
         userRepository = FakeUserRepository()
         userRepository.setUserInfos(users)
         val logger =
@@ -69,7 +71,13 @@
                 LogBuffer("TestBuffer", 1, mock(LogcatEchoTracker::class.java), false)
             )
         underTest =
-            TrustRepositoryImpl(testScope.backgroundScope, userRepository, trustManager, logger)
+            TrustRepositoryImpl(
+                testScope.backgroundScope,
+                testDispatcher,
+                userRepository,
+                trustManager,
+                logger,
+            )
     }
 
     fun TestScope.init() {
@@ -275,4 +283,11 @@
             userRepository.setSelectedUserInfo(users[1])
             assertThat(trustUsuallyManaged).isFalse()
         }
+
+    @Test
+    fun reportKeyguardShowingChanged() =
+        testScope.runTest {
+            underTest.reportKeyguardShowingChanged()
+            verify(trustManager).reportKeyguardShowingChanged()
+        }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModelTest.kt
index 3777e40..6f74ed3 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModelTest.kt
@@ -16,98 +16,108 @@
 
 package com.android.systemui.keyguard.ui.viewmodel
 
-import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.flag.junit.FlagsParameterization
 import androidx.test.filters.SmallTest
-import com.android.systemui.Flags as AConfigFlags
+import com.android.compose.animation.scene.ObservableTransitionState
+import com.android.systemui.Flags.FLAG_KEYGUARD_BOTTOM_AREA_REFACTOR
+import com.android.systemui.Flags.FLAG_MIGRATE_CLOCKS_TO_BLUEPRINT
 import com.android.systemui.SysuiTestCase
-import com.android.systemui.common.ui.data.repository.FakeConfigurationRepository
-import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor
+import com.android.systemui.common.ui.domain.interactor.configurationInteractor
+import com.android.systemui.communal.data.repository.fakeCommunalSceneRepository
+import com.android.systemui.communal.domain.interactor.communalSceneInteractor
+import com.android.systemui.communal.shared.model.CommunalScenes
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.doze.util.BurnInHelperWrapper
-import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository
+import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
 import com.android.systemui.keyguard.domain.interactor.BurnInInteractor
-import com.android.systemui.keyguard.domain.interactor.KeyguardBottomAreaInteractor
-import com.android.systemui.keyguard.domain.interactor.KeyguardInteractorFactory
+import com.android.systemui.keyguard.domain.interactor.keyguardBottomAreaInteractor
+import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
 import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
 import com.android.systemui.keyguard.shared.model.BurnInModel
+import com.android.systemui.keyguard.shared.model.StatusBarState
 import com.android.systemui.keyguard.shared.quickaffordance.KeyguardQuickAffordancePosition
 import com.android.systemui.kosmos.testDispatcher
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.testKosmos
-import com.android.systemui.util.mockito.any
-import com.android.systemui.util.mockito.mock
-import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.test.runTest
 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.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4
+import platform.test.runner.parameterized.Parameters
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @SmallTest
-@RunWith(AndroidJUnit4::class)
-class KeyguardIndicationAreaViewModelTest : SysuiTestCase() {
+@RunWith(ParameterizedAndroidJunit4::class)
+class KeyguardIndicationAreaViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
     private val kosmos = testKosmos()
     private val testScope = kosmos.testScope
 
-    @Mock private lateinit var burnInHelperWrapper: BurnInHelperWrapper
-    @Mock private lateinit var shortcutsCombinedViewModel: KeyguardQuickAffordancesCombinedViewModel
-
-    @Mock private lateinit var burnInInteractor: BurnInInteractor
-    private val burnInFlow = MutableStateFlow(BurnInModel())
-
-    private lateinit var bottomAreaInteractor: KeyguardBottomAreaInteractor
+    private val bottomAreaInteractor = kosmos.keyguardBottomAreaInteractor
     private lateinit var underTest: KeyguardIndicationAreaViewModel
-    private lateinit var repository: FakeKeyguardRepository
+    private val keyguardRepository = kosmos.fakeKeyguardRepository
+    private val communalSceneRepository = kosmos.fakeCommunalSceneRepository
 
     private val startButtonFlow =
-        MutableStateFlow<KeyguardQuickAffordanceViewModel>(
+        MutableStateFlow(
             KeyguardQuickAffordanceViewModel(
                 slotId = KeyguardQuickAffordancePosition.BOTTOM_START.toSlotId()
             )
         )
     private val endButtonFlow =
-        MutableStateFlow<KeyguardQuickAffordanceViewModel>(
+        MutableStateFlow(
             KeyguardQuickAffordanceViewModel(
                 slotId = KeyguardQuickAffordancePosition.BOTTOM_END.toSlotId()
             )
         )
-    private val alphaFlow = MutableStateFlow<Float>(1f)
+    private val alphaFlow = MutableStateFlow(1f)
+
+    init {
+        mSetFlagsRule.setFlagsParameterization(flags)
+    }
 
     @Before
     fun setUp() {
-        MockitoAnnotations.initMocks(this)
-
-        mSetFlagsRule.disableFlags(AConfigFlags.FLAG_KEYGUARD_BOTTOM_AREA_REFACTOR)
-        mSetFlagsRule.disableFlags(AConfigFlags.FLAG_MIGRATE_CLOCKS_TO_BLUEPRINT)
-
-        whenever(burnInHelperWrapper.burnInOffset(anyInt(), any()))
-            .thenReturn(RETURNED_BURN_IN_OFFSET)
-        whenever(burnInInteractor.burnIn(anyInt(), anyInt())).thenReturn(burnInFlow)
-
-        val withDeps = KeyguardInteractorFactory.create()
-        val keyguardInteractor = withDeps.keyguardInteractor
-        repository = withDeps.repository
-
-        val bottomAreaViewModel: KeyguardBottomAreaViewModel = mock()
-        whenever(bottomAreaViewModel.startButton).thenReturn(startButtonFlow)
-        whenever(bottomAreaViewModel.endButton).thenReturn(endButtonFlow)
-        whenever(bottomAreaViewModel.alpha).thenReturn(alphaFlow)
-        bottomAreaInteractor = KeyguardBottomAreaInteractor(repository = repository)
+        val bottomAreaViewModel =
+            mock<KeyguardBottomAreaViewModel> {
+                on { startButton } doReturn startButtonFlow
+                on { endButton } doReturn endButtonFlow
+                on { alpha } doReturn alphaFlow
+            }
+        val burnInInteractor =
+            mock<BurnInInteractor> {
+                on { burnIn(anyInt(), anyInt()) } doReturn flowOf(BurnInModel())
+            }
+        val burnInHelperWrapper =
+            mock<BurnInHelperWrapper> {
+                on { burnInOffset(anyInt(), any()) } doReturn RETURNED_BURN_IN_OFFSET
+            }
+        val shortcutsCombinedViewModel =
+            mock<KeyguardQuickAffordancesCombinedViewModel> {
+                on { startButton } doReturn startButtonFlow
+                on { endButton } doReturn endButtonFlow
+            }
         underTest =
             KeyguardIndicationAreaViewModel(
-                keyguardInteractor = keyguardInteractor,
+                keyguardInteractor = kosmos.keyguardInteractor,
                 bottomAreaInteractor = bottomAreaInteractor,
                 keyguardBottomAreaViewModel = bottomAreaViewModel,
                 burnInHelperWrapper = burnInHelperWrapper,
                 burnInInteractor = burnInInteractor,
                 shortcutsCombinedViewModel = shortcutsCombinedViewModel,
-                configurationInteractor = ConfigurationInteractor(FakeConfigurationRepository()),
+                configurationInteractor = kosmos.configurationInteractor,
                 keyguardTransitionInteractor = kosmos.keyguardTransitionInteractor,
-                backgroundCoroutineContext = kosmos.testDispatcher,
+                backgroundDispatcher = kosmos.testDispatcher,
+                communalSceneInteractor = kosmos.communalSceneInteractor,
                 mainDispatcher = kosmos.testDispatcher
             )
     }
@@ -115,77 +125,120 @@
     @Test
     fun alpha() =
         testScope.runTest {
-            val value = collectLastValue(underTest.alpha)
+            val alpha by collectLastValue(underTest.alpha)
 
-            assertThat(value()).isEqualTo(1f)
+            assertThat(alpha).isEqualTo(1f)
             alphaFlow.value = 0.1f
-            assertThat(value()).isEqualTo(0.1f)
+            assertThat(alpha).isEqualTo(0.1f)
             alphaFlow.value = 0.5f
-            assertThat(value()).isEqualTo(0.5f)
+            assertThat(alpha).isEqualTo(0.5f)
             alphaFlow.value = 0.2f
-            assertThat(value()).isEqualTo(0.2f)
+            assertThat(alpha).isEqualTo(0.2f)
             alphaFlow.value = 0f
-            assertThat(value()).isEqualTo(0f)
+            assertThat(alpha).isEqualTo(0f)
         }
 
     @Test
+    @DisableFlags(FLAG_KEYGUARD_BOTTOM_AREA_REFACTOR)
     fun isIndicationAreaPadded() =
         testScope.runTest {
-            repository.setKeyguardShowing(true)
-            val value = collectLastValue(underTest.isIndicationAreaPadded)
+            keyguardRepository.setKeyguardShowing(true)
+            val isIndicationAreaPadded by collectLastValue(underTest.isIndicationAreaPadded)
 
-            assertThat(value()).isFalse()
+            assertThat(isIndicationAreaPadded).isFalse()
             startButtonFlow.value = startButtonFlow.value.copy(isVisible = true)
-            assertThat(value()).isTrue()
+            assertThat(isIndicationAreaPadded).isTrue()
             endButtonFlow.value = endButtonFlow.value.copy(isVisible = true)
-            assertThat(value()).isTrue()
+            assertThat(isIndicationAreaPadded).isTrue()
             startButtonFlow.value = startButtonFlow.value.copy(isVisible = false)
-            assertThat(value()).isTrue()
+            assertThat(isIndicationAreaPadded).isTrue()
             endButtonFlow.value = endButtonFlow.value.copy(isVisible = false)
-            assertThat(value()).isFalse()
+            assertThat(isIndicationAreaPadded).isFalse()
         }
 
     @Test
+    @DisableFlags(FLAG_MIGRATE_CLOCKS_TO_BLUEPRINT, FLAG_KEYGUARD_BOTTOM_AREA_REFACTOR)
     fun indicationAreaTranslationX() =
         testScope.runTest {
-            val value = collectLastValue(underTest.indicationAreaTranslationX)
+            val translationX by collectLastValue(underTest.indicationAreaTranslationX)
 
-            assertThat(value()).isEqualTo(0f)
+            assertThat(translationX).isEqualTo(0f)
             bottomAreaInteractor.setClockPosition(100, 100)
-            assertThat(value()).isEqualTo(100f)
+            assertThat(translationX).isEqualTo(100f)
             bottomAreaInteractor.setClockPosition(200, 100)
-            assertThat(value()).isEqualTo(200f)
+            assertThat(translationX).isEqualTo(200f)
             bottomAreaInteractor.setClockPosition(200, 200)
-            assertThat(value()).isEqualTo(200f)
+            assertThat(translationX).isEqualTo(200f)
             bottomAreaInteractor.setClockPosition(300, 100)
-            assertThat(value()).isEqualTo(300f)
+            assertThat(translationX).isEqualTo(300f)
         }
 
     @Test
+    @DisableFlags(FLAG_MIGRATE_CLOCKS_TO_BLUEPRINT)
     fun indicationAreaTranslationY() =
         testScope.runTest {
-            val value =
+            val translationY by
                 collectLastValue(underTest.indicationAreaTranslationY(DEFAULT_BURN_IN_OFFSET))
 
             // Negative 0 - apparently there's a difference in floating point arithmetic - FML
-            assertThat(value()).isEqualTo(-0f)
+            assertThat(translationY).isEqualTo(-0f)
             val expected1 = setDozeAmountAndCalculateExpectedTranslationY(0.1f)
-            assertThat(value()).isEqualTo(expected1)
+            assertThat(translationY).isEqualTo(expected1)
             val expected2 = setDozeAmountAndCalculateExpectedTranslationY(0.2f)
-            assertThat(value()).isEqualTo(expected2)
+            assertThat(translationY).isEqualTo(expected2)
             val expected3 = setDozeAmountAndCalculateExpectedTranslationY(0.5f)
-            assertThat(value()).isEqualTo(expected3)
+            assertThat(translationY).isEqualTo(expected3)
             val expected4 = setDozeAmountAndCalculateExpectedTranslationY(1f)
-            assertThat(value()).isEqualTo(expected4)
+            assertThat(translationY).isEqualTo(expected4)
+        }
+
+    @Test
+    fun visibilityWhenCommunalNotShowing() =
+        testScope.runTest {
+            keyguardRepository.setStatusBarState(StatusBarState.KEYGUARD)
+            val visible by collectLastValue(underTest.visible)
+
+            assertThat(visible).isTrue()
+            keyguardRepository.setStatusBarState(StatusBarState.SHADE)
+            assertThat(visible).isFalse()
+        }
+
+    @Test
+    fun visibilityWhenCommunalShowing() =
+        testScope.runTest {
+            keyguardRepository.setStatusBarState(StatusBarState.KEYGUARD)
+            communalSceneRepository.setTransitionState(
+                flowOf(ObservableTransitionState.Idle(CommunalScenes.Communal))
+            )
+
+            val visible by collectLastValue(underTest.visible)
+
+            assertThat(visible).isTrue()
+            keyguardRepository.setStatusBarState(StatusBarState.SHADE)
+            assertThat(visible).isTrue()
+
+            communalSceneRepository.setTransitionState(
+                flowOf(ObservableTransitionState.Idle(CommunalScenes.Blank))
+            )
+            assertThat(visible).isFalse()
         }
 
     private fun setDozeAmountAndCalculateExpectedTranslationY(dozeAmount: Float): Float {
-        repository.setDozeAmount(dozeAmount)
+        keyguardRepository.setDozeAmount(dozeAmount)
         return dozeAmount * (RETURNED_BURN_IN_OFFSET - DEFAULT_BURN_IN_OFFSET)
     }
 
     companion object {
         private const val DEFAULT_BURN_IN_OFFSET = 5
         private const val RETURNED_BURN_IN_OFFSET = 3
+
+        @JvmStatic
+        @Parameters(name = "{0}")
+        fun getParams(): List<FlagsParameterization> {
+            return FlagsParameterization.allCombinationsOf(
+                FLAG_KEYGUARD_BOTTOM_AREA_REFACTOR,
+                FLAG_MIGRATE_CLOCKS_TO_BLUEPRINT,
+            )
+        }
     }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepositoryTest.kt
new file mode 100644
index 0000000..14d6094
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepositoryTest.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.qs.panels.data.repository
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.res.R
+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 PaginatedGridRepositoryTest : SysuiTestCase() {
+    private val kosmos = testKosmos()
+
+    val underTest = kosmos.paginatedGridRepository
+
+    @Test
+    fun rows_followsConfig() =
+        with(kosmos) {
+            testScope.runTest {
+                val rows by collectLastValue(underTest.rows)
+
+                setRowsInConfig(3)
+                assertThat(rows).isEqualTo(3)
+
+                setRowsInConfig(6)
+                assertThat(rows).isEqualTo(6)
+            }
+        }
+
+    private fun setRowsInConfig(rows: Int) =
+        with(kosmos) {
+            testCase.context.orCreateTestableResources.addOverride(
+                R.integer.quick_settings_max_rows,
+                rows,
+            )
+            fakeConfigurationRepository.onConfigurationChange()
+        }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayoutTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayoutTest.kt
new file mode 100644
index 0000000..914a095
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayoutTest.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.qs.panels.ui.compose
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.qs.panels.data.repository.IconTilesRepository
+import com.android.systemui.qs.panels.data.repository.iconTilesRepository
+import com.android.systemui.qs.panels.ui.viewmodel.MockTileViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.fixedColumnsSizeViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.iconTilesViewModel
+import com.android.systemui.qs.pipeline.shared.TileSpec
+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 InfiniteGridLayoutTest : SysuiTestCase() {
+    private val kosmos =
+        testKosmos().apply {
+            iconTilesRepository =
+                object : IconTilesRepository {
+                    override fun isIconTile(spec: TileSpec): Boolean {
+                        return spec.spec.startsWith("small")
+                    }
+                }
+        }
+
+    private val underTest =
+        with(kosmos) {
+            InfiniteGridLayout(
+                iconTilesViewModel,
+                fixedColumnsSizeViewModel,
+            )
+        }
+
+    @Test
+    fun correctPagination_underOnePage_sameOrder() =
+        with(kosmos) {
+            testScope.runTest {
+                val rows = 3
+                val columns = 4
+
+                val tiles =
+                    listOf(
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                        largeTile(),
+                        smallTile()
+                    )
+
+                val pages = underTest.splitIntoPages(tiles, rows = rows, columns = columns)
+
+                assertThat(pages).hasSize(1)
+                assertThat(pages[0]).isEqualTo(tiles)
+            }
+        }
+
+    @Test
+    fun correctPagination_twoPages_sameOrder() =
+        with(kosmos) {
+            testScope.runTest {
+                val rows = 3
+                val columns = 4
+
+                val tiles =
+                    listOf(
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                    )
+                // --- Page 1 ---
+                // [L L] [S] [S]
+                // [L L] [L L]
+                // [S] [S] [L L]
+                // --- Page 2 ---
+                // [L L] [S] [S]
+                // [L L]
+
+                val pages = underTest.splitIntoPages(tiles, rows = rows, columns = columns)
+
+                assertThat(pages).hasSize(2)
+                assertThat(pages[0]).isEqualTo(tiles.take(8))
+                assertThat(pages[1]).isEqualTo(tiles.drop(8))
+            }
+        }
+
+    companion object {
+        fun largeTile() = MockTileViewModel(TileSpec.create("large"))
+
+        fun smallTile() = MockTileViewModel(TileSpec.create("small"))
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/PaginatableGridLayoutTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/PaginatableGridLayoutTest.kt
new file mode 100644
index 0000000..6df3f8d
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/PaginatableGridLayoutTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.qs.panels.ui.compose
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.qs.panels.shared.model.SizedTile
+import com.android.systemui.qs.panels.ui.viewmodel.MockTileViewModel
+import com.android.systemui.qs.pipeline.shared.TileSpec
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PaginatableGridLayoutTest : SysuiTestCase() {
+    @Test
+    fun correctRows_gapsAtEnd() {
+        val columns = 6
+
+        val sizedTiles =
+            listOf(
+                largeTile(),
+                extraLargeTile(),
+                largeTile(),
+                smallTile(),
+                largeTile(),
+            )
+
+        // [L L] [XL XL XL]
+        // [L L] [S] [L L]
+
+        val rows = PaginatableGridLayout.splitInRows(sizedTiles, columns)
+
+        assertThat(rows).hasSize(2)
+        assertThat(rows[0]).isEqualTo(sizedTiles.take(2))
+        assertThat(rows[1]).isEqualTo(sizedTiles.drop(2))
+    }
+
+    @Test
+    fun correctRows_fullLastRow_noEmptyRow() {
+        val columns = 6
+
+        val sizedTiles =
+            listOf(
+                largeTile(),
+                extraLargeTile(),
+                smallTile(),
+            )
+
+        // [L L] [XL XL XL] [S]
+
+        val rows = PaginatableGridLayout.splitInRows(sizedTiles, columns)
+
+        assertThat(rows).hasSize(1)
+        assertThat(rows[0]).isEqualTo(sizedTiles)
+    }
+
+    companion object {
+        fun extraLargeTile() = SizedTile(MockTileViewModel(TileSpec.create("XLarge")), 3)
+
+        fun largeTile() = SizedTile(MockTileViewModel(TileSpec.create("large")), 2)
+
+        fun smallTile() = SizedTile(MockTileViewModel(TileSpec.create("small")), 1)
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayoutTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayoutTest.kt
new file mode 100644
index 0000000..3354b4d4
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayoutTest.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.qs.panels.ui.compose
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.qs.panels.data.repository.IconTilesRepository
+import com.android.systemui.qs.panels.data.repository.iconTilesRepository
+import com.android.systemui.qs.panels.ui.viewmodel.MockTileViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.iconTilesViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.partitionedGridViewModel
+import com.android.systemui.qs.pipeline.shared.TileSpec
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PartitionedGridLayoutTest : SysuiTestCase() {
+    private val kosmos =
+        testKosmos().apply {
+            iconTilesRepository =
+                object : IconTilesRepository {
+                    override fun isIconTile(spec: TileSpec): Boolean {
+                        return spec.spec.startsWith("small")
+                    }
+                }
+        }
+
+    private val underTest = with(kosmos) { PartitionedGridLayout(partitionedGridViewModel) }
+
+    @Test
+    fun correctPagination_underOnePage_partitioned_sameRelativeOrder() =
+        with(kosmos) {
+            testScope.runTest {
+                val rows = 3
+                val columns = 4
+
+                val tiles =
+                    listOf(
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                        largeTile(),
+                        smallTile()
+                    )
+                val (smallTiles, largeTiles) =
+                    tiles.partition { iconTilesViewModel.isIconTile(it.spec) }
+
+                // [L L] [L L]
+                // [L L]
+                // [S] [S] [S]
+
+                val pages = underTest.splitIntoPages(tiles, rows = rows, columns = columns)
+
+                Truth.assertThat(pages).hasSize(1)
+                Truth.assertThat(pages[0]).isEqualTo(largeTiles + smallTiles)
+            }
+        }
+
+    @Test
+    fun correctPagination_twoPages_partitioned_sameRelativeOrder() =
+        with(kosmos) {
+            testScope.runTest {
+                val rows = 3
+                val columns = 4
+
+                val tiles =
+                    listOf(
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                        largeTile(),
+                        smallTile(),
+                        smallTile(),
+                    )
+                // --- Page 1 ---
+                // [L L] [L L]
+                // [L L]
+                // [S] [S] [S] [S]
+                // --- Page 2 ---
+                // [S] [S]
+
+                val (smallTiles, largeTiles) =
+                    tiles.partition { iconTilesViewModel.isIconTile(it.spec) }
+
+                val pages = underTest.splitIntoPages(tiles, rows = rows, columns = columns)
+
+                val expectedPage0 = largeTiles + smallTiles.take(4)
+                val expectedPage1 = smallTiles.drop(4)
+
+                Truth.assertThat(pages).hasSize(2)
+                Truth.assertThat(pages[0]).isEqualTo(expectedPage0)
+                Truth.assertThat(pages[1]).isEqualTo(expectedPage1)
+            }
+        }
+
+    companion object {
+        fun largeTile() = MockTileViewModel(TileSpec.create("large"))
+
+        fun smallTile() = MockTileViewModel(TileSpec.create("small"))
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingDataInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingDataInteractorTest.kt
new file mode 100644
index 0000000..2194c75
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingDataInteractorTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.qs.tiles.impl.irecording
+
+import android.os.UserHandle
+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.Kosmos
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
+import com.android.systemui.recordissue.IssueRecordingState
+import com.android.systemui.settings.fakeUserFileManager
+import com.android.systemui.settings.userTracker
+import com.google.common.truth.Truth
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class IssueRecordingDataInteractorTest : SysuiTestCase() {
+
+    private val kosmos = Kosmos().also { it.testCase = this }
+    private val userTracker = kosmos.userTracker
+    private val userFileManager = kosmos.fakeUserFileManager
+    private val testUser = UserHandle.of(1)
+
+    lateinit var state: IssueRecordingState
+    private lateinit var underTest: IssueRecordingDataInteractor
+
+    @Before
+    fun setup() {
+        state = IssueRecordingState(userTracker, userFileManager)
+        underTest = IssueRecordingDataInteractor(state, kosmos.testScope.testScheduler)
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun emitsEvent_whenIsRecordingStatusChanges_correctly() {
+        kosmos.testScope.runTest {
+            val data by
+                collectLastValue(
+                    underTest.tileData(testUser, flowOf(DataUpdateTrigger.InitialRequest))
+                )
+            runCurrent()
+            Truth.assertThat(data?.isRecording).isFalse()
+
+            state.isRecording = true
+            runCurrent()
+            Truth.assertThat(data?.isRecording).isTrue()
+
+            state.isRecording = false
+            runCurrent()
+            Truth.assertThat(data?.isRecording).isFalse()
+        }
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingMapperTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingMapperTest.kt
new file mode 100644
index 0000000..2444229
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingMapperTest.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.qs.tiles.impl.irecording
+
+import android.content.res.mainResources
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.qs.pipeline.shared.TileSpec
+import com.android.systemui.qs.qsEventLogger
+import com.android.systemui.qs.tiles.viewmodel.QSTileConfig
+import com.android.systemui.qs.tiles.viewmodel.QSTileState
+import com.android.systemui.qs.tiles.viewmodel.QSTileUIConfig
+import com.android.systemui.recordissue.RecordIssueModule
+import com.android.systemui.res.R
+import com.google.common.truth.Truth
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class IssueRecordingMapperTest : SysuiTestCase() {
+    private val kosmos = Kosmos().also { it.testCase = this }
+    private val uiConfig =
+        QSTileUIConfig.Resource(R.drawable.qs_record_issue_icon_off, R.string.qs_record_issue_label)
+    private val config =
+        QSTileConfig(
+            TileSpec.create(RecordIssueModule.TILE_SPEC),
+            uiConfig,
+            kosmos.qsEventLogger.getNewInstanceId()
+        )
+    private val resources = kosmos.mainResources
+    private val theme = resources.newTheme()
+
+    @Test
+    fun whenData_isRecording_useCorrectResources() {
+        val underTest = IssueRecordingMapper(resources, theme)
+        val tileState = underTest.map(config, IssueRecordingModel(true))
+        Truth.assertThat(tileState.activationState).isEqualTo(QSTileState.ActivationState.ACTIVE)
+    }
+
+    @Test
+    fun whenData_isNotRecording_useCorrectResources() {
+        val underTest = IssueRecordingMapper(resources, theme)
+        val tileState = underTest.map(config, IssueRecordingModel(false))
+        Truth.assertThat(tileState.activationState).isEqualTo(QSTileState.ActivationState.INACTIVE)
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingUserActionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingUserActionInteractorTest.kt
new file mode 100644
index 0000000..4e58069
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingUserActionInteractorTest.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.qs.tiles.impl.irecording
+
+import android.os.UserHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.animation.dialogTransitionAnimator
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.plugins.activityStarter
+import com.android.systemui.plugins.statusbar.statusBarStateController
+import com.android.systemui.qs.pipeline.domain.interactor.panelInteractor
+import com.android.systemui.qs.tiles.base.interactor.QSTileInput
+import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction
+import com.android.systemui.recordissue.RecordIssueDialogDelegate
+import com.android.systemui.settings.UserContextProvider
+import com.android.systemui.settings.userTracker
+import com.android.systemui.statusbar.phone.KeyguardDismissUtil
+import com.android.systemui.statusbar.policy.keyguardStateController
+import com.google.common.truth.Truth
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class IssueRecordingUserActionInteractorTest : SysuiTestCase() {
+
+    val user = UserHandle(1)
+    val kosmos = Kosmos().also { it.testCase = this }
+
+    private lateinit var userContextProvider: UserContextProvider
+    private lateinit var underTest: IssueRecordingUserActionInteractor
+
+    private var hasCreatedDialogDelegate: Boolean = false
+
+    @Before
+    fun setup() {
+        hasCreatedDialogDelegate = false
+        with(kosmos) {
+            val factory =
+                object : RecordIssueDialogDelegate.Factory {
+                    override fun create(onStarted: Runnable): RecordIssueDialogDelegate {
+                        hasCreatedDialogDelegate = true
+
+                        // Inside some tests in presubmit, createDialog throws an error because
+                        // the test thread's looper hasn't been prepared, and Dialog.class
+                        // internally is creating a new handler. For testing, we only care that the
+                        // dialog is created, so using a mock is acceptable here.
+                        return mock(RecordIssueDialogDelegate::class.java)
+                    }
+                }
+
+            userContextProvider = userTracker
+            underTest =
+                IssueRecordingUserActionInteractor(
+                    testDispatcher,
+                    KeyguardDismissUtil(
+                        keyguardStateController,
+                        statusBarStateController,
+                        activityStarter
+                    ),
+                    keyguardStateController,
+                    dialogTransitionAnimator,
+                    panelInteractor,
+                    userTracker,
+                    factory
+                )
+        }
+    }
+
+    @Test
+    fun handleInput_showsPromptToStartRecording_whenNotRecordingAlready() {
+        kosmos.testScope.runTest {
+            underTest.handleInput(
+                QSTileInput(user, QSTileUserAction.Click(null), IssueRecordingModel(false))
+            )
+            Truth.assertThat(hasCreatedDialogDelegate).isTrue()
+        }
+    }
+
+    @Test
+    fun handleInput_attemptsToStopRecording_whenRecording() {
+        kosmos.testScope.runTest {
+            val input = QSTileInput(user, QSTileUserAction.Click(null), IssueRecordingModel(true))
+            try {
+                underTest.handleInput(input)
+            } catch (e: NullPointerException) {
+                // As of 06/07/2024, PendingIntent.startService is not easily mockable and throws
+                // an NPE inside IActivityManager. Catching that here and ignore it, then verify
+                // mock interactions were done correctly
+            }
+            Truth.assertThat(hasCreatedDialogDelegate).isFalse()
+        }
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt
index 5b6fea5..d43d50a 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt
@@ -42,9 +42,9 @@
 import com.android.systemui.qs.ui.adapter.FakeQSSceneAdapter
 import com.android.systemui.res.R
 import com.android.systemui.scene.domain.interactor.sceneBackInteractor
-import com.android.systemui.scene.domain.interactor.sceneContainerStartable
 import com.android.systemui.scene.domain.interactor.sceneInteractor
 import com.android.systemui.scene.domain.resolver.homeSceneFamilyResolver
+import com.android.systemui.scene.domain.startable.sceneContainerStartable
 import com.android.systemui.scene.shared.model.SceneFamilies
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.settings.brightness.ui.viewmodel.brightnessMirrorViewModel
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
index 4d5d22c..412505d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
@@ -58,9 +58,9 @@
 import com.android.systemui.qs.footerActionsController
 import com.android.systemui.qs.footerActionsViewModelFactory
 import com.android.systemui.qs.ui.adapter.FakeQSSceneAdapter
-import com.android.systemui.scene.domain.interactor.sceneContainerStartable
 import com.android.systemui.scene.domain.interactor.sceneInteractor
 import com.android.systemui.scene.domain.resolver.homeSceneFamilyResolver
+import com.android.systemui.scene.domain.startable.sceneContainerStartable
 import com.android.systemui.scene.shared.model.SceneFamilies
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.scene.shared.model.fakeSceneDataSource
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/interactor/SceneBackInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/interactor/SceneBackInteractorTest.kt
index e3108ad..1f3454d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/interactor/SceneBackInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/interactor/SceneBackInteractorTest.kt
@@ -28,6 +28,7 @@
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.flags.EnableSceneContainer
 import com.android.systemui.kosmos.testScope
+import com.android.systemui.scene.domain.startable.sceneContainerStartable
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartableTest.kt
new file mode 100644
index 0000000..695edaf
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartableTest.kt
@@ -0,0 +1,248 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.scene.domain.startable
+
+import android.content.pm.UserInfo
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.compose.animation.scene.ObservableTransitionState
+import com.android.internal.policy.IKeyguardStateCallback
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.flags.EnableSceneContainer
+import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository
+import com.android.systemui.keyguard.data.repository.fakeTrustRepository
+import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.scene.data.repository.setSceneTransition
+import com.android.systemui.scene.domain.interactor.sceneInteractor
+import com.android.systemui.scene.shared.model.Scenes
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fakeMobileConnectionsRepository
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.fakeUserRepository
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@EnableSceneContainer
+class KeyguardStateCallbackStartableTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+
+    private val underTest = kosmos.keyguardStateCallbackStartable
+
+    @Test
+    fun addCallback_hydratesAllWithCurrentState() =
+        testScope.runTest {
+            val testState = setUpTest()
+            val callback = mockCallback()
+
+            underTest.addCallback(callback)
+            runCurrent()
+
+            with(testState) {
+                val captor = argumentCaptor<Boolean>()
+                verify(callback, atLeastOnce()).onShowingStateChanged(captor.capture(), eq(userId))
+                assertThat(captor.lastValue).isEqualTo(isKeyguardShowing)
+                verify(callback, atLeastOnce()).onInputRestrictedStateChanged(captor.capture())
+                assertThat(captor.lastValue).isEqualTo(isInputRestricted)
+                verify(callback, atLeastOnce()).onSimSecureStateChanged(captor.capture())
+                assertThat(captor.lastValue).isEqualTo(isSimSecure)
+                verify(callback, atLeastOnce()).onTrustedChanged(captor.capture())
+                assertThat(captor.lastValue).isEqualTo(isTrusted)
+            }
+        }
+
+    @Test
+    fun hydrateKeyguardShowingState() =
+        testScope.runTest {
+            setUpTest(isKeyguardShowing = true)
+            val callback = mockCallback()
+            underTest.addCallback(callback)
+            runCurrent()
+            verify(callback, atLeastOnce()).onShowingStateChanged(eq(true), anyInt())
+
+            unlockDevice()
+            runCurrent()
+
+            verify(callback).onShowingStateChanged(eq(false), anyInt())
+        }
+
+    @Test
+    fun hydrateInputRestrictedState() =
+        testScope.runTest {
+            setUpTest(isKeyguardShowing = true)
+            val callback = mockCallback()
+            underTest.addCallback(callback)
+            runCurrent()
+            val captor = argumentCaptor<Boolean>()
+            verify(callback, atLeastOnce()).onInputRestrictedStateChanged(captor.capture())
+            assertThat(captor.lastValue).isTrue()
+
+            unlockDevice()
+            runCurrent()
+
+            verify(callback, atLeastOnce()).onInputRestrictedStateChanged(captor.capture())
+            assertThat(captor.lastValue).isFalse()
+        }
+
+    @Test
+    fun hydrateSimSecureState() =
+        testScope.runTest {
+            setUpTest(isSimSecure = false)
+            val callback = mockCallback()
+            underTest.addCallback(callback)
+            runCurrent()
+            val captor = argumentCaptor<Boolean>()
+            verify(callback, atLeastOnce()).onSimSecureStateChanged(captor.capture())
+            assertThat(captor.lastValue).isFalse()
+
+            kosmos.fakeMobileConnectionsRepository.isAnySimSecure.value = true
+            runCurrent()
+
+            verify(callback, atLeastOnce()).onSimSecureStateChanged(captor.capture())
+            assertThat(captor.lastValue).isTrue()
+        }
+
+    @Test
+    fun notifyWhenKeyguardShowingChanged() =
+        testScope.runTest {
+            setUpTest(isKeyguardShowing = true)
+            val callback = mockCallback()
+            underTest.addCallback(callback)
+            runCurrent()
+            assertThat(kosmos.fakeTrustRepository.keyguardShowingChangeEventCount).isEqualTo(1)
+
+            unlockDevice()
+            runCurrent()
+
+            assertThat(kosmos.fakeTrustRepository.keyguardShowingChangeEventCount).isEqualTo(2)
+        }
+
+    @Test
+    fun notifyWhenTrustChanged() =
+        testScope.runTest {
+            setUpTest(isTrusted = false)
+            val callback = mockCallback()
+            underTest.addCallback(callback)
+            runCurrent()
+            val captor = argumentCaptor<Boolean>()
+            verify(callback, atLeastOnce()).onTrustedChanged(captor.capture())
+            assertThat(captor.lastValue).isFalse()
+
+            kosmos.fakeTrustRepository.setCurrentUserTrusted(true)
+            runCurrent()
+
+            verify(callback, atLeastOnce()).onTrustedChanged(captor.capture())
+            assertThat(captor.lastValue).isTrue()
+        }
+
+    private suspend fun TestScope.setUpTest(
+        isKeyguardShowing: Boolean = true,
+        userId: Int = selectedUser.id,
+        isInputRestricted: Boolean = true,
+        isSimSecure: Boolean = false,
+        isTrusted: Boolean = false,
+    ): TestState {
+        val testState =
+            TestState(
+                isKeyguardShowing = isKeyguardShowing,
+                userId = userId,
+                isInputRestricted = isInputRestricted,
+                isSimSecure = isSimSecure,
+                isTrusted = isTrusted,
+            )
+
+        if (isKeyguardShowing) {
+            lockDevice()
+        } else {
+            unlockDevice()
+        }
+
+        kosmos.fakeUserRepository.setUserInfos(listOf(selectedUser))
+        kosmos.fakeUserRepository.setSelectedUserInfo(selectedUser)
+
+        if (isInputRestricted && !isKeyguardShowing) {
+            // TODO(b/348644111): add support for mNeedToReshowWhenReenabled
+        } else if (!isInputRestricted) {
+            assertWithMessage(
+                    "If isInputRestricted is false, isKeyguardShowing must also be false!"
+                )
+                .that(isKeyguardShowing)
+                .isFalse()
+        }
+
+        kosmos.fakeMobileConnectionsRepository.isAnySimSecure.value = isSimSecure
+
+        kosmos.fakeTrustRepository.setCurrentUserTrusted(isTrusted)
+
+        runCurrent()
+
+        underTest.start()
+
+        return testState
+    }
+
+    private fun lockDevice() {
+        kosmos.setSceneTransition(ObservableTransitionState.Idle(Scenes.Lockscreen))
+        kosmos.sceneInteractor.changeScene(Scenes.Lockscreen, "")
+    }
+
+    private fun unlockDevice() {
+        kosmos.fakeDeviceEntryFingerprintAuthRepository.setAuthenticationStatus(
+            SuccessFingerprintAuthenticationStatus(0, true)
+        )
+        kosmos.setSceneTransition(ObservableTransitionState.Idle(Scenes.Gone))
+        kosmos.sceneInteractor.changeScene(Scenes.Gone, "")
+    }
+
+    private fun mockCallback(): IKeyguardStateCallback {
+        return mock()
+    }
+
+    private data class TestState(
+        val isKeyguardShowing: Boolean,
+        val userId: Int,
+        val isInputRestricted: Boolean,
+        val isSimSecure: Boolean,
+        val isTrusted: Boolean,
+    )
+
+    companion object {
+        private val selectedUser =
+            UserInfo(
+                /* id= */ 100,
+                /* name= */ "First user",
+                /* flags= */ 0,
+            )
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt
index e40c8ee..9edc3af 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt
@@ -51,7 +51,6 @@
 import com.android.systemui.power.domain.interactor.powerInteractor
 import com.android.systemui.power.shared.model.WakeSleepReason
 import com.android.systemui.power.shared.model.WakefulnessState
-import com.android.systemui.scene.domain.interactor.sceneContainerStartable
 import com.android.systemui.scene.domain.interactor.sceneInteractor
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.scene.shared.model.fakeSceneDataSource
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorTest.kt
index 8e765f7..9ef42c3 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorTest.kt
@@ -21,13 +21,13 @@
 import android.provider.Settings.Global
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.settingslib.statusbar.notification.data.model.ZenMode
 import com.android.settingslib.statusbar.notification.data.repository.updateNotificationPolicy
 import com.android.settingslib.statusbar.notification.domain.interactor.NotificationsSoundPolicyInteractor
 import com.android.settingslib.volume.shared.model.AudioStream
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.kosmos.testScope
+import com.android.systemui.statusbar.policy.data.repository.zenModeRepository
 import com.android.systemui.testKosmos
 import com.google.common.truth.Expect
 import com.google.common.truth.Truth.assertThat
@@ -52,24 +52,20 @@
 
     @Before
     fun setup() {
-        with(kosmos) {
-            underTest = NotificationsSoundPolicyInteractor(notificationsSoundPolicyRepository)
-        }
+        with(kosmos) { underTest = NotificationsSoundPolicyInteractor(zenModeRepository) }
     }
 
     @Test
     fun onlyAlarmsCategory_areAlarmsAllowed_isTrue() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateZenMode(ZenMode(Global.ZEN_MODE_OFF))
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_OFF)
                 val expectedByCategory =
                     NotificationManager.Policy.ALL_PRIORITY_CATEGORIES.associateWith {
                         it == NotificationManager.Policy.PRIORITY_CATEGORY_ALARMS
                     }
                 expectedByCategory.forEach { entry ->
-                    notificationsSoundPolicyRepository.updateNotificationPolicy(
-                        priorityCategories = entry.key
-                    )
+                    zenModeRepository.updateNotificationPolicy(priorityCategories = entry.key)
 
                     val areAlarmsAllowed by collectLastValue(underTest.areAlarmsAllowed)
                     runCurrent()
@@ -84,15 +80,13 @@
     fun onlyMediaCategory_areAlarmsAllowed_isTrue() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateZenMode(ZenMode(Global.ZEN_MODE_OFF))
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_OFF)
                 val expectedByCategory =
                     NotificationManager.Policy.ALL_PRIORITY_CATEGORIES.associateWith {
                         it == NotificationManager.Policy.PRIORITY_CATEGORY_MEDIA
                     }
                 expectedByCategory.forEach { entry ->
-                    notificationsSoundPolicyRepository.updateNotificationPolicy(
-                        priorityCategories = entry.key
-                    )
+                    zenModeRepository.updateNotificationPolicy(priorityCategories = entry.key)
 
                     val isMediaAllowed by collectLastValue(underTest.isMediaAllowed)
                     runCurrent()
@@ -108,7 +102,7 @@
         with(kosmos) {
             testScope.runTest {
                 for (category in NotificationManager.Policy.ALL_PRIORITY_CATEGORIES) {
-                    notificationsSoundPolicyRepository.updateNotificationPolicy(
+                    zenModeRepository.updateNotificationPolicy(
                         priorityCategories = category,
                         state = NotificationManager.Policy.STATE_UNSET,
                     )
@@ -126,7 +120,7 @@
     fun allCategoriesAllowed_isRingerAllowed_isTrue() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateNotificationPolicy(
+                zenModeRepository.updateNotificationPolicy(
                     priorityCategories =
                         NotificationManager.Policy.ALL_PRIORITY_CATEGORIES.reduce { acc, value ->
                             acc or value
@@ -146,7 +140,7 @@
     fun noCategoriesAndBlocked_isRingerAllowed_isFalse() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateNotificationPolicy(
+                zenModeRepository.updateNotificationPolicy(
                     priorityCategories = 0,
                     state = NotificationManager.Policy.STATE_PRIORITY_CHANNELS_BLOCKED,
                 )
@@ -163,10 +157,8 @@
     fun zenModeNoInterruptions_allStreams_muted() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateNotificationPolicy()
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Global.ZEN_MODE_NO_INTERRUPTIONS)
-                )
+                zenModeRepository.updateNotificationPolicy()
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_NO_INTERRUPTIONS)
 
                 for (stream in AudioStream.supportedStreamTypes) {
                     val isZenMuted by collectLastValue(underTest.isZenMuted(AudioStream(stream)))
@@ -182,8 +174,8 @@
     fun zenModeOff_allStreams_notMuted() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateNotificationPolicy()
-                notificationsSoundPolicyRepository.updateZenMode(ZenMode(Global.ZEN_MODE_OFF))
+                zenModeRepository.updateNotificationPolicy()
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_OFF)
 
                 for (stream in AudioStream.supportedStreamTypes) {
                     val isZenMuted by collectLastValue(underTest.isZenMuted(AudioStream(stream)))
@@ -205,8 +197,8 @@
                     AudioManager.STREAM_SYSTEM,
                 )
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateNotificationPolicy()
-                notificationsSoundPolicyRepository.updateZenMode(ZenMode(Global.ZEN_MODE_ALARMS))
+                zenModeRepository.updateNotificationPolicy()
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_ALARMS)
 
                 for (stream in AudioStream.supportedStreamTypes) {
                     val isZenMuted by collectLastValue(underTest.isZenMuted(AudioStream(stream)))
@@ -222,10 +214,8 @@
     fun alarms_allowed_notMuted() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-                )
-                notificationsSoundPolicyRepository.updateNotificationPolicy(
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+                zenModeRepository.updateNotificationPolicy(
                     priorityCategories = NotificationManager.Policy.PRIORITY_CATEGORY_ALARMS
                 )
 
@@ -242,10 +232,8 @@
     fun media_allowed_notMuted() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-                )
-                notificationsSoundPolicyRepository.updateNotificationPolicy(
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+                zenModeRepository.updateNotificationPolicy(
                     priorityCategories = NotificationManager.Policy.PRIORITY_CATEGORY_MEDIA
                 )
 
@@ -262,10 +250,8 @@
     fun ringer_allowed_notificationsNotMuted() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-                )
-                notificationsSoundPolicyRepository.updateNotificationPolicy(
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+                zenModeRepository.updateNotificationPolicy(
                     priorityCategories =
                         NotificationManager.Policy.ALL_PRIORITY_CATEGORIES.reduce { acc, value ->
                             acc or value
@@ -288,10 +274,8 @@
     fun ringer_allowed_ringNotMuted() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-                )
-                notificationsSoundPolicyRepository.updateNotificationPolicy(
+                zenModeRepository.updateZenMode(Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+                zenModeRepository.updateNotificationPolicy(
                     priorityCategories =
                         NotificationManager.Policy.ALL_PRIORITY_CATEGORIES.reduce { acc, value ->
                             acc or value
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt
new file mode 100644
index 0000000..5e87f46
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.statusbar.notification.row.ui.viewmodel
+
+import android.app.PendingIntent
+import android.platform.test.annotations.EnableFlags
+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.statusbar.notification.row.data.repository.fakeNotificationRowRepository
+import com.android.systemui.statusbar.notification.row.shared.IconModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel.TimerState.Paused
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+@EnableFlags(RichOngoingNotificationFlag.FLAG_NAME)
+class TimerViewModelTest : SysuiTestCase() {
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+    private val repository = kosmos.fakeNotificationRowRepository
+
+    private var contentModel: TimerContentModel?
+        get() = repository.richOngoingContentModel.value as? TimerContentModel
+        set(value) {
+            repository.richOngoingContentModel.value = value
+        }
+
+    private lateinit var underTest: TimerViewModel
+
+    @Before
+    fun setup() {
+        underTest = kosmos.getTimerViewModel(repository)
+    }
+
+    @Test
+    fun labelShowsTheTimerName() =
+        testScope.runTest {
+            val label by collectLastValue(underTest.label)
+            contentModel = pausedTimer(name = "Example Timer Name")
+            assertThat(label).isEqualTo("Example Timer Name")
+        }
+
+    @Test
+    fun pausedTimeRemainingFormatsWell() =
+        testScope.runTest {
+            val label by collectLastValue(underTest.pausedTime)
+            contentModel = pausedTimer(timeRemaining = Duration.ofMinutes(3))
+            assertThat(label).isEqualTo("3:00")
+            contentModel = pausedTimer(timeRemaining = Duration.ofSeconds(119))
+            assertThat(label).isEqualTo("1:59")
+            contentModel = pausedTimer(timeRemaining = Duration.ofSeconds(121))
+            assertThat(label).isEqualTo("2:01")
+            contentModel = pausedTimer(timeRemaining = Duration.ofHours(1))
+            assertThat(label).isEqualTo("1:00:00")
+            contentModel = pausedTimer(timeRemaining = Duration.ofHours(24))
+            assertThat(label).isEqualTo("24:00:00")
+        }
+
+    private fun pausedTimer(
+        icon: IconModel = mock(),
+        name: String = "example",
+        timeRemaining: Duration = Duration.ofMinutes(3),
+        resumeIntent: PendingIntent? = null,
+        resetIntent: PendingIntent? = null
+    ) =
+        TimerContentModel(
+            icon = icon,
+            name = name,
+            state =
+                Paused(
+                    timeRemaining = timeRemaining,
+                    resumeIntent = resumeIntent,
+                    resetIntent = resetIntent,
+                )
+        )
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt
index 9fde116..40315a2 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt
@@ -23,6 +23,7 @@
 import android.platform.test.flag.junit.FlagsParameterization
 import android.provider.Settings
 import androidx.test.filters.SmallTest
+import com.android.settingslib.statusbar.notification.data.repository.updateNotificationPolicy
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.flags.Flags
@@ -56,7 +57,6 @@
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.mockito.MockitoAnnotations
 import platform.test.runner.parameterized.ParameterizedAndroidJunit4
 import platform.test.runner.parameterized.Parameters
 
@@ -97,7 +97,9 @@
 
     @Before
     fun setUp() {
-        MockitoAnnotations.initMocks(this)
+        // "Why is this not lazily initialised above?" you may ask. There's a simple answer: likely
+        // due to some timing issue with how the flags are getting initialised for parameterization,
+        // some tests start failing when this isn't initialised this way. You can just leave it be.
         underTest = kosmos.notificationListViewModel
     }
 
@@ -146,36 +148,40 @@
             assertThat(important).isTrue()
         }
 
+    // NOTE: The empty shade view and the footer view should be mutually exclusive.
+
     @Test
-    fun shouldIncludeEmptyShadeView_trueWhenNoNotifs() =
+    fun shouldShowEmptyShadeView_trueWhenNoNotifs() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldIncludeFooterView by collectLastValue(underTest.shouldIncludeFooterView)
 
             // WHEN has no notifs
             activeNotificationListRepository.setActiveNotifs(count = 0)
             runCurrent()
 
             // THEN empty shade is visible
-            assertThat(shouldInclude).isTrue()
+            assertThat(shouldShowEmptyShadeView).isTrue()
+            assertThat(shouldIncludeFooterView?.value).isFalse()
         }
 
     @Test
-    fun shouldIncludeEmptyShadeView_falseWhenNotifs() =
+    fun shouldShowEmptyShadeView_falseWhenNotifs() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has notifs
             activeNotificationListRepository.setActiveNotifs(count = 2)
             runCurrent()
 
             // THEN empty shade is not visible
-            assertThat(shouldInclude).isFalse()
+            assertThat(shouldShowEmptyShadeView).isFalse()
         }
 
     @Test
-    fun shouldIncludeEmptyShadeView_falseWhenQsExpandedDefault() =
+    fun shouldShowEmptyShadeView_falseWhenQsExpandedDefault() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShow by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has no notifs
             activeNotificationListRepository.setActiveNotifs(count = 0)
@@ -184,13 +190,14 @@
             runCurrent()
 
             // THEN empty shade is not visible
-            assertThat(shouldInclude).isFalse()
+            assertThat(shouldShow).isFalse()
         }
 
     @Test
-    fun shouldIncludeEmptyShadeView_trueWhenQsExpandedInSplitShade() =
+    fun shouldShowEmptyShadeView_trueWhenQsExpandedInSplitShade() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldIncludeFooterView by collectLastValue(underTest.shouldIncludeFooterView)
 
             // WHEN has no notifs
             activeNotificationListRepository.setActiveNotifs(count = 0)
@@ -203,13 +210,15 @@
             runCurrent()
 
             // THEN empty shade is visible
-            assertThat(shouldInclude).isTrue()
+            assertThat(shouldShowEmptyShadeView).isTrue()
+            assertThat(shouldIncludeFooterView?.value).isFalse()
         }
 
     @Test
-    fun shouldIncludeEmptyShadeView_trueWhenLockedShade() =
+    fun shouldShowEmptyShadeView_trueWhenLockedShade() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldIncludeFooterView by collectLastValue(underTest.shouldIncludeFooterView)
 
             // WHEN has no notifs
             activeNotificationListRepository.setActiveNotifs(count = 0)
@@ -218,13 +227,14 @@
             runCurrent()
 
             // THEN empty shade is visible
-            assertThat(shouldInclude).isTrue()
+            assertThat(shouldShowEmptyShadeView).isTrue()
+            assertThat(shouldIncludeFooterView?.value).isFalse()
         }
 
     @Test
-    fun shouldIncludeEmptyShadeView_falseWhenKeyguard() =
+    fun shouldShowEmptyShadeView_falseWhenKeyguard() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShow by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has no notifs
             activeNotificationListRepository.setActiveNotifs(count = 0)
@@ -233,13 +243,13 @@
             runCurrent()
 
             // THEN empty shade is not visible
-            assertThat(shouldInclude).isFalse()
+            assertThat(shouldShow).isFalse()
         }
 
     @Test
-    fun shouldIncludeEmptyShadeView_falseWhenStartingToSleep() =
+    fun shouldShowEmptyShadeView_falseWhenStartingToSleep() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldShowEmptyShadeView)
+            val shouldShow by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has no notifs
             activeNotificationListRepository.setActiveNotifs(count = 0)
@@ -250,7 +260,7 @@
             runCurrent()
 
             // THEN empty shade is not visible
-            assertThat(shouldInclude).isFalse()
+            assertThat(shouldShow).isFalse()
         }
 
     @Test
@@ -258,8 +268,10 @@
         testScope.runTest {
             val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
 
-            zenModeRepository.setSuppressedVisualEffects(Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST)
-            zenModeRepository.zenMode.value = Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS
+            zenModeRepository.updateNotificationPolicy(
+                suppressedVisualEffects = Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST
+            )
+            zenModeRepository.updateZenMode(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
             runCurrent()
 
             assertThat(hidden).isTrue()
@@ -270,8 +282,10 @@
         testScope.runTest {
             val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
 
-            zenModeRepository.setSuppressedVisualEffects(Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST)
-            zenModeRepository.zenMode.value = Settings.Global.ZEN_MODE_OFF
+            zenModeRepository.updateNotificationPolicy(
+                suppressedVisualEffects = Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST
+            )
+            zenModeRepository.updateZenMode(Settings.Global.ZEN_MODE_OFF)
             runCurrent()
 
             assertThat(hidden).isFalse()
@@ -302,7 +316,8 @@
     @Test
     fun shouldIncludeFooterView_trueWhenShade() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldIncludeFooterView)
+            val shouldIncludeFooterView by collectLastValue(underTest.shouldIncludeFooterView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has notifs
             activeNotificationListRepository.setActiveNotifs(count = 2)
@@ -312,13 +327,15 @@
             runCurrent()
 
             // THEN footer is visible
-            assertThat(shouldInclude?.value).isTrue()
+            assertThat(shouldIncludeFooterView?.value).isTrue()
+            assertThat(shouldShowEmptyShadeView).isFalse()
         }
 
     @Test
     fun shouldIncludeFooterView_trueWhenLockedShade() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldIncludeFooterView)
+            val shouldIncludeFooterView by collectLastValue(underTest.shouldIncludeFooterView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has notifs
             activeNotificationListRepository.setActiveNotifs(count = 2)
@@ -328,7 +345,8 @@
             runCurrent()
 
             // THEN footer is visible
-            assertThat(shouldInclude?.value).isTrue()
+            assertThat(shouldIncludeFooterView?.value).isTrue()
+            assertThat(shouldShowEmptyShadeView).isFalse()
         }
 
     @Test
@@ -404,7 +422,8 @@
     @Test
     fun shouldIncludeFooterView_trueWhenQsExpandedSplitShade() =
         testScope.runTest {
-            val shouldInclude by collectLastValue(underTest.shouldIncludeFooterView)
+            val shouldIncludeFooterView by collectLastValue(underTest.shouldIncludeFooterView)
+            val shouldShowEmptyShadeView by collectLastValue(underTest.shouldShowEmptyShadeView)
 
             // WHEN has notifs
             activeNotificationListRepository.setActiveNotifs(count = 2)
@@ -419,7 +438,8 @@
             runCurrent()
 
             // THEN footer is visible
-            assertThat(shouldInclude?.value).isTrue()
+            assertThat(shouldIncludeFooterView?.value).isTrue()
+            assertThat(shouldShowEmptyShadeView).isFalse()
         }
 
     @Test
@@ -528,9 +548,7 @@
                     FakeHeadsUpRowRepository(key = "1"),
                     FakeHeadsUpRowRepository(key = "2"),
                 )
-            headsUpRepository.setNotifications(
-                rows,
-            )
+            headsUpRepository.setNotifications(rows)
             runCurrent()
 
             // THEN the list is empty
@@ -566,7 +584,7 @@
 
             headsUpRepository.setNotifications(
                 FakeHeadsUpRowRepository(key = "0", isPinned = true),
-                FakeHeadsUpRowRepository(key = "1")
+                FakeHeadsUpRowRepository(key = "1"),
             )
             runCurrent()
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
new file mode 100644
index 0000000..f1fed19
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
@@ -0,0 +1,154 @@
+/*
+ * 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.policy.domain.interactor
+
+import android.app.NotificationManager.Policy
+import android.provider.Settings
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.statusbar.notification.data.repository.updateNotificationPolicy
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.statusbar.policy.data.repository.fakeZenModeRepository
+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
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class ZenModeInteractorTest : SysuiTestCase() {
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+    private val repository = kosmos.fakeZenModeRepository
+
+    private val underTest = kosmos.zenModeInteractor
+
+    @Test
+    fun isZenModeEnabled_off() =
+        testScope.runTest {
+            val enabled by collectLastValue(underTest.isZenModeEnabled)
+
+            repository.updateZenMode(Settings.Global.ZEN_MODE_OFF)
+            runCurrent()
+
+            assertThat(enabled).isFalse()
+        }
+
+    @Test
+    fun isZenModeEnabled_alarms() =
+        testScope.runTest {
+            val enabled by collectLastValue(underTest.isZenModeEnabled)
+
+            repository.updateZenMode(Settings.Global.ZEN_MODE_ALARMS)
+            runCurrent()
+
+            assertThat(enabled).isTrue()
+        }
+
+    @Test
+    fun isZenModeEnabled_importantInterruptions() =
+        testScope.runTest {
+            val enabled by collectLastValue(underTest.isZenModeEnabled)
+
+            repository.updateZenMode(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+            runCurrent()
+
+            assertThat(enabled).isTrue()
+        }
+
+    @Test
+    fun isZenModeEnabled_noInterruptions() =
+        testScope.runTest {
+            val enabled by collectLastValue(underTest.isZenModeEnabled)
+
+            repository.updateZenMode(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
+            runCurrent()
+
+            assertThat(enabled).isTrue()
+        }
+
+    @Test
+    fun testIsZenModeEnabled_unknown() =
+        testScope.runTest {
+            val enabled by collectLastValue(underTest.isZenModeEnabled)
+
+            repository.updateZenMode(4) // this should fail if we ever add another zen mode type
+            runCurrent()
+
+            assertThat(enabled).isFalse()
+        }
+
+    @Test
+    fun areNotificationsHiddenInShade_noPolicy() =
+        testScope.runTest {
+            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
+
+            repository.updateNotificationPolicy(null)
+            repository.updateZenMode(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+            runCurrent()
+
+            assertThat(hidden).isFalse()
+        }
+
+    @Test
+    fun areNotificationsHiddenInShade_zenOffShadeSuppressed() =
+        testScope.runTest {
+            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
+
+            repository.updateNotificationPolicy(
+                suppressedVisualEffects = Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST
+            )
+            repository.updateZenMode(Settings.Global.ZEN_MODE_OFF)
+            runCurrent()
+
+            assertThat(hidden).isFalse()
+        }
+
+    @Test
+    fun areNotificationsHiddenInShade_zenOnShadeNotSuppressed() =
+        testScope.runTest {
+            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
+
+            repository.updateNotificationPolicy(
+                suppressedVisualEffects = Policy.SUPPRESSED_EFFECT_STATUS_BAR
+            )
+            repository.updateZenMode(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+            runCurrent()
+
+            assertThat(hidden).isFalse()
+        }
+
+    @Test
+    fun areNotificationsHiddenInShade_zenOnShadeSuppressed() =
+        testScope.runTest {
+            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
+
+            repository.updateNotificationPolicy(
+                suppressedVisualEffects = Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST
+            )
+            repository.updateZenMode(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
+            runCurrent()
+
+            assertThat(hidden).isTrue()
+        }
+}
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 d620639..6e49e43 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
@@ -20,7 +20,6 @@
 import android.provider.Settings
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.settingslib.statusbar.notification.data.model.ZenMode
 import com.android.settingslib.statusbar.notification.data.repository.updateNotificationPolicy
 import com.android.settingslib.volume.domain.interactor.AudioVolumeInteractor
 import com.android.settingslib.volume.shared.model.AudioStream
@@ -29,7 +28,7 @@
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.statusbar.notification.domain.interactor.notificationsSoundPolicyInteractor
-import com.android.systemui.statusbar.notification.domain.interactor.notificationsSoundPolicyRepository
+import com.android.systemui.statusbar.policy.data.repository.zenModeRepository
 import com.android.systemui.testKosmos
 import com.android.systemui.volume.data.repository.audioRepository
 import com.google.common.truth.Truth.assertThat
@@ -104,10 +103,8 @@
     fun zenMuted_cantChange() {
         with(kosmos) {
             testScope.runTest {
-                notificationsSoundPolicyRepository.updateNotificationPolicy()
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
-                )
+                zenModeRepository.updateNotificationPolicy()
+                zenModeRepository.updateZenMode(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
 
                 val canChangeVolume by
                     collectLastValue(
@@ -141,9 +138,7 @@
         with(kosmos) {
             testScope.runTest {
                 audioRepository.setLastAudibleVolume(audioStream, 30)
-                notificationsSoundPolicyRepository.updateZenMode(
-                    ZenMode(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
-                )
+                zenModeRepository.updateZenMode(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
 
                 val model by collectLastValue(underTest.getAudioStream(audioStream))
                 runCurrent()
diff --git a/packages/SystemUI/res/drawable/ic_check_box.xml b/packages/SystemUI/res/drawable/ic_check_box.xml
deleted file mode 100644
index a8d1a65..0000000
--- a/packages/SystemUI/res/drawable/ic_check_box.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<!--
-  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
-  -->
-
-<selector xmlns:android="http://schemas.android.com/apk/res/android">
-    <item
-        android:id="@+id/checked"
-        android:state_checked="true"
-        android:drawable="@drawable/ic_check_box_blue_24dp" />
-    <item
-        android:id="@+id/unchecked"
-        android:state_checked="false"
-        android:drawable="@drawable/ic_check_box_outline_24dp" />
-</selector>
diff --git a/packages/SystemUI/res/drawable/ic_check_box_blue_24dp.xml b/packages/SystemUI/res/drawable/ic_check_box_blue_24dp.xml
deleted file mode 100644
index 43cae69..0000000
--- a/packages/SystemUI/res/drawable/ic_check_box_blue_24dp.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<!--
-  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
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-        android:width="24dp"
-        android:height="24dp"
-        android:viewportWidth="24"
-        android:viewportHeight="24">
-    <path
-        android:pathData="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"
-        android:fillColor="#4285F4"/>
-</vector>
-
diff --git a/packages/SystemUI/res/drawable/ic_check_box_outline_24dp.xml b/packages/SystemUI/res/drawable/ic_check_box_outline_24dp.xml
deleted file mode 100644
index f6f453a..0000000
--- a/packages/SystemUI/res/drawable/ic_check_box_outline_24dp.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<!--
-  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
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-        android:width="24dp"
-        android:height="24dp"
-        android:viewportWidth="24"
-        android:viewportHeight="24">
-    <path
-        android:pathData="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z"
-        android:fillColor="#757575"/>
-</vector>
-
diff --git a/packages/SystemUI/res/drawable/ic_speaker_group_black_24dp.xml b/packages/SystemUI/res/drawable/ic_speaker_group_black_24dp.xml
deleted file mode 100644
index ae0d562..0000000
--- a/packages/SystemUI/res/drawable/ic_speaker_group_black_24dp.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-<!--
-  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
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-        android:width="24dp"
-        android:height="24dp"
-        android:viewportWidth="24"
-        android:viewportHeight="24">
-    <path
-        android:pathData="M18.2,1L9.8,1C8.81,1 8,1.81 8,2.8v14.4c0,0.99 0.81,1.79 1.8,1.79l8.4,0.01c0.99,0 1.8,-0.81 1.8,-1.8L20,2.8c0,-0.99 -0.81,-1.8 -1.8,-1.8zM14,3c1.1,0 2,0.89 2,2s-0.9,2 -2,2 -2,-0.89 -2,-2 0.9,-2 2,-2zM14,16.5c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"
-        android:fillColor="#000000"/>
-    <path
-        android:pathData="M14,12.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"
-        android:fillColor="#000000"/>
-    <path
-        android:pathData="M6,5H4v16c0,1.1 0.89,2 2,2h10v-2H6V5z"
-        android:fillColor="#000000"/>
-</vector>
diff --git a/packages/SystemUI/res/layout/media_output_list_item.xml b/packages/SystemUI/res/layout/media_output_list_item.xml
deleted file mode 100644
index 5b1ec7f..0000000
--- a/packages/SystemUI/res/layout/media_output_list_item.xml
+++ /dev/null
@@ -1,146 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ 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.
-  -->
-
-<LinearLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:id="@+id/device_container"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:orientation="vertical">
-    <FrameLayout
-        android:layout_width="match_parent"
-        android:layout_height="64dp"
-        android:layout_marginStart="16dp"
-        android:layout_marginEnd="16dp"
-        android:layout_marginBottom="12dp">
-        <FrameLayout
-            android:id="@+id/item_layout"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:background="@drawable/media_output_item_background"
-            android:layout_gravity="center_vertical|start">
-            <com.android.systemui.media.dialog.MediaOutputSeekbar
-                android:id="@+id/volume_seekbar"
-                android:splitTrack="false"
-                android:visibility="gone"
-                android:paddingStart="0dp"
-                android:paddingEnd="0dp"
-                android:background="@null"
-                android:contentDescription="@string/media_output_dialog_accessibility_seekbar"
-                android:progressDrawable="@drawable/media_output_dialog_seekbar_background"
-                android:thumb="@null"
-                android:layout_width="match_parent"
-                android:layout_height="match_parent"/>
-        </FrameLayout>
-
-        <FrameLayout
-            android:layout_width="56dp"
-            android:layout_height="64dp"
-            android:layout_gravity="center_vertical|start">
-            <ImageView
-                android:id="@+id/title_icon"
-                android:layout_width="24dp"
-                android:layout_height="24dp"
-                android:layout_gravity="center"/>
-        </FrameLayout>
-
-        <TextView
-            android:id="@+id/title"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical|start"
-            android:layout_marginStart="56dp"
-            android:layout_marginEnd="56dp"
-            android:ellipsize="end"
-            android:maxLines="1"
-            android:fontFamily="@*android:string/config_headlineFontFamilyMedium"
-            android:textSize="16sp"/>
-
-        <LinearLayout
-            android:id="@+id/two_line_layout"
-            android:orientation="vertical"
-            android:layout_width="wrap_content"
-            android:layout_gravity="center_vertical|start"
-            android:layout_height="48dp"
-            android:layout_marginEnd="56dp"
-            android:layout_marginStart="56dp">
-            <TextView
-                android:id="@+id/two_line_title"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:ellipsize="end"
-                android:maxLines="1"
-                android:fontFamily="@*android:string/config_headlineFontFamilyMedium"
-                android:textColor="@color/media_dialog_item_main_content"
-                android:textSize="16sp"/>
-            <TextView
-                android:id="@+id/subtitle"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:ellipsize="end"
-                android:maxLines="1"
-                android:textColor="@color/media_dialog_item_main_content"
-                android:textSize="14sp"
-                android:fontFamily="@*android:string/config_bodyFontFamily"
-                android:visibility="gone"/>
-        </LinearLayout>
-
-        <ProgressBar
-            android:id="@+id/volume_indeterminate_progress"
-            style="?android:attr/progressBarStyleSmallTitle"
-            android:layout_width="24dp"
-            android:layout_height="24dp"
-            android:layout_marginEnd="16dp"
-            android:indeterminate="true"
-            android:layout_gravity="end|center"
-            android:indeterminateOnly="true"
-            android:visibility="gone"/>
-
-        <ImageView
-            android:id="@+id/media_output_item_status"
-            android:layout_width="24dp"
-            android:layout_height="24dp"
-            android:layout_marginEnd="16dp"
-            android:indeterminate="true"
-            android:layout_gravity="end|center"
-            android:indeterminateOnly="true"
-            android:importantForAccessibility="no"
-            android:visibility="gone"/>
-
-        <LinearLayout
-            android:id="@+id/end_action_area"
-            android:visibility="gone"
-            android:orientation="vertical"
-            android:layout_width="48dp"
-            android:layout_height="64dp"
-            android:layout_gravity="end|center"
-            android:gravity="center_vertical">
-            <CheckBox
-                android:id="@+id/check_box"
-                android:focusable="false"
-                android:importantForAccessibility="no"
-                android:layout_width="24dp"
-                android:layout_height="24dp"
-                android:layout_marginEnd="16dp"
-                android:layout_gravity="end"
-                android:button="@drawable/ic_circle_check_box"
-                android:visibility="gone"
-            />
-
-        </LinearLayout>
-    </FrameLayout>
-</LinearLayout>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml b/packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml
new file mode 100644
index 0000000..f2bfbe5c9
--- /dev/null
+++ b/packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml
@@ -0,0 +1,116 @@
+<?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.statusbar.notification.row.ui.view.TimerView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <androidx.constraintlayout.widget.Guideline
+        android:id="@+id/topBaseline"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        app:layout_constraintGuide_begin="22sp"
+        />
+
+    <ImageView
+        android:id="@+id/icon"
+        android:layout_width="24dp"
+        android:layout_height="24dp"
+        android:src="@drawable/ic_close"
+        app:tint="@android:color/white"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/label"
+        android:baseline="18dp"
+        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
+        />
+    <TextView
+        android:id="@+id/label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintStart_toEndOf="@id/icon"
+        app:layout_constraintEnd_toStartOf="@id/chronoRemaining"
+        android:singleLine="true"
+        tools:text="15s Timer"
+        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
+        android:paddingEnd="4dp"
+        />
+    <Chronometer
+        android:id="@+id/chronoRemaining"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:singleLine="true"
+        android:textSize="20sp"
+        android:gravity="end"
+        tools:text="0:12"
+        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
+        app:layout_constraintEnd_toStartOf="@id/pausedTimeRemaining"
+        app:layout_constraintStart_toEndOf="@id/label"
+        android:countDown="true"
+        android:paddingEnd="4dp"
+        />
+    <TextView
+        android:id="@+id/pausedTimeRemaining"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:singleLine="true"
+        android:textSize="20sp"
+        android:gravity="end"
+        tools:text="0:12"
+        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/chronoRemaining"
+        android:paddingEnd="4dp"
+        />
+
+    <androidx.constraintlayout.widget.Barrier
+        android:id="@+id/bottomOfTop"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:barrierDirection="bottom"
+        app:constraint_referenced_ids="icon,label,chronoRemaining,pausedTimeRemaining"
+        />
+
+    <com.android.systemui.statusbar.notification.row.ui.view.TimerButtonView
+        android:id="@+id/mainButton"
+        android:layout_width="124dp"
+        android:layout_height="wrap_content"
+        tools:text="Reset"
+        tools:drawableStart="@android:drawable/ic_menu_add"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/altButton"
+        app:layout_constraintTop_toBottomOf="@id/bottomOfTop"
+        app:layout_constraintHorizontal_chainStyle="spread"
+        android:paddingEnd="4dp"
+        />
+
+    <com.android.systemui.statusbar.notification.row.ui.view.TimerButtonView
+        android:id="@+id/altButton"
+        tools:text="Reset"
+        tools:drawableStart="@android:drawable/ic_menu_add"
+        android:drawablePadding="2dp"
+        android:drawableTint="@android:color/white"
+        android:layout_width="124dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toBottomOf="@id/bottomOfTop"
+        app:layout_constraintStart_toEndOf="@id/mainButton"
+        app:layout_constraintEnd_toEndOf="parent"
+        android:paddingEnd="4dp"
+        />
+</com.android.systemui.statusbar.notification.row.ui.view.TimerView>
\ No newline at end of file
diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml
index ba59c2f9..b3d3021 100644
--- a/packages/SystemUI/res/values/colors.xml
+++ b/packages/SystemUI/res/values/colors.xml
@@ -18,7 +18,6 @@
 -->
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
-    <drawable name="notification_number_text_color">#ffffffff</drawable>
     <drawable name="system_bar_background">@color/system_bar_background_opaque</drawable>
     <color name="system_bar_background_opaque">#ff000000</color>
     <color name="system_bar_background_transparent">#00000000</color>
diff --git a/packages/SystemUI/res/values/config.xml b/packages/SystemUI/res/values/config.xml
index 4ef9442..80b9ec7 100644
--- a/packages/SystemUI/res/values/config.xml
+++ b/packages/SystemUI/res/values/config.xml
@@ -25,9 +25,6 @@
      (package/class)  -->
     <string name="config_recentsComponent" translatable="false">com.android.systemui.recents.OverviewProxyRecentsImpl</string>
 
-    <!-- Whether or not we show the number in the bar. -->
-    <bool name="config_statusBarShowNumber">false</bool>
-
     <!-- For how long the lock screen can be on before the display turns off. -->
     <integer name="config_lockScreenDisplayTimeout">10000</integer>
 
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/recents/model/Task.java b/packages/SystemUI/shared/src/com/android/systemui/shared/recents/model/Task.java
index f06e333..78d4fc8 100644
--- a/packages/SystemUI/shared/src/com/android/systemui/shared/recents/model/Task.java
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/recents/model/Task.java
@@ -246,6 +246,9 @@
     public ActivityManager.RecentTaskInfo.PersistedTaskSnapshotData lastSnapshotData =
             new ActivityManager.RecentTaskInfo.PersistedTaskSnapshotData();
 
+    @ViewDebug.ExportedProperty(category="recents")
+    public boolean isVisible;
+
     public Task() {
         // Do nothing
     }
@@ -279,6 +282,7 @@
         lastSnapshotData.set(other.lastSnapshotData);
         positionInParent = other.positionInParent;
         appBounds = other.appBounds;
+        isVisible = other.isVisible;
     }
 
     /**
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java
index 660f0db..2eac393 100644
--- a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java
@@ -53,6 +53,9 @@
 import android.view.animation.Interpolator;
 import android.view.animation.LinearInterpolator;
 
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.logging.UiEvent;
 import com.android.internal.logging.UiEventLogger;
@@ -142,12 +145,14 @@
     };
 
     private final IRotationWatcher.Stub mRotationWatcher = new IRotationWatcher.Stub() {
+        @WorkerThread
         @Override
         public void onRotationChanged(final int rotation) {
+            @Nullable Boolean rotationLocked = RotationPolicyUtil.isRotationLocked(mContext);
             // We need this to be scheduled as early as possible to beat the redrawing of
             // window in response to the orientation change.
             mMainThreadHandler.postAtFrontOfQueue(() -> {
-                onRotationWatcherChanged(rotation);
+                onRotationWatcherChanged(rotation, rotationLocked);
             });
         }
     };
@@ -281,8 +286,8 @@
         TaskStackChangeListeners.getInstance().unregisterTaskStackListener(mTaskStackListener);
     }
 
-    public void setRotationLockedAtAngle(int rotationSuggestion, String caller) {
-        final Boolean isLocked = isRotationLocked();
+    public void setRotationLockedAtAngle(
+            @Nullable Boolean isLocked, int rotationSuggestion, String caller) {
         if (isLocked == null) {
             // Ignore if we can't read the setting for the current user
             return;
@@ -291,21 +296,6 @@
                 /* rotation= */ rotationSuggestion, caller);
     }
 
-    /**
-     * @return whether rotation is currently locked, or <code>null</code> if the setting couldn't
-     *         be read
-     */
-    public Boolean isRotationLocked() {
-        try {
-            return RotationPolicy.isRotationLocked(mContext);
-        } catch (SecurityException e) {
-            // TODO(b/279561841): RotationPolicy uses the current user to resolve the setting which
-            //                    may change before the rotation watcher can be unregistered
-            Log.e(TAG, "Failed to get isRotationLocked", e);
-            return null;
-        }
-    }
-
     public void setRotateSuggestionButtonState(boolean visible) {
         setRotateSuggestionButtonState(visible, false /* force */);
     }
@@ -469,7 +459,7 @@
      * Called when the rotation watcher rotation changes, either from the watcher registered
      * internally in this class, or a signal propagated from NavBarHelper.
      */
-    public void onRotationWatcherChanged(int rotation) {
+    public void onRotationWatcherChanged(int rotation, @Nullable Boolean isRotationLocked) {
         if (!mListenersRegistered) {
             // Ignore if not registered
             return;
@@ -477,17 +467,16 @@
 
         // If the screen rotation changes while locked, potentially update lock to flow with
         // new screen rotation and hide any showing suggestions.
-        Boolean rotationLocked = isRotationLocked();
-        if (rotationLocked == null) {
+        if (isRotationLocked == null) {
             // Ignore if we can't read the setting for the current user
             return;
         }
         // The isVisible check makes the rotation button disappear when we are not locked
         // (e.g. for tabletop auto-rotate).
-        if (rotationLocked || mRotationButton.isVisible()) {
+        if (isRotationLocked || mRotationButton.isVisible()) {
             // Do not allow a change in rotation to set user rotation when docked.
-            if (shouldOverrideUserLockPrefs(rotation) && rotationLocked && !mDocked) {
-                setRotationLockedAtAngle(rotation, /* caller= */
+            if (shouldOverrideUserLockPrefs(rotation) && isRotationLocked && !mDocked) {
+                setRotationLockedAtAngle(true, rotation, /* caller= */
                         "RotationButtonController#onRotationWatcherChanged");
             }
             setRotateSuggestionButtonState(false /* visible */, true /* forced */);
@@ -592,7 +581,8 @@
     private void onRotateSuggestionClick(View v) {
         mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_ACCEPTED);
         incrementNumAcceptedRotationSuggestionsIfNeeded();
-        setRotationLockedAtAngle(mLastRotationSuggestion,
+        setRotationLockedAtAngle(
+                RotationPolicyUtil.isRotationLocked(mContext), mLastRotationSuggestion,
                 /* caller= */ "RotationButtonController#onRotateSuggestionClick");
         Log.i(TAG, "onRotateSuggestionClick() mLastRotationSuggestion=" + mLastRotationSuggestion);
         v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationPolicyUtil.kt b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationPolicyUtil.kt
new file mode 100644
index 0000000..eac4a10
--- /dev/null
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationPolicyUtil.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.shared.rotation
+
+import android.content.Context
+import android.util.Log
+import com.android.internal.view.RotationPolicy
+
+class RotationPolicyUtil {
+    companion object {
+        /**
+         * Recommend to be called on bg thread, or reuse the results. It's because
+         * [RotationPolicy.isRotationLocked] may make a binder call to query settings.
+         *
+         * @return whether rotation is currently locked, or <code>null</code> if the setting
+         *   couldn't be read
+         */
+        @JvmStatic
+        fun isRotationLocked(context: Context): Boolean? {
+            try {
+                return RotationPolicy.isRotationLocked(context)
+            } catch (e: SecurityException) {
+                // TODO(b/279561841): RotationPolicy uses the current user to resolve the setting
+                // which may change before the rotation watcher can be unregistered
+                Log.e("RotationPolicy", "Failed to get isRotationLocked", e)
+                return null
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/keyguard/KeyguardSecurityContainer.java b/packages/SystemUI/src/com/android/keyguard/KeyguardSecurityContainer.java
index 70465bc..4217820 100644
--- a/packages/SystemUI/src/com/android/keyguard/KeyguardSecurityContainer.java
+++ b/packages/SystemUI/src/com/android/keyguard/KeyguardSecurityContainer.java
@@ -1097,7 +1097,8 @@
             int yTranslation = mResources.getDimensionPixelSize(R.dimen.disappear_y_translation);
 
             AnimatorSet anims = new AnimatorSet();
-            ObjectAnimator yAnim = ObjectAnimator.ofFloat(mView, View.TRANSLATION_Y, yTranslation);
+            ObjectAnimator yAnim = ObjectAnimator.ofFloat(mViewFlipper, View.TRANSLATION_Y,
+                    yTranslation);
             ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(mUserSwitcherViewGroup, View.ALPHA,
                     0f);
 
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/WindowMagnificationController.java b/packages/SystemUI/src/com/android/systemui/accessibility/WindowMagnificationController.java
index 25ad385..b37ba89 100644
--- a/packages/SystemUI/src/com/android/systemui/accessibility/WindowMagnificationController.java
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/WindowMagnificationController.java
@@ -1633,7 +1633,7 @@
 
         int maxHeightSize;
         int maxWidthSize;
-        if (Flags.redesignMagnifierWindowSize()) {
+        if (Flags.redesignMagnificationWindowSize()) {
             // mOuterBorderSize = transparent margin area
             // mMirrorSurfaceMargin = transparent margin area + orange border width
             // We would like to allow the width and height to be full size. Therefore, the max
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractor.kt
index 7d3075a..ed931bd 100644
--- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/SimBouncerInteractor.kt
@@ -43,10 +43,11 @@
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
@@ -68,7 +69,12 @@
     mobileConnectionsRepository: MobileConnectionsRepository,
 ) {
     val subId: StateFlow<Int> = repository.subscriptionId
-    val isAnySimSecure: Flow<Boolean> = mobileConnectionsRepository.isAnySimSecure
+    val isAnySimSecure: StateFlow<Boolean> =
+        mobileConnectionsRepository.isAnySimSecure.stateIn(
+            scope = applicationScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = mobileConnectionsRepository.getIsAnySimSecure(),
+        )
     val isLockedEsim: StateFlow<Boolean?> = repository.isLockedEsim
     val errorDialogMessage: StateFlow<String?> = repository.errorDialogMessage
 
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardService.java b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardService.java
index 209bc7a..c4b70d8 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardService.java
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardService.java
@@ -89,6 +89,7 @@
 import com.android.systemui.power.domain.interactor.PowerInteractor;
 import com.android.systemui.power.shared.model.ScreenPowerState;
 import com.android.systemui.scene.domain.interactor.SceneInteractor;
+import com.android.systemui.scene.domain.startable.KeyguardStateCallbackStartable;
 import com.android.systemui.scene.shared.flag.SceneContainerFlag;
 import com.android.systemui.scene.shared.model.Scenes;
 import com.android.systemui.settings.DisplayTracker;
@@ -122,6 +123,7 @@
     private final KeyguardInteractor mKeyguardInteractor;
     private final Lazy<SceneInteractor> mSceneInteractorLazy;
     private final Executor mMainExecutor;
+    private final Lazy<KeyguardStateCallbackStartable> mKeyguardStateCallbackStartableLazy;
 
     private static RemoteAnimationTarget[] wrap(TransitionInfo info, boolean wallpapers,
             SurfaceControl.Transaction t, ArrayMap<SurfaceControl, SurfaceControl> leashMap,
@@ -341,7 +343,8 @@
             Lazy<SceneInteractor> sceneInteractorLazy,
             @Main Executor mainExecutor,
             KeyguardInteractor keyguardInteractor,
-            KeyguardEnabledInteractor keyguardEnabledInteractor) {
+            KeyguardEnabledInteractor keyguardEnabledInteractor,
+            Lazy<KeyguardStateCallbackStartable> keyguardStateCallbackStartableLazy) {
         super();
         mKeyguardViewMediator = keyguardViewMediator;
         mKeyguardLifecyclesDispatcher = keyguardLifecyclesDispatcher;
@@ -353,6 +356,7 @@
         mKeyguardInteractor = keyguardInteractor;
         mSceneInteractorLazy = sceneInteractorLazy;
         mMainExecutor = mainExecutor;
+        mKeyguardStateCallbackStartableLazy = keyguardStateCallbackStartableLazy;
 
         if (KeyguardWmStateRefactor.isEnabled()) {
             WindowManagerLockscreenVisibilityViewBinder.bind(
@@ -440,7 +444,11 @@
         public void addStateMonitorCallback(IKeyguardStateCallback callback) {
             trace("addStateMonitorCallback");
             checkPermission();
-            mKeyguardViewMediator.addStateMonitorCallback(callback);
+            if (SceneContainerFlag.isEnabled()) {
+                mKeyguardStateCallbackStartableLazy.get().addCallback(callback);
+            } else {
+                mKeyguardViewMediator.addStateMonitorCallback(callback);
+            }
         }
 
         @Override // Binder interface
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewMediator.java b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewMediator.java
index b70dbe2..36b7ed2 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewMediator.java
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewMediator.java
@@ -3801,6 +3801,10 @@
     }
 
     private void notifyDefaultDisplayCallbacks(boolean showing) {
+        if (SceneContainerFlag.isEnabled()) {
+            return;
+        }
+
         // TODO(b/140053364)
         whitelistIpcs(() -> {
             int size = mKeyguardStateCallbacks.size();
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepository.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepository.kt
index 882f231..dd3e619 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/BiometricSettingsRepository.kt
@@ -188,35 +188,33 @@
         )
 
     private val isFingerprintEnrolled: Flow<Boolean> =
-        selectedUserId
-            .flatMapLatest { currentUserId ->
-                conflatedCallbackFlow {
-                    val callback =
-                        object : AuthController.Callback {
-                            override fun onEnrollmentsChanged(
-                                sensorBiometricType: BiometricType,
-                                userId: Int,
-                                hasEnrollments: Boolean
-                            ) {
-                                if (sensorBiometricType.isFingerprint && userId == currentUserId) {
-                                    trySendWithFailureLogging(
-                                        hasEnrollments,
-                                        TAG,
-                                        "update fpEnrollment"
-                                    )
-                                }
+        selectedUserId.flatMapLatest { currentUserId ->
+            conflatedCallbackFlow {
+                val callback =
+                    object : AuthController.Callback {
+                        override fun onEnrollmentsChanged(
+                            sensorBiometricType: BiometricType,
+                            userId: Int,
+                            hasEnrollments: Boolean
+                        ) {
+                            if (sensorBiometricType.isFingerprint && userId == currentUserId) {
+                                trySendWithFailureLogging(
+                                    hasEnrollments,
+                                    TAG,
+                                    "update fpEnrollment"
+                                )
                             }
                         }
-                    authController.addCallback(callback)
-                    awaitClose { authController.removeCallback(callback) }
-                }
+                    }
+                authController.addCallback(callback)
+                trySendWithFailureLogging(
+                    authController.isFingerprintEnrolled(currentUserId),
+                    TAG,
+                    "Initial value of fingerprint enrollment"
+                )
+                awaitClose { authController.removeCallback(callback) }
             }
-            .stateIn(
-                scope,
-                started = SharingStarted.Eagerly,
-                initialValue =
-                    authController.isFingerprintEnrolled(userRepository.getSelectedUserInfo().id)
-            )
+        }
 
     private val isFaceEnrolled: Flow<Boolean> =
         selectedUserId.flatMapLatest { selectedUserId: Int ->
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/TrustRepository.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/TrustRepository.kt
index 6522439..bd5d096 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/TrustRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/TrustRepository.kt
@@ -23,11 +23,13 @@
 import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.keyguard.shared.model.ActiveUnlockModel
 import com.android.systemui.keyguard.shared.model.TrustManagedModel
 import com.android.systemui.keyguard.shared.model.TrustModel
 import com.android.systemui.user.data.repository.UserRepository
 import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.channels.awaitClose
@@ -44,6 +46,7 @@
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.shareIn
 import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
 
 /** Encapsulates any state relevant to trust agents and trust grants. */
 interface TrustRepository {
@@ -51,7 +54,7 @@
     val isCurrentUserTrustUsuallyManaged: StateFlow<Boolean>
 
     /** Flow representing whether the current user is trusted. */
-    val isCurrentUserTrusted: Flow<Boolean>
+    val isCurrentUserTrusted: StateFlow<Boolean>
 
     /** Flow representing whether active unlock is running for the current user. */
     val isCurrentUserActiveUnlockRunning: Flow<Boolean>
@@ -63,6 +66,9 @@
 
     /** A trust agent is requesting to dismiss the keyguard from a trust change. */
     val trustAgentRequestingToDismissKeyguard: Flow<TrustModel>
+
+    /** Reports a keyguard visibility change. */
+    suspend fun reportKeyguardShowingChanged()
 }
 
 @OptIn(ExperimentalCoroutinesApi::class)
@@ -71,6 +77,7 @@
 @Inject
 constructor(
     @Application private val applicationScope: CoroutineScope,
+    @Background private val backgroundDispatcher: CoroutineDispatcher,
     private val userRepository: UserRepository,
     private val trustManager: TrustManager,
     private val logger: TrustRepositoryLogger,
@@ -191,11 +198,26 @@
     private fun isUserTrustManaged(userId: Int) =
         trustManagedForUser[userId]?.isTrustManaged ?: false
 
-    override val isCurrentUserTrusted: Flow<Boolean>
+    override val isCurrentUserTrusted: StateFlow<Boolean>
         get() =
             combine(trust, userRepository.selectedUserInfo, ::Pair)
-                .map { latestTrustModelForUser[it.second.id]?.isTrusted ?: false }
+                .map { isCurrentUserTrusted(it.second.id) }
                 .distinctUntilChanged()
                 .onEach { logger.isCurrentUserTrusted(it) }
                 .onStart { emit(false) }
+                .stateIn(
+                    scope = applicationScope,
+                    started = SharingStarted.WhileSubscribed(),
+                    initialValue = isCurrentUserTrusted(),
+                )
+
+    private fun isCurrentUserTrusted(
+        selectedUserId: Int = userRepository.getSelectedUserInfo().id
+    ): Boolean {
+        return latestTrustModelForUser[selectedUserId]?.isTrusted ?: false
+    }
+
+    override suspend fun reportKeyguardShowingChanged() {
+        withContext(backgroundDispatcher) { trustManager.reportKeyguardShowingChanged() }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/TrustInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/TrustInteractor.kt
index 2ff6e16..73248bb 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/TrustInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/TrustInteractor.kt
@@ -17,14 +17,21 @@
 package com.android.systemui.keyguard.domain.interactor
 
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.keyguard.data.repository.TrustRepository
 import javax.inject.Inject
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
 
 /** Encapsulates any state relevant to trust agents and trust grants. */
 @SysUISingleton
-class TrustInteractor @Inject constructor(repository: TrustRepository) {
+class TrustInteractor
+@Inject
+constructor(
+    @Application private val applicationScope: CoroutineScope,
+    private val repository: TrustRepository,
+) {
     /**
      * Whether the current user has a trust agent enabled. This is true if the user has at least one
      * trust agent enabled in settings.
@@ -39,5 +46,10 @@
     val isTrustAgentCurrentlyAllowed: StateFlow<Boolean> = repository.isCurrentUserTrustManaged
 
     /** Whether the current user is trusted by any of the enabled trust agents. */
-    val isTrusted: Flow<Boolean> = repository.isCurrentUserTrusted
+    val isTrusted: StateFlow<Boolean> = repository.isCurrentUserTrusted
+
+    /** Reports a keyguard visibility change. */
+    fun reportKeyguardShowingChanged() {
+        applicationScope.launch { repository.reportKeyguardShowingChanged() }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardIndicationAreaBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardIndicationAreaBinder.kt
index 3e4253b..ba94f45 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardIndicationAreaBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardIndicationAreaBinder.kt
@@ -133,6 +133,12 @@
                             configurationBasedDimensions.value = loadFromResources(view)
                         }
                     }
+
+                    launch("$TAG#viewModel.visible") {
+                        viewModel.visible.collect { visible ->
+                            indicationController.setVisible(visible)
+                        }
+                    }
                 }
             }
         return disposables
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModel.kt
index a758720d..609b571d 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardIndicationAreaViewModel.kt
@@ -17,6 +17,7 @@
 package com.android.systemui.keyguard.ui.viewmodel
 
 import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor
+import com.android.systemui.communal.domain.interactor.CommunalSceneInteractor
 import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.doze.util.BurnInHelperWrapper
@@ -28,9 +29,10 @@
 import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
 import com.android.systemui.keyguard.shared.model.BurnInModel
 import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.keyguard.shared.model.StatusBarState
 import com.android.systemui.res.R
+import com.android.systemui.util.kotlin.BooleanFlowOperators.anyOf
 import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
@@ -44,14 +46,15 @@
 @Inject
 constructor(
     private val keyguardInteractor: KeyguardInteractor,
-    private val bottomAreaInteractor: KeyguardBottomAreaInteractor,
+    bottomAreaInteractor: KeyguardBottomAreaInteractor,
     keyguardBottomAreaViewModel: KeyguardBottomAreaViewModel,
     private val burnInHelperWrapper: BurnInHelperWrapper,
-    private val burnInInteractor: BurnInInteractor,
-    private val shortcutsCombinedViewModel: KeyguardQuickAffordancesCombinedViewModel,
+    burnInInteractor: BurnInInteractor,
+    shortcutsCombinedViewModel: KeyguardQuickAffordancesCombinedViewModel,
     configurationInteractor: ConfigurationInteractor,
     keyguardTransitionInteractor: KeyguardTransitionInteractor,
-    @Background private val backgroundCoroutineContext: CoroutineContext,
+    communalSceneInteractor: CommunalSceneInteractor,
+    @Background private val backgroundDispatcher: CoroutineDispatcher,
     @Main private val mainDispatcher: CoroutineDispatcher,
 ) {
 
@@ -61,6 +64,13 @@
     /** An observable for the alpha level for the entire bottom area. */
     val alpha: Flow<Float> = keyguardBottomAreaViewModel.alpha
 
+    /** An observable for the visibility value for the indication area view. */
+    val visible: Flow<Boolean> =
+        anyOf(
+            keyguardInteractor.statusBarState.map { state -> state == StatusBarState.KEYGUARD },
+            communalSceneInteractor.isCommunalVisible
+        )
+
     /** An observable for whether the indication area should be padded. */
     val isIndicationAreaPadded: Flow<Boolean> =
         if (KeyguardBottomAreaRefactor.isEnabled) {
@@ -97,7 +107,7 @@
                 )
             }
             .distinctUntilChanged()
-            .flowOn(backgroundCoroutineContext)
+            .flowOn(backgroundDispatcher)
 
     /** An observable for the x-offset by which the indication area should be translated. */
     val indicationAreaTranslationX: Flow<Float> =
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java b/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java
index 80c4379..2e5ff9d 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/NavBarHelper.java
@@ -56,6 +56,8 @@
 import android.view.accessibility.AccessibilityManager;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 
 import com.android.internal.accessibility.common.ShortcutConstants;
 import com.android.systemui.Dumpable;
@@ -64,12 +66,14 @@
 import com.android.systemui.accessibility.SystemActions;
 import com.android.systemui.assist.AssistManager;
 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.navigationbar.gestural.EdgeBackGestureHandler;
 import com.android.systemui.recents.OverviewProxyService;
 import com.android.systemui.settings.DisplayTracker;
 import com.android.systemui.settings.UserTracker;
+import com.android.systemui.shared.rotation.RotationPolicyUtil;
 import com.android.systemui.shared.system.QuickStepContract;
 import com.android.systemui.statusbar.CommandQueue;
 import com.android.systemui.statusbar.NotificationShadeWindowController;
@@ -108,6 +112,7 @@
 
     private final Handler mHandler = new Handler(Looper.getMainLooper());
     private final Executor mMainExecutor;
+    private final Handler mBgHandler;
     private final AccessibilityManager mAccessibilityManager;
     private final Lazy<AssistManager> mAssistManagerLazy;
     private final Lazy<Optional<CentralSurfaces>> mCentralSurfacesOptionalLazy;
@@ -160,13 +165,15 @@
 
     // Listens for changes to display rotation
     private final IRotationWatcher mRotationWatcher = new IRotationWatcher.Stub() {
+        @WorkerThread
         @Override
         public void onRotationChanged(final int rotation) {
             // We need this to be scheduled as early as possible to beat the redrawing of
             // window in response to the orientation change.
+            @Nullable Boolean isRotationLocked = RotationPolicyUtil.isRotationLocked(mContext);
             mHandler.postAtFrontOfQueue(() -> {
                 mRotationWatcherRotation = rotation;
-                dispatchRotationChanged(rotation);
+                dispatchRotationChanged(rotation, isRotationLocked);
             });
         }
     };
@@ -194,7 +201,8 @@
             ConfigurationController configurationController,
             DumpManager dumpManager,
             CommandQueue commandQueue,
-            @Main Executor mainExecutor) {
+            @Main Executor mainExecutor,
+            @Background Handler bgHandler) {
         // b/319489709: This component shouldn't be running for a non-primary user
         if (!Process.myUserHandle().equals(UserHandle.SYSTEM)) {
             Log.wtf(TAG, "Unexpected initialization for non-primary user", new Throwable());
@@ -215,6 +223,7 @@
         mDefaultDisplayId = displayTracker.getDefaultDisplayId();
         mEdgeBackGestureHandler = edgeBackGestureHandlerFactory.create(context);
         mMainExecutor = mainExecutor;
+        mBgHandler = bgHandler;
 
         mNavBarMode = navigationModeController.addListener(this);
         mCommandQueue.addCallback(this);
@@ -322,7 +331,13 @@
             listener.updateAssistantAvailable(mAssistantAvailable, mLongPressHomeEnabled);
         }
         listener.updateWallpaperVisibility(mWallpaperVisible, mDefaultDisplayId);
-        listener.updateRotationWatcherState(mRotationWatcherRotation);
+
+        mBgHandler.post(() -> {
+            Boolean isRotationLocked = RotationPolicyUtil.isRotationLocked(mContext);
+            mMainExecutor.execute(
+                    () -> listener.updateRotationWatcherState(
+                            mRotationWatcherRotation, isRotationLocked));
+        });
     }
 
     /**
@@ -526,9 +541,9 @@
         }
     }
 
-    private void dispatchRotationChanged(int rotation) {
+    private void dispatchRotationChanged(int rotation, @Nullable Boolean isRotationLocked) {
         for (NavbarTaskbarStateUpdater listener : mStateListeners) {
-            listener.updateRotationWatcherState(rotation);
+            listener.updateRotationWatcherState(rotation, isRotationLocked);
         }
     }
 
@@ -544,7 +559,7 @@
         void updateAccessibilityServicesState();
         void updateAssistantAvailable(boolean available, boolean longPressHomeEnabled);
         default void updateWallpaperVisibility(boolean visible, int displayId) {}
-        default void updateRotationWatcherState(int rotation) {}
+        default void updateRotationWatcherState(int rotation, @Nullable Boolean isRotationLocked) {}
     }
 
     /** Data class to help Taskbar/Navbar initiate state correctly when switching between the two.*/
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java
index 07289cb..69aa450 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java
@@ -136,6 +136,7 @@
 import com.android.systemui.shared.navigationbar.RegionSamplingHelper;
 import com.android.systemui.shared.recents.utilities.Utilities;
 import com.android.systemui.shared.rotation.RotationButtonController;
+import com.android.systemui.shared.rotation.RotationPolicyUtil;
 import com.android.systemui.shared.system.QuickStepContract;
 import com.android.systemui.shared.system.SysUiStatsLog;
 import com.android.systemui.shared.system.TaskStackChangeListener;
@@ -366,9 +367,11 @@
                 }
 
                 @Override
-                public void updateRotationWatcherState(int rotation) {
+                public void updateRotationWatcherState(
+                        int rotation, @Nullable Boolean isRotationLocked) {
                     if (mIsOnDefaultDisplay && mView != null) {
-                        mView.getRotationButtonController().onRotationWatcherChanged(rotation);
+                        RotationButtonController controller = mView.getRotationButtonController();
+                        controller.onRotationWatcherChanged(rotation, isRotationLocked);
                         if (mView.needsReorient(rotation)) {
                             repositionNavigationBar(rotation);
                         }
@@ -819,8 +822,9 @@
 
             // Reset user rotation pref to match that of the WindowManager if starting in locked
             // mode. This will automatically happen when switching from auto-rotate to locked mode.
-            if (display != null && rotationButtonController.isRotationLocked()) {
-                rotationButtonController.setRotationLockedAtAngle(
+            @Nullable Boolean isRotationLocked = RotationPolicyUtil.isRotationLocked(mContext);
+            if (display != null && isRotationLocked) {
+                rotationButtonController.setRotationLockedAtAngle(isRotationLocked,
                         display.getRotation(), /* caller= */ "NavigationBar#onViewAttached");
             }
         } else {
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarControllerImpl.java b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarControllerImpl.java
index b177b0b..1c2a087 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarControllerImpl.java
@@ -23,6 +23,7 @@
 import static com.android.systemui.navigationbar.gestural.EdgeBackGestureHandler.DEBUG_MISSING_GESTURE_TAG;
 import static com.android.systemui.shared.recents.utilities.Utilities.isLargeScreen;
 import static com.android.wm.shell.Flags.enableTaskbarNavbarUnification;
+import static com.android.wm.shell.Flags.enableTaskbarOnPhones;
 
 import android.content.Context;
 import android.content.pm.ActivityInfo;
@@ -285,8 +286,10 @@
 
     @VisibleForTesting
     boolean supportsTaskbar() {
-        // Enable for tablets, unfolded state on a foldable device or (non handheld AND flag is set)
-        return mIsLargeScreen || (!mIsPhone && enableTaskbarNavbarUnification());
+        // Enable for tablets, unfolded state on a foldable device, (non handheld AND flag is set),
+        // or handheld when enableTaskbarOnPhones() returns true.
+        boolean foldedOrPhone = !mIsPhone || enableTaskbarOnPhones();
+        return mIsLargeScreen || (foldedOrPhone && enableTaskbarNavbarUnification());
     }
 
     private final CommandQueue.Callbacks mCommandQueueCallbacks = new CommandQueue.Callbacks() {
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PaginatedBaseLayoutType.kt
similarity index 76%
copy from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
copy to packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PaginatedBaseLayoutType.kt
index d8af3fa..0285cbd 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PaginatedBaseLayoutType.kt
@@ -14,8 +14,11 @@
  * limitations under the License.
  */
 
-package com.android.systemui.qs.panels.data.repository
+package com.android.systemui.qs.panels.dagger
 
-import com.android.systemui.kosmos.Kosmos
+import javax.inject.Qualifier
 
-val Kosmos.infiniteGridSizeRepository by Kosmos.Fixture { InfiniteGridSizeRepository() }
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class PaginatedBaseLayoutType
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt
index 7b67993..c214361 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/dagger/PanelsModule.kt
@@ -30,18 +30,21 @@
 import com.android.systemui.qs.panels.shared.model.GridLayoutType
 import com.android.systemui.qs.panels.shared.model.IconLabelVisibilityLog
 import com.android.systemui.qs.panels.shared.model.InfiniteGridLayoutType
+import com.android.systemui.qs.panels.shared.model.PaginatedGridLayoutType
 import com.android.systemui.qs.panels.shared.model.PartitionedGridLayoutType
 import com.android.systemui.qs.panels.shared.model.StretchedGridLayoutType
 import com.android.systemui.qs.panels.ui.compose.GridLayout
 import com.android.systemui.qs.panels.ui.compose.InfiniteGridLayout
+import com.android.systemui.qs.panels.ui.compose.PaginatableGridLayout
+import com.android.systemui.qs.panels.ui.compose.PaginatedGridLayout
 import com.android.systemui.qs.panels.ui.compose.PartitionedGridLayout
 import com.android.systemui.qs.panels.ui.compose.StretchedGridLayout
+import com.android.systemui.qs.panels.ui.viewmodel.FixedColumnsSizeViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.FixedColumnsSizeViewModelImpl
 import com.android.systemui.qs.panels.ui.viewmodel.IconLabelVisibilityViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.IconLabelVisibilityViewModelImpl
 import com.android.systemui.qs.panels.ui.viewmodel.IconTilesViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.IconTilesViewModelImpl
-import com.android.systemui.qs.panels.ui.viewmodel.InfiniteGridSizeViewModel
-import com.android.systemui.qs.panels.ui.viewmodel.InfiniteGridSizeViewModelImpl
 import dagger.Binds
 import dagger.Module
 import dagger.Provides
@@ -62,14 +65,24 @@
 
     @Binds fun bindIconTilesViewModel(impl: IconTilesViewModelImpl): IconTilesViewModel
 
-    @Binds fun bindGridSizeViewModel(impl: InfiniteGridSizeViewModelImpl): InfiniteGridSizeViewModel
+    @Binds fun bindGridSizeViewModel(impl: FixedColumnsSizeViewModelImpl): FixedColumnsSizeViewModel
 
     @Binds
     fun bindIconLabelVisibilityViewModel(
         impl: IconLabelVisibilityViewModelImpl
     ): IconLabelVisibilityViewModel
 
-    @Binds @Named("Default") fun bindDefaultGridLayout(impl: PartitionedGridLayout): GridLayout
+    @Binds
+    @PaginatedBaseLayoutType
+    fun bindPaginatedBaseGridLayout(impl: PartitionedGridLayout): PaginatableGridLayout
+
+    @Binds
+    @PaginatedBaseLayoutType
+    fun bindPaginatedBaseConsistencyInteractor(
+        impl: NoopGridConsistencyInteractor
+    ): GridTypeConsistencyInteractor
+
+    @Binds @Named("Default") fun bindDefaultGridLayout(impl: PaginatedGridLayout): GridLayout
 
     companion object {
         @Provides
@@ -109,6 +122,14 @@
         }
 
         @Provides
+        @IntoSet
+        fun providePaginatedGridLayout(
+            gridLayout: PaginatedGridLayout
+        ): Pair<GridLayoutType, GridLayout> {
+            return Pair(PaginatedGridLayoutType, gridLayout)
+        }
+
+        @Provides
         fun provideGridLayoutMap(
             entries: Set<@JvmSuppressWildcards Pair<GridLayoutType, GridLayout>>
         ): Map<GridLayoutType, GridLayout> {
@@ -147,6 +168,14 @@
         }
 
         @Provides
+        @IntoSet
+        fun providePaginatedGridConsistencyInteractor(
+            @PaginatedBaseLayoutType consistencyInteractor: GridTypeConsistencyInteractor,
+        ): Pair<GridLayoutType, GridTypeConsistencyInteractor> {
+            return Pair(PaginatedGridLayoutType, consistencyInteractor)
+        }
+
+        @Provides
         fun provideGridConsistencyInteractorMap(
             entries: Set<@JvmSuppressWildcards Pair<GridLayoutType, GridTypeConsistencyInteractor>>
         ): Map<GridLayoutType, GridTypeConsistencyInteractor> {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/FixedColumnsRepository.kt
similarity index 94%
rename from packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepository.kt
rename to packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/FixedColumnsRepository.kt
index 43ccdf66..32ce973 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/FixedColumnsRepository.kt
@@ -23,7 +23,7 @@
 import kotlinx.coroutines.flow.asStateFlow
 
 @SysUISingleton
-class InfiniteGridSizeRepository @Inject constructor() {
+class FixedColumnsRepository @Inject constructor() {
     // Number of columns in the narrowest state for consistency
     private val _columns = MutableStateFlow(4)
     val columns: StateFlow<Int> = _columns.asStateFlow()
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt
index 44d8688..47c4ffd 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/GridLayoutTypeRepository.kt
@@ -18,7 +18,7 @@
 
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.qs.panels.shared.model.GridLayoutType
-import com.android.systemui.qs.panels.shared.model.PartitionedGridLayoutType
+import com.android.systemui.qs.panels.shared.model.PaginatedGridLayoutType
 import javax.inject.Inject
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
@@ -26,13 +26,14 @@
 
 interface GridLayoutTypeRepository {
     val layout: StateFlow<GridLayoutType>
+
     fun setLayout(type: GridLayoutType)
 }
 
 @SysUISingleton
 class GridLayoutTypeRepositoryImpl @Inject constructor() : GridLayoutTypeRepository {
     private val _layout: MutableStateFlow<GridLayoutType> =
-        MutableStateFlow(PartitionedGridLayoutType)
+        MutableStateFlow(PaginatedGridLayoutType)
     override val layout = _layout.asStateFlow()
 
     override fun setLayout(type: GridLayoutType) {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepository.kt
new file mode 100644
index 0000000..26b2e2b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepository.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.qs.panels.data.repository
+
+import android.content.res.Resources
+import com.android.systemui.common.ui.data.repository.ConfigurationRepository
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.res.R
+import com.android.systemui.util.kotlin.emitOnStart
+import javax.inject.Inject
+import kotlinx.coroutines.flow.map
+
+/**
+ * Provides the number of [rows] to use with a paginated grid, by tracking the resource
+ * [R.integer.quick_settings_max_rows].
+ */
+@SysUISingleton
+class PaginatedGridRepository
+@Inject
+constructor(
+    @Main private val resources: Resources,
+    configurationRepository: ConfigurationRepository,
+) {
+    val rows =
+        configurationRepository.onConfigurationChange.emitOnStart().map {
+            resources.getInteger(R.integer.quick_settings_max_rows)
+        }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/FixedColumnsSizeInteractor.kt
similarity index 83%
rename from packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractor.kt
rename to packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/FixedColumnsSizeInteractor.kt
index 13c6072..9591002 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/FixedColumnsSizeInteractor.kt
@@ -17,11 +17,11 @@
 package com.android.systemui.qs.panels.domain.interactor
 
 import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.qs.panels.data.repository.InfiniteGridSizeRepository
+import com.android.systemui.qs.panels.data.repository.FixedColumnsRepository
 import javax.inject.Inject
 import kotlinx.coroutines.flow.StateFlow
 
 @SysUISingleton
-class InfiniteGridSizeInteractor @Inject constructor(repo: InfiniteGridSizeRepository) {
+class FixedColumnsSizeInteractor @Inject constructor(repo: FixedColumnsRepository) {
     val columns: StateFlow<Int> = repo.columns
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt
index e99c64c..0fe79af 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractor.kt
@@ -28,7 +28,7 @@
 @Inject
 constructor(
     private val iconTilesInteractor: IconTilesInteractor,
-    private val gridSizeInteractor: InfiniteGridSizeInteractor
+    private val gridSizeInteractor: FixedColumnsSizeInteractor
 ) : GridTypeConsistencyInteractor {
 
     /**
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/NoopConsistencyInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/PaginatedGridInteractor.kt
similarity index 75%
rename from packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/NoopConsistencyInteractor.kt
rename to packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/PaginatedGridInteractor.kt
index 97ceacc..d7d1ce9 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/NoopConsistencyInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/domain/interactor/PaginatedGridInteractor.kt
@@ -17,10 +17,14 @@
 package com.android.systemui.qs.panels.domain.interactor
 
 import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.qs.pipeline.shared.TileSpec
+import com.android.systemui.qs.panels.data.repository.PaginatedGridRepository
 import javax.inject.Inject
 
 @SysUISingleton
-class NoopConsistencyInteractor @Inject constructor() : GridTypeConsistencyInteractor {
-    override fun reconcileTiles(tiles: List<TileSpec>): List<TileSpec> = tiles
+class PaginatedGridInteractor
+@Inject
+constructor(paginatedGridRepository: PaginatedGridRepository) {
+    val rows = paginatedGridRepository.rows
+
+    val defaultRows = 4
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt
index 9550ddb..b1942fe 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/shared/model/GridLayoutType.kt
@@ -34,3 +34,6 @@
 
 /** Grid type grouping large tiles on top and icon tiles at the bottom. */
 data object PartitionedGridLayoutType : GridLayoutType
+
+/** Grid type for a paginated list of tiles. It will delegate to some other layout type. */
+data object PaginatedGridLayoutType : GridLayoutType
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/GridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/GridLayout.kt
index 8806931..e2f6bcf 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/GridLayout.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/GridLayout.kt
@@ -18,15 +18,19 @@
 
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import com.android.systemui.qs.panels.shared.model.SizedTile
+import com.android.systemui.qs.panels.shared.model.TileRow
 import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
 import com.android.systemui.qs.pipeline.shared.TileSpec
 
+/** A layout of tiles, indicating how they should be composed when showing in QS or in edit mode. */
 interface GridLayout {
     @Composable
     fun TileGrid(
         tiles: List<TileViewModel>,
         modifier: Modifier,
+        editModeStart: () -> Unit,
     )
 
     @Composable
@@ -37,3 +41,49 @@
         onRemoveTile: (TileSpec) -> Unit,
     )
 }
+
+/**
+ * A type of [GridLayout] that can be paginated, to use together with [PaginatedGridLayout].
+ *
+ * [splitIntoPages] determines how to split a list of tiles based on the number of rows and columns
+ * available.
+ */
+interface PaginatableGridLayout : GridLayout {
+    fun splitIntoPages(
+        tiles: List<TileViewModel>,
+        rows: Int,
+        columns: Int,
+    ): List<List<TileViewModel>>
+
+    companion object {
+
+        /**
+         * Splits a list of [SizedTile] into rows, each with at most [columns] occupied.
+         *
+         * It will leave gaps at the end of a row if the next [SizedTile] has [SizedTile.width] that
+         * is larger than the space remaining in the row.
+         */
+        fun splitInRows(
+            tiles: List<SizedTile<TileViewModel>>,
+            columns: Int
+        ): List<List<SizedTile<TileViewModel>>> {
+            val row = TileRow<TileViewModel>(columns)
+
+            return buildList {
+                for (tile in tiles) {
+                    check(tile.width <= columns)
+                    if (!row.maybeAddTile(tile)) {
+                        // Couldn't add tile to previous row, create a row with the current tiles
+                        // and start a new one
+                        add(row.tiles)
+                        row.clear()
+                        row.maybeAddTile(tile)
+                    }
+                }
+                if (row.tiles.isNotEmpty()) {
+                    add(row.tiles)
+                }
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt
index 2f0fe22..ea97f0d 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/InfiniteGridLayout.kt
@@ -26,9 +26,10 @@
 import androidx.compose.ui.res.dimensionResource
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.qs.panels.shared.model.SizedTile
 import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.FixedColumnsSizeViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.IconTilesViewModel
-import com.android.systemui.qs.panels.ui.viewmodel.InfiniteGridSizeViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
 import com.android.systemui.qs.pipeline.shared.TileSpec
 import com.android.systemui.res.R
@@ -39,13 +40,14 @@
 @Inject
 constructor(
     private val iconTilesViewModel: IconTilesViewModel,
-    private val gridSizeViewModel: InfiniteGridSizeViewModel,
-) : GridLayout {
+    private val gridSizeViewModel: FixedColumnsSizeViewModel,
+) : PaginatableGridLayout {
 
     @Composable
     override fun TileGrid(
         tiles: List<TileViewModel>,
         modifier: Modifier,
+        editModeStart: () -> Unit,
     ) {
         DisposableEffect(tiles) {
             val token = Any()
@@ -55,16 +57,8 @@
         val columns by gridSizeViewModel.columns.collectAsStateWithLifecycle()
 
         TileLazyGrid(modifier = modifier, columns = GridCells.Fixed(columns)) {
-            items(
-                tiles.size,
-                span = { index ->
-                    if (iconTilesViewModel.isIconTile(tiles[index].spec)) {
-                        GridItemSpan(1)
-                    } else {
-                        GridItemSpan(2)
-                    }
-                }
-            ) { index ->
+            items(tiles.size, span = { index -> GridItemSpan(tiles[index].spec.width()) }) { index
+                ->
                 Tile(
                     tile = tiles[index],
                     iconOnly = iconTilesViewModel.isIconTile(tiles[index].spec),
@@ -92,4 +86,22 @@
             onRemoveTile = onRemoveTile,
         )
     }
+
+    override fun splitIntoPages(
+        tiles: List<TileViewModel>,
+        rows: Int,
+        columns: Int,
+    ): List<List<TileViewModel>> {
+
+        return PaginatableGridLayout.splitInRows(
+                tiles.map { SizedTile(it, it.spec.width()) },
+                columns,
+            )
+            .chunked(rows)
+            .map { it.flatten().map { it.tile } }
+    }
+
+    private fun TileSpec.width(): Int {
+        return if (iconTilesViewModel.isIconTile(this)) 1 else 2
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PagerDots.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PagerDots.kt
new file mode 100644
index 0000000..7de22161
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PagerDots.kt
@@ -0,0 +1,166 @@
+/*
+ * 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.qs.panels.ui.compose
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.semantics.pageLeft
+import androidx.compose.ui.semantics.pageRight
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.math.absoluteValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@Composable
+fun PagerDots(
+    pagerState: PagerState,
+    activeColor: Color,
+    nonActiveColor: Color,
+    modifier: Modifier = Modifier,
+    dotSize: Dp = 6.dp,
+    spaceSize: Dp = 4.dp,
+) {
+    if (pagerState.pageCount < 2) {
+        return
+    }
+    val inPageTransition by
+        remember(pagerState) {
+            derivedStateOf {
+                pagerState.currentPageOffsetFraction.absoluteValue > 0.01 &&
+                    !pagerState.isOverscrolling()
+            }
+        }
+    val coroutineScope = rememberCoroutineScope()
+    Row(
+        modifier =
+            modifier
+                .wrapContentWidth()
+                .pagerDotsSemantics(
+                    pagerState,
+                    coroutineScope,
+                ),
+        horizontalArrangement = spacedBy(spaceSize),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (!inPageTransition) {
+            repeat(pagerState.pageCount) { i ->
+                // We use canvas directly to only invalidate the draw phase when the page is
+                // changing.
+                Canvas(Modifier.size(dotSize)) {
+                    if (pagerState.currentPage == i) {
+                        drawCircle(activeColor)
+                    } else {
+                        drawCircle(nonActiveColor)
+                    }
+                }
+            }
+        } else {
+            val doubleDotWidth = dotSize * 2 + spaceSize
+            val cornerRadius = dotSize / 2
+            val width by
+                animateDpAsState(targetValue = if (inPageTransition) doubleDotWidth else dotSize)
+
+            fun DrawScope.drawDoubleRect() {
+                drawRoundRect(
+                    color = activeColor,
+                    size = Size(width.toPx(), dotSize.toPx()),
+                    cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx())
+                )
+            }
+
+            repeat(pagerState.pageCount) { page ->
+                Canvas(Modifier.size(dotSize)) {
+                    val withPrevious = pagerState.currentPageOffsetFraction < 0
+                    val ltr = layoutDirection == LayoutDirection.Ltr
+                    if (
+                        withPrevious && page == (pagerState.currentPage - 1) ||
+                            !withPrevious && page == pagerState.currentPage
+                    ) {
+                        if (ltr) {
+                            drawDoubleRect()
+                        }
+                    } else if (
+                        withPrevious && page == pagerState.currentPage ||
+                            !withPrevious && page == (pagerState.currentPage + 1)
+                    ) {
+                        if (!ltr) {
+                            drawDoubleRect()
+                        }
+                    } else {
+                        drawCircle(nonActiveColor)
+                    }
+                }
+            }
+        }
+    }
+}
+
+private fun Modifier.pagerDotsSemantics(
+    pagerState: PagerState,
+    coroutineScope: CoroutineScope,
+): Modifier {
+    return then(
+        Modifier.semantics {
+            pageLeft {
+                if (pagerState.canScrollBackward) {
+                    coroutineScope.launch {
+                        pagerState.animateScrollToPage(pagerState.currentPage - 1)
+                    }
+                    true
+                } else {
+                    false
+                }
+            }
+            pageRight {
+                if (pagerState.canScrollForward) {
+                    coroutineScope.launch {
+                        pagerState.animateScrollToPage(pagerState.currentPage + 1)
+                    }
+                    true
+                } else {
+                    false
+                }
+            }
+            stateDescription = "Page ${pagerState.settledPage + 1} of ${pagerState.pageCount}"
+        }
+    )
+}
+
+private fun PagerState.isOverscrolling(): Boolean {
+    val position = currentPage + currentPageOffsetFraction
+    return position < 0 || position > pageCount - 1
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt
new file mode 100644
index 0000000..2ee957e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.qs.panels.ui.compose
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.systemui.qs.panels.dagger.PaginatedBaseLayoutType
+import com.android.systemui.qs.panels.ui.compose.PaginatedGridLayout.Dimensions.FooterHeight
+import com.android.systemui.qs.panels.ui.compose.PaginatedGridLayout.Dimensions.InterPageSpacing
+import com.android.systemui.qs.panels.ui.viewmodel.PaginatedGridViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
+import com.android.systemui.res.R
+import javax.inject.Inject
+
+class PaginatedGridLayout
+@Inject
+constructor(
+    private val viewModel: PaginatedGridViewModel,
+    @PaginatedBaseLayoutType private val delegateGridLayout: PaginatableGridLayout,
+) : GridLayout by delegateGridLayout {
+    @Composable
+    override fun TileGrid(
+        tiles: List<TileViewModel>,
+        modifier: Modifier,
+        editModeStart: () -> Unit,
+    ) {
+        DisposableEffect(tiles) {
+            val token = Any()
+            tiles.forEach { it.startListening(token) }
+            onDispose { tiles.forEach { it.stopListening(token) } }
+        }
+        val columns by viewModel.columns.collectAsStateWithLifecycle()
+        val rows by viewModel.rows.collectAsStateWithLifecycle()
+
+        val pages =
+            remember(tiles, columns, rows) {
+                delegateGridLayout.splitIntoPages(tiles, rows = rows, columns = columns)
+            }
+
+        val pagerState = rememberPagerState(0) { pages.size }
+
+        Column {
+            HorizontalPager(
+                state = pagerState,
+                modifier = Modifier,
+                pageSpacing = if (pages.size > 1) InterPageSpacing else 0.dp,
+                beyondViewportPageCount = 1,
+                verticalAlignment = Alignment.Top,
+            ) {
+                val page = pages[it]
+
+                delegateGridLayout.TileGrid(tiles = page, modifier = Modifier, editModeStart = {})
+            }
+            Box(
+                modifier = Modifier.height(FooterHeight).fillMaxWidth(),
+            ) {
+                PagerDots(
+                    pagerState = pagerState,
+                    activeColor = MaterialTheme.colorScheme.primary,
+                    nonActiveColor = MaterialTheme.colorScheme.surfaceVariant,
+                    modifier = Modifier.align(Alignment.Center)
+                )
+                CompositionLocalProvider(value = LocalContentColor provides Color.White) {
+                    IconButton(
+                        onClick = editModeStart,
+                        modifier = Modifier.align(Alignment.CenterEnd),
+                    ) {
+                        Icon(
+                            imageVector = Icons.Default.Edit,
+                            contentDescription = stringResource(id = R.string.qs_edit)
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    private object Dimensions {
+        val FooterHeight = 48.dp
+        val InterPageSpacing = 16.dp
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayout.kt
index 9233e76..7f5e474 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayout.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayout.kt
@@ -53,6 +53,7 @@
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.android.compose.modifiers.background
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.qs.panels.shared.model.SizedTile
 import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.PartitionedGridViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
@@ -63,9 +64,13 @@
 
 @SysUISingleton
 class PartitionedGridLayout @Inject constructor(private val viewModel: PartitionedGridViewModel) :
-    GridLayout {
+    PaginatableGridLayout {
     @Composable
-    override fun TileGrid(tiles: List<TileViewModel>, modifier: Modifier) {
+    override fun TileGrid(
+        tiles: List<TileViewModel>,
+        modifier: Modifier,
+        editModeStart: () -> Unit,
+    ) {
         DisposableEffect(tiles) {
             val token = Any()
             tiles.forEach { it.startListening(token) }
@@ -169,6 +174,20 @@
         }
     }
 
+    override fun splitIntoPages(
+        tiles: List<TileViewModel>,
+        rows: Int,
+        columns: Int,
+    ): List<List<TileViewModel>> {
+        val (smallTiles, largeTiles) = tiles.partition { viewModel.isIconTile(it.spec) }
+
+        val sizedLargeTiles = largeTiles.map { SizedTile(it, 2) }
+        val sizedSmallTiles = smallTiles.map { SizedTile(it, 1) }
+        val largeTilesRows = PaginatableGridLayout.splitInRows(sizedLargeTiles, columns)
+        val smallTilesRows = PaginatableGridLayout.splitInRows(sizedSmallTiles, columns)
+        return (largeTilesRows + smallTilesRows).chunked(rows).map { it.flatten().map { it.tile } }
+    }
+
     @Composable
     private fun CurrentTiles(
         tiles: List<EditTileViewModel>,
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt
index 7f4e0a7..4a90102 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/StretchedGridLayout.kt
@@ -30,8 +30,8 @@
 import com.android.systemui.qs.panels.shared.model.SizedTile
 import com.android.systemui.qs.panels.shared.model.TileRow
 import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
+import com.android.systemui.qs.panels.ui.viewmodel.FixedColumnsSizeViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.IconTilesViewModel
-import com.android.systemui.qs.panels.ui.viewmodel.InfiniteGridSizeViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
 import com.android.systemui.qs.pipeline.shared.TileSpec
 import com.android.systemui.res.R
@@ -42,13 +42,14 @@
 @Inject
 constructor(
     private val iconTilesViewModel: IconTilesViewModel,
-    private val gridSizeViewModel: InfiniteGridSizeViewModel,
+    private val gridSizeViewModel: FixedColumnsSizeViewModel,
 ) : GridLayout {
 
     @Composable
     override fun TileGrid(
         tiles: List<TileViewModel>,
         modifier: Modifier,
+        editModeStart: () -> Unit,
     ) {
         DisposableEffect(tiles) {
             val token = Any()
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/TileGrid.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/TileGrid.kt
index 2dab7c3..8c57d41 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/TileGrid.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/TileGrid.kt
@@ -23,9 +23,13 @@
 import com.android.systemui.qs.panels.ui.viewmodel.TileGridViewModel
 
 @Composable
-fun TileGrid(viewModel: TileGridViewModel, modifier: Modifier = Modifier) {
+fun TileGrid(
+    viewModel: TileGridViewModel,
+    modifier: Modifier = Modifier,
+    editModeStart: () -> Unit
+) {
     val gridLayout by viewModel.gridLayout.collectAsStateWithLifecycle()
     val tiles by viewModel.tileViewModels.collectAsStateWithLifecycle(emptyList())
 
-    gridLayout.TileGrid(tiles, modifier)
+    gridLayout.TileGrid(tiles, modifier, editModeStart)
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/FixedColumnsSizeViewModel.kt
similarity index 78%
rename from packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModel.kt
rename to packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/FixedColumnsSizeViewModel.kt
index a4ee58f..865c86b 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/FixedColumnsSizeViewModel.kt
@@ -17,16 +17,16 @@
 package com.android.systemui.qs.panels.ui.viewmodel
 
 import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.qs.panels.domain.interactor.InfiniteGridSizeInteractor
+import com.android.systemui.qs.panels.domain.interactor.FixedColumnsSizeInteractor
 import javax.inject.Inject
 import kotlinx.coroutines.flow.StateFlow
 
-interface InfiniteGridSizeViewModel {
+interface FixedColumnsSizeViewModel {
     val columns: StateFlow<Int>
 }
 
 @SysUISingleton
-class InfiniteGridSizeViewModelImpl @Inject constructor(interactor: InfiniteGridSizeInteractor) :
-    InfiniteGridSizeViewModel {
+class FixedColumnsSizeViewModelImpl @Inject constructor(interactor: FixedColumnsSizeInteractor) :
+    FixedColumnsSizeViewModel {
     override val columns: StateFlow<Int> = interactor.columns
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PaginatedGridViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PaginatedGridViewModel.kt
new file mode 100644
index 0000000..28bf474
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PaginatedGridViewModel.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.qs.panels.ui.viewmodel
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.qs.panels.domain.interactor.PaginatedGridInteractor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+
+@SysUISingleton
+class PaginatedGridViewModel
+@Inject
+constructor(
+    iconTilesViewModel: IconTilesViewModel,
+    gridSizeViewModel: FixedColumnsSizeViewModel,
+    iconLabelVisibilityViewModel: IconLabelVisibilityViewModel,
+    paginatedGridInteractor: PaginatedGridInteractor,
+    @Application applicationScope: CoroutineScope,
+) :
+    IconTilesViewModel by iconTilesViewModel,
+    FixedColumnsSizeViewModel by gridSizeViewModel,
+    IconLabelVisibilityViewModel by iconLabelVisibilityViewModel {
+    val rows =
+        paginatedGridInteractor.rows.stateIn(
+            applicationScope,
+            SharingStarted.WhileSubscribed(),
+            paginatedGridInteractor.defaultRows,
+        )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModel.kt
index 730cf63..2049edb 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModel.kt
@@ -24,9 +24,9 @@
 @Inject
 constructor(
     iconTilesViewModel: IconTilesViewModel,
-    gridSizeViewModel: InfiniteGridSizeViewModel,
+    gridSizeViewModel: FixedColumnsSizeViewModel,
     iconLabelVisibilityViewModel: IconLabelVisibilityViewModel,
 ) :
     IconTilesViewModel by iconTilesViewModel,
-    InfiniteGridSizeViewModel by gridSizeViewModel,
+    FixedColumnsSizeViewModel by gridSizeViewModel,
     IconLabelVisibilityViewModel by iconLabelVisibilityViewModel
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 70f3b84..a3feb2b 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/RecordIssueTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/RecordIssueTile.kt
@@ -46,6 +46,7 @@
 import com.android.systemui.recordissue.IssueRecordingService
 import com.android.systemui.recordissue.IssueRecordingState
 import com.android.systemui.recordissue.RecordIssueDialogDelegate
+import com.android.systemui.recordissue.RecordIssueModule.Companion.TILE_SPEC
 import com.android.systemui.recordissue.TraceurMessageSender
 import com.android.systemui.res.R
 import com.android.systemui.screenrecord.RecordingService
@@ -197,8 +198,4 @@
             expandedAccessibilityClassName = Switch::class.java.name
         }
     }
-
-    companion object {
-        const val TILE_SPEC = "record_issue"
-    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingDataInteractor.kt
new file mode 100644
index 0000000..1af328e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingDataInteractor.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.qs.tiles.impl.irecording
+
+import android.os.UserHandle
+import com.android.systemui.Flags
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
+import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor
+import com.android.systemui.recordissue.IssueRecordingState
+import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.onStart
+
+class IssueRecordingDataInteractor
+@Inject
+constructor(
+    private val state: IssueRecordingState,
+    @Background private val bgCoroutineContext: CoroutineContext,
+) : QSTileDataInteractor<IssueRecordingModel> {
+
+    override fun tileData(
+        user: UserHandle,
+        triggers: Flow<DataUpdateTrigger>
+    ): Flow<IssueRecordingModel> =
+        conflatedCallbackFlow {
+                val listener = Runnable { trySend(IssueRecordingModel(state.isRecording)) }
+                state.addListener(listener)
+                awaitClose { state.removeListener(listener) }
+            }
+            .onStart { emit(IssueRecordingModel(state.isRecording)) }
+            .distinctUntilChanged()
+            .flowOn(bgCoroutineContext)
+
+    override fun availability(user: UserHandle): Flow<Boolean> =
+        flowOf(android.os.Build.IS_DEBUGGABLE && Flags.recordIssueQsTile())
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingMapper.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingMapper.kt
new file mode 100644
index 0000000..ff931b3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingMapper.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.qs.tiles.impl.irecording
+
+import android.content.res.Resources
+import android.content.res.Resources.Theme
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.qs.tiles.base.interactor.QSTileDataToStateMapper
+import com.android.systemui.qs.tiles.viewmodel.QSTileConfig
+import com.android.systemui.qs.tiles.viewmodel.QSTileState
+import com.android.systemui.res.R
+import javax.inject.Inject
+
+class IssueRecordingMapper
+@Inject
+constructor(
+    @Main private val resources: Resources,
+    private val theme: Theme,
+) : QSTileDataToStateMapper<IssueRecordingModel> {
+    override fun map(config: QSTileConfig, data: IssueRecordingModel): QSTileState =
+        QSTileState.build(resources, theme, config.uiConfig) {
+            if (data.isRecording) {
+                activationState = QSTileState.ActivationState.ACTIVE
+                secondaryLabel = resources.getString(R.string.qs_record_issue_stop)
+                icon = { Icon.Resource(R.drawable.qs_record_issue_icon_on, null) }
+            } else {
+                icon = { Icon.Resource(R.drawable.qs_record_issue_icon_off, null) }
+                activationState = QSTileState.ActivationState.INACTIVE
+                secondaryLabel = resources.getString(R.string.qs_record_issue_start)
+            }
+            supportedActions = setOf(QSTileState.UserAction.CLICK)
+            contentDescription = "$label, $secondaryLabel"
+        }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingModel.kt
similarity index 76%
copy from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
copy to packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingModel.kt
index d8af3fa..260729b 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingModel.kt
@@ -14,8 +14,6 @@
  * limitations under the License.
  */
 
-package com.android.systemui.qs.panels.data.repository
+package com.android.systemui.qs.tiles.impl.irecording
 
-import com.android.systemui.kosmos.Kosmos
-
-val Kosmos.infiniteGridSizeRepository by Kosmos.Fixture { InfiniteGridSizeRepository() }
+@JvmInline value class IssueRecordingModel(val isRecording: Boolean)
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingUserActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingUserActionInteractor.kt
new file mode 100644
index 0000000..4971fef
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/irecording/IssueRecordingUserActionInteractor.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.qs.tiles.impl.irecording
+
+import android.app.AlertDialog
+import android.app.BroadcastOptions
+import android.app.PendingIntent
+import android.util.Log
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.systemui.animation.DialogCuj
+import com.android.systemui.animation.DialogTransitionAnimator
+import com.android.systemui.animation.Expandable
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.qs.pipeline.domain.interactor.PanelInteractor
+import com.android.systemui.qs.tiles.base.interactor.QSTileInput
+import com.android.systemui.qs.tiles.base.interactor.QSTileUserActionInteractor
+import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction
+import com.android.systemui.recordissue.IssueRecordingService
+import com.android.systemui.recordissue.RecordIssueDialogDelegate
+import com.android.systemui.recordissue.RecordIssueModule.Companion.TILE_SPEC
+import com.android.systemui.screenrecord.RecordingService
+import com.android.systemui.settings.UserContextProvider
+import com.android.systemui.statusbar.phone.KeyguardDismissUtil
+import com.android.systemui.statusbar.policy.KeyguardStateController
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.withContext
+
+private const val TAG = "IssueRecordingActionInteractor"
+
+class IssueRecordingUserActionInteractor
+@Inject
+constructor(
+    @Main private val mainCoroutineContext: CoroutineContext,
+    private val keyguardDismissUtil: KeyguardDismissUtil,
+    private val keyguardStateController: KeyguardStateController,
+    private val dialogTransitionAnimator: DialogTransitionAnimator,
+    private val panelInteractor: PanelInteractor,
+    private val userContextProvider: UserContextProvider,
+    private val delegateFactory: RecordIssueDialogDelegate.Factory,
+) : QSTileUserActionInteractor<IssueRecordingModel> {
+
+    override suspend fun handleInput(input: QSTileInput<IssueRecordingModel>) {
+        if (input.action is QSTileUserAction.Click) {
+            if (input.data.isRecording) {
+                stopIssueRecordingService()
+            } else {
+                withContext(mainCoroutineContext) { showPrompt(input.action.expandable) }
+            }
+        } else {
+            Log.v(TAG, "the RecordIssueTile doesn't handle ${input.action} events yet.")
+        }
+    }
+
+    private fun showPrompt(expandable: Expandable?) {
+        val dialog: AlertDialog =
+            delegateFactory
+                .create {
+                    startIssueRecordingService()
+                    dialogTransitionAnimator.disableAllCurrentDialogsExitAnimations()
+                    panelInteractor.collapsePanels()
+                }
+                .createDialog()
+        val dismissAction =
+            ActivityStarter.OnDismissAction {
+                // We animate from the touched view only if we are not on the keyguard, given
+                // that if we are we will dismiss it which will also collapse the shade.
+                if (expandable != null && !keyguardStateController.isShowing) {
+                    expandable
+                        .dialogTransitionController(
+                            DialogCuj(InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN, TILE_SPEC)
+                        )
+                        ?.let { dialogTransitionAnimator.show(dialog, it) } ?: dialog.show()
+                } else {
+                    dialog.show()
+                }
+                false
+            }
+        keyguardDismissUtil.executeWhenUnlocked(dismissAction, false, true)
+    }
+
+    private fun startIssueRecordingService() =
+        PendingIntent.getForegroundService(
+                userContextProvider.userContext,
+                RecordingService.REQUEST_CODE,
+                IssueRecordingService.getStartIntent(userContextProvider.userContext),
+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+            )
+            .send(BroadcastOptions.makeBasic().apply { isInteractive = true }.toBundle())
+
+    private fun stopIssueRecordingService() =
+        PendingIntent.getService(
+                userContextProvider.userContext,
+                RecordingService.REQUEST_CODE,
+                IssueRecordingService.getStopIntent(userContextProvider.userContext),
+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+            )
+            .send(BroadcastOptions.makeBasic().apply { isInteractive = true }.toBundle())
+}
diff --git a/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt b/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt
index 4ea3345..b077349 100644
--- a/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt
+++ b/packages/SystemUI/src/com/android/systemui/recordissue/IssueRecordingState.kt
@@ -18,7 +18,7 @@
 
 import android.content.Context
 import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.qs.tiles.RecordIssueTile
+import com.android.systemui.recordissue.RecordIssueModule.Companion.TILE_SPEC
 import com.android.systemui.res.R
 import com.android.systemui.settings.UserFileManager
 import com.android.systemui.settings.UserTracker
@@ -35,11 +35,7 @@
 ) {
 
     private val prefs =
-        userFileManager.getSharedPreferences(
-            RecordIssueTile.TILE_SPEC,
-            Context.MODE_PRIVATE,
-            userTracker.userId
-        )
+        userFileManager.getSharedPreferences(TILE_SPEC, Context.MODE_PRIVATE, userTracker.userId)
 
     var takeBugreport
         get() = prefs.getBoolean(KEY_TAKE_BUG_REPORT, false)
diff --git a/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueModule.kt b/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueModule.kt
index 26af9a7..907b92c 100644
--- a/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/recordissue/RecordIssueModule.kt
@@ -16,12 +16,20 @@
 
 package com.android.systemui.recordissue
 
+import com.android.systemui.Flags
 import com.android.systemui.qs.QsEventLogger
 import com.android.systemui.qs.pipeline.shared.TileSpec
 import com.android.systemui.qs.tileimpl.QSTileImpl
 import com.android.systemui.qs.tiles.RecordIssueTile
+import com.android.systemui.qs.tiles.base.viewmodel.QSTileViewModelFactory
+import com.android.systemui.qs.tiles.impl.irecording.IssueRecordingDataInteractor
+import com.android.systemui.qs.tiles.impl.irecording.IssueRecordingMapper
+import com.android.systemui.qs.tiles.impl.irecording.IssueRecordingModel
+import com.android.systemui.qs.tiles.impl.irecording.IssueRecordingUserActionInteractor
 import com.android.systemui.qs.tiles.viewmodel.QSTileConfig
 import com.android.systemui.qs.tiles.viewmodel.QSTileUIConfig
+import com.android.systemui.qs.tiles.viewmodel.QSTileViewModel
+import com.android.systemui.qs.tiles.viewmodel.StubQSTileViewModel
 import com.android.systemui.res.R
 import dagger.Binds
 import dagger.Module
@@ -34,19 +42,19 @@
     /** Inject RecordIssueTile into tileMap in QSModule */
     @Binds
     @IntoMap
-    @StringKey(RecordIssueTile.TILE_SPEC)
+    @StringKey(TILE_SPEC)
     fun bindRecordIssueTile(recordIssueTile: RecordIssueTile): QSTileImpl<*>
 
     companion object {
 
-        const val RECORD_ISSUE_TILE_SPEC = "record_issue"
+        const val TILE_SPEC = "record_issue"
 
         @Provides
         @IntoMap
-        @StringKey(RECORD_ISSUE_TILE_SPEC)
+        @StringKey(TILE_SPEC)
         fun provideRecordIssueTileConfig(uiEventLogger: QsEventLogger): QSTileConfig =
             QSTileConfig(
-                tileSpec = TileSpec.create(RECORD_ISSUE_TILE_SPEC),
+                tileSpec = TileSpec.create(TILE_SPEC),
                 uiConfig =
                     QSTileUIConfig.Resource(
                         iconRes = R.drawable.qs_record_issue_icon_off,
@@ -54,5 +62,24 @@
                     ),
                 instanceId = uiEventLogger.getNewInstanceId(),
             )
+
+        /** Inject FlashlightTile into tileViewModelMap in QSModule */
+        @Provides
+        @IntoMap
+        @StringKey(TILE_SPEC)
+        fun provideIssueRecordingTileViewModel(
+            factory: QSTileViewModelFactory.Static<IssueRecordingModel>,
+            mapper: IssueRecordingMapper,
+            stateInteractor: IssueRecordingDataInteractor,
+            userActionInteractor: IssueRecordingUserActionInteractor
+        ): QSTileViewModel =
+            if (Flags.qsNewTilesFuture())
+                factory.create(
+                    TileSpec.create(TILE_SPEC),
+                    userActionInteractor,
+                    stateInteractor,
+                    mapper,
+                )
+            else StubQSTileViewModel
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt b/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt
index 08462d7..6e89973 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt
@@ -23,6 +23,7 @@
 import com.android.systemui.scene.domain.resolver.HomeSceneFamilyResolverModule
 import com.android.systemui.scene.domain.resolver.NotifShadeSceneFamilyResolverModule
 import com.android.systemui.scene.domain.resolver.QuickSettingsSceneFamilyResolverModule
+import com.android.systemui.scene.domain.startable.KeyguardStateCallbackStartable
 import com.android.systemui.scene.domain.startable.SceneContainerStartable
 import com.android.systemui.scene.domain.startable.ScrimStartable
 import com.android.systemui.scene.domain.startable.StatusBarStartable
@@ -72,6 +73,11 @@
 
     @Binds
     @IntoMap
+    @ClassKey(KeyguardStateCallbackStartable::class)
+    fun keyguardStateCallbackStartable(impl: KeyguardStateCallbackStartable): CoreStartable
+
+    @Binds
+    @IntoMap
     @ClassKey(WindowRootViewVisibilityInteractor::class)
     fun bindWindowRootViewVisibilityInteractor(
         impl: WindowRootViewVisibilityInteractor
diff --git a/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt b/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt
index 17dc9a5..7d63b4c 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt
@@ -24,6 +24,7 @@
 import com.android.systemui.scene.domain.resolver.HomeSceneFamilyResolverModule
 import com.android.systemui.scene.domain.resolver.NotifShadeSceneFamilyResolverModule
 import com.android.systemui.scene.domain.resolver.QuickSettingsSceneFamilyResolverModule
+import com.android.systemui.scene.domain.startable.KeyguardStateCallbackStartable
 import com.android.systemui.scene.domain.startable.SceneContainerStartable
 import com.android.systemui.scene.domain.startable.ScrimStartable
 import com.android.systemui.scene.domain.startable.StatusBarStartable
@@ -78,6 +79,11 @@
 
     @Binds
     @IntoMap
+    @ClassKey(KeyguardStateCallbackStartable::class)
+    fun keyguardStateCallbackStartable(impl: KeyguardStateCallbackStartable): CoreStartable
+
+    @Binds
+    @IntoMap
     @ClassKey(WindowRootViewVisibilityInteractor::class)
     fun bindWindowRootViewVisibilityInteractor(
         impl: WindowRootViewVisibilityInteractor
diff --git a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartable.kt b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartable.kt
new file mode 100644
index 0000000..6d1c1a7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartable.kt
@@ -0,0 +1,170 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.scene.domain.startable
+
+import android.os.DeadObjectException
+import android.os.RemoteException
+import com.android.internal.policy.IKeyguardStateCallback
+import com.android.systemui.CoreStartable
+import com.android.systemui.bouncer.domain.interactor.SimBouncerInteractor
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
+import com.android.systemui.keyguard.domain.interactor.TrustInteractor
+import com.android.systemui.scene.domain.interactor.SceneInteractor
+import com.android.systemui.scene.shared.flag.SceneContainerFlag
+import com.android.systemui.scene.shared.model.Scenes
+import com.android.systemui.user.domain.interactor.SelectedUserInteractor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** Keeps all [IKeyguardStateCallback]s hydrated with the latest state. */
+@SysUISingleton
+class KeyguardStateCallbackStartable
+@Inject
+constructor(
+    @Application private val applicationScope: CoroutineScope,
+    @Background private val backgroundDispatcher: CoroutineDispatcher,
+    private val sceneInteractor: SceneInteractor,
+    private val selectedUserInteractor: SelectedUserInteractor,
+    private val deviceEntryInteractor: DeviceEntryInteractor,
+    private val simBouncerInteractor: SimBouncerInteractor,
+    private val trustInteractor: TrustInteractor,
+) : CoreStartable {
+
+    private val callbacks = mutableListOf<IKeyguardStateCallback>()
+
+    override fun start() {
+        if (!SceneContainerFlag.isEnabled) {
+            return
+        }
+
+        hydrateKeyguardShowingAndInputRestrictionStates()
+        hydrateSimSecureState()
+        notifyWhenKeyguardShowingChanged()
+        notifyWhenTrustChanged()
+    }
+
+    fun addCallback(callback: IKeyguardStateCallback) {
+        SceneContainerFlag.assertInNewMode()
+
+        callbacks.add(callback)
+
+        applicationScope.launch(backgroundDispatcher) {
+            callback.onShowingStateChanged(
+                !deviceEntryInteractor.isDeviceEntered.value,
+                selectedUserInteractor.getSelectedUserId(),
+            )
+            callback.onTrustedChanged(trustInteractor.isTrusted.value)
+            callback.onSimSecureStateChanged(simBouncerInteractor.isAnySimSecure.value)
+            // TODO(b/348644111): add support for mNeedToReshowWhenReenabled
+            callback.onInputRestrictedStateChanged(!deviceEntryInteractor.isDeviceEntered.value)
+        }
+    }
+
+    private fun hydrateKeyguardShowingAndInputRestrictionStates() {
+        applicationScope.launch {
+            combine(
+                    selectedUserInteractor.selectedUser,
+                    deviceEntryInteractor.isDeviceEntered,
+                    ::Pair
+                )
+                .collectLatest { (selectedUserId, isDeviceEntered) ->
+                    val iterator = callbacks.iterator()
+                    withContext(backgroundDispatcher) {
+                        while (iterator.hasNext()) {
+                            val callback = iterator.next()
+                            try {
+                                callback.onShowingStateChanged(!isDeviceEntered, selectedUserId)
+                                // TODO(b/348644111): add support for mNeedToReshowWhenReenabled
+                                callback.onInputRestrictedStateChanged(!isDeviceEntered)
+                            } catch (e: RemoteException) {
+                                if (e is DeadObjectException) {
+                                    iterator.remove()
+                                }
+                            }
+                        }
+                    }
+                }
+        }
+    }
+
+    private fun hydrateSimSecureState() {
+        applicationScope.launch {
+            simBouncerInteractor.isAnySimSecure.collectLatest { isSimSecured ->
+                val iterator = callbacks.iterator()
+                withContext(backgroundDispatcher) {
+                    while (iterator.hasNext()) {
+                        val callback = iterator.next()
+                        try {
+                            callback.onSimSecureStateChanged(isSimSecured)
+                        } catch (e: RemoteException) {
+                            if (e is DeadObjectException) {
+                                iterator.remove()
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun notifyWhenKeyguardShowingChanged() {
+        applicationScope.launch {
+            // This is equivalent to isDeviceEntered but it waits for the full transition animation
+            // to finish before emitting a new value and not just for the current scene to be
+            // switched.
+            sceneInteractor.transitionState
+                .filter { it.isIdle(Scenes.Gone) || it.isIdle(Scenes.Lockscreen) }
+                .map { it.isIdle(Scenes.Lockscreen) }
+                .distinctUntilChanged()
+                .collectLatest { trustInteractor.reportKeyguardShowingChanged() }
+        }
+    }
+
+    private fun notifyWhenTrustChanged() {
+        applicationScope.launch {
+            trustInteractor.isTrusted.collectLatest { isTrusted ->
+                val iterator = callbacks.iterator()
+                withContext(backgroundDispatcher) {
+                    while (iterator.hasNext()) {
+                        val callback = iterator.next()
+                        try {
+                            callback.onTrustedChanged(isTrusted)
+                        } catch (e: RemoteException) {
+                            if (e is DeadObjectException) {
+                                iterator.remove()
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt
index 1e689bd..218853d7 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt
@@ -22,6 +22,7 @@
 import com.android.compose.animation.scene.ObservableTransitionState
 import com.android.compose.animation.scene.SceneKey
 import com.android.internal.logging.UiEventLogger
+import com.android.internal.policy.IKeyguardStateCallback
 import com.android.systemui.CoreStartable
 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
@@ -127,6 +128,8 @@
     private val centralSurfaces: CentralSurfaces?
         get() = centralSurfacesOptLazy.get().getOrNull()
 
+    private val keyguardStateCallbacks = mutableListOf<IKeyguardStateCallback>()
+
     override fun start() {
         if (SceneContainerFlag.isEnabled) {
             sceneLogger.logFrameworkEnabled(isEnabled = true)
diff --git a/packages/SystemUI/src/com/android/systemui/scene/shared/flag/SceneContainerFlag.kt b/packages/SystemUI/src/com/android/systemui/scene/shared/flag/SceneContainerFlag.kt
index cf33c4a..6c63c97 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/shared/flag/SceneContainerFlag.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/shared/flag/SceneContainerFlag.kt
@@ -39,13 +39,14 @@
     inline val isEnabled
         get() =
             sceneContainer() && // mainAconfigFlag
-            ComposeLockscreen.isEnabled &&
+                ComposeLockscreen.isEnabled &&
                 KeyguardBottomAreaRefactor.isEnabled &&
                 KeyguardWmStateRefactor.isEnabled &&
                 MigrateClocksToBlueprint.isEnabled &&
                 NotificationsHeadsUpRefactor.isEnabled &&
                 PredictiveBackSysUiFlag.isEnabled &&
                 DeviceEntryUdfpsRefactor.isEnabled
+
     // NOTE: Changes should also be made in getSecondaryFlags and @EnableSceneContainer
 
     /** The main aconfig flag. */
@@ -91,6 +92,14 @@
     @JvmStatic
     inline fun assertInLegacyMode() = RefactorFlagUtils.assertInLegacyMode(isEnabled, DESCRIPTION)
 
+    /**
+     * Called to ensure the new code is only run when the flag is enabled. This will throw an
+     * exception if the flag is disabled to ensure that the refactor author catches issues in
+     * testing.
+     */
+    @JvmStatic
+    inline fun assertInNewMode() = RefactorFlagUtils.assertInNewMode(isEnabled, DESCRIPTION)
+
     /** Returns a developer-readable string that describes the current requirement list. */
     @JvmStatic
     fun requirementDescription(): String {
diff --git a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneSceneViewModel.kt
index ac91337..9f48ee9 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneSceneViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneSceneViewModel.kt
@@ -64,14 +64,25 @@
                     // TODO(b/338577208): Remove this once we add Dual Shade invocation zones.
                     shadeMode is ShadeMode.Dual
             ) {
-                put(
-                    Swipe(
-                        pointerCount = 2,
-                        fromSource = Edge.Top,
-                        direction = SwipeDirection.Down,
-                    ),
-                    UserActionResult(SceneFamilies.QuickSettings)
-                )
+                if (shadeInteractor.shadeAlignment == Alignment.BottomEnd) {
+                    put(
+                        Swipe(
+                            pointerCount = 2,
+                            fromSource = Edge.Bottom,
+                            direction = SwipeDirection.Up,
+                        ),
+                        UserActionResult(SceneFamilies.QuickSettings, OpenBottomShade)
+                    )
+                } else {
+                    put(
+                        Swipe(
+                            pointerCount = 2,
+                            fromSource = Edge.Top,
+                            direction = SwipeDirection.Down,
+                        ),
+                        UserActionResult(SceneFamilies.QuickSettings)
+                    )
+                }
             }
 
             if (shadeInteractor.shadeAlignment == Alignment.BottomEnd) {
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt
index ee8161c..ce321dc 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeControllerSceneImpl.kt
@@ -182,7 +182,11 @@
     }
 
     override fun expandToQs() {
-        sceneInteractor.changeScene(SceneFamilies.QuickSettings, "ShadeController.animateExpandQs")
+        sceneInteractor.changeScene(
+            SceneFamilies.QuickSettings,
+            "ShadeController.animateExpandQs",
+            OpenBottomShade.takeIf { shadeInteractor.shadeAlignment == Alignment.BottomEnd }
+        )
     }
 
     override fun setVisibilityListener(listener: ShadeVisibilityListener) {
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt
index b946129..6551854 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt
@@ -51,7 +51,7 @@
                 initialValue = Scenes.Lockscreen,
             )
 
-    /** Dictates whether the panel is aligned to the top or the bottom. */
+    /** Dictates the alignment of the overlay shade panel on the screen. */
     val panelAlignment = shadeInteractor.shadeAlignment
 
     /** Notifies that the user has clicked the semi-transparent background scrim. */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/KeyguardIndicationController.java b/packages/SystemUI/src/com/android/systemui/statusbar/KeyguardIndicationController.java
index 1a7871a..2f3fc729 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/KeyguardIndicationController.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/KeyguardIndicationController.java
@@ -1670,11 +1670,6 @@
     private final StatusBarStateController.StateListener mStatusBarStateListener =
             new StatusBarStateController.StateListener() {
         @Override
-        public void onStateChanged(int newState) {
-            setVisible(newState == StatusBarState.KEYGUARD);
-        }
-
-        @Override
         public void onDozingChanged(boolean dozing) {
             if (mDozing == dozing) {
                 return;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java
index 04a413a..240953d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java
@@ -291,8 +291,9 @@
     }
 
     private void updateMediaMetaData(MediaListener callback) {
-        callback.onPrimaryMetadataOrStateChanged(mMediaMetadata,
-                getMediaControllerPlaybackState(mMediaController));
+        int playbackState = getMediaControllerPlaybackState(mMediaController);
+        mHandler.post(
+                () -> callback.onPrimaryMetadataOrStateChanged(mMediaMetadata, playbackState));
     }
 
     public void removeCallback(MediaListener callback) {
@@ -437,9 +438,11 @@
 
     private void updateMediaMetaData(List<MediaListener> callbacks) {
         @PlaybackState.State int state = getMediaControllerPlaybackState(mMediaController);
-        for (int i = 0; i < callbacks.size(); i++) {
-            callbacks.get(i).onPrimaryMetadataOrStateChanged(mMediaMetadata, state);
-        }
+        mHandler.post(() -> {
+            for (int i = 0; i < callbacks.size(); i++) {
+                callbacks.get(i).onPrimaryMetadataOrStateChanged(mMediaMetadata, state);
+            }
+        });
     }
 
     @Override
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java
index 855798c..28e3a83 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationShelf.java
@@ -94,7 +94,6 @@
     private float mCornerAnimationDistance;
     private float mActualWidth = -1;
     private int mMaxIconsOnLockscreen;
-    private int mNotificationScrimPadding;
     private boolean mCanModifyColorOfNotifications;
     private boolean mCanInteract;
     private NotificationStackScrollLayout mHostLayout;
@@ -138,7 +137,6 @@
         mStatusBarHeight = SystemBarUtils.getStatusBarHeight(mContext);
         mPaddingBetweenElements = res.getDimensionPixelSize(R.dimen.notification_divider_height);
         mMaxIconsOnLockscreen = res.getInteger(R.integer.max_notif_icons_on_lockscreen);
-        mNotificationScrimPadding = res.getDimensionPixelSize(R.dimen.notification_side_paddings);
 
         ViewGroup.LayoutParams layoutParams = getLayoutParams();
         final int newShelfHeight = res.getDimensionPixelOffset(R.dimen.notification_shelf_height);
@@ -265,7 +263,7 @@
         }
 
         final float stackBottom = SceneContainerFlag.isEnabled()
-                ? getStackBottom(ambientState)
+                ? ambientState.getStackTop() + ambientState.getStackHeight()
                 : ambientState.getStackY() + ambientState.getStackHeight();
 
         if (viewState.hidden) {
@@ -278,19 +276,6 @@
         }
     }
 
-    /**
-     * bottom-most position, where we can draw the stack
-     */
-    private float getStackBottom(AmbientState ambientState) {
-        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return 0f;
-        float stackBottom = ambientState.getStackCutoff() - mNotificationScrimPadding;
-        if (ambientState.isExpansionChanging()) {
-            stackBottom = MathUtils.lerp(stackBottom * StackScrollAlgorithm.START_FRACTION,
-                    stackBottom, ambientState.getExpansionFraction());
-        }
-        return stackBottom;
-    }
-
     private int getSpeedBumpIndex() {
         NotificationIconContainerRefactor.assertInLegacyMode();
         return mHostLayout.getSpeedBumpIndex();
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarIconView.java b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarIconView.java
index d0702fc..bbf0ae1 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarIconView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarIconView.java
@@ -69,7 +69,6 @@
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
-import java.text.NumberFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 
@@ -148,11 +147,6 @@
     @VisibleForTesting float mScaleToFitNewIconSize = 1;
     private StatusBarIcon mIcon;
     @ViewDebug.ExportedProperty private String mSlot;
-    private Drawable mNumberBackground;
-    private Paint mNumberPain;
-    private int mNumberX;
-    private int mNumberY;
-    private String mNumberText;
     private StatusBarNotification mNotification;
     private final boolean mBlocked;
     private Configuration mConfiguration;
@@ -201,10 +195,6 @@
         mDozer = new NotificationDozeHelper();
         mBlocked = blocked;
         mSlot = slot;
-        mNumberPain = new Paint();
-        mNumberPain.setTextAlign(Paint.Align.CENTER);
-        mNumberPain.setColor(context.getColor(R.drawable.notification_number_text_color));
-        mNumberPain.setAntiAlias(true);
         setNotification(sbn);
         setScaleType(ScaleType.CENTER);
         mConfiguration = new Configuration(context.getResources().getConfiguration());
@@ -410,8 +400,6 @@
                 && mIcon.iconLevel == icon.iconLevel;
         final boolean visibilityEquals = mIcon != null
                 && mIcon.visible == icon.visible;
-        final boolean numberEquals = mIcon != null
-                && mIcon.number == icon.number;
         mIcon = icon.clone();
         setContentDescription(icon.contentDescription);
         if (!iconEquals) {
@@ -425,20 +413,6 @@
             setImageLevel(icon.iconLevel);
         }
 
-        if (!numberEquals) {
-            if (icon.number > 0 && getContext().getResources().getBoolean(
-                        R.bool.config_statusBarShowNumber)) {
-                if (mNumberBackground == null) {
-                    mNumberBackground = getContext().getResources().getDrawable(
-                            R.drawable.ic_notification_overlay);
-                }
-                placeNumber();
-            } else {
-                mNumberBackground = null;
-                mNumberText = null;
-            }
-            invalidate();
-        }
         if (!visibilityEquals) {
             setVisibility(icon.visible && !mBlocked ? VISIBLE : GONE);
         }
@@ -568,14 +542,6 @@
     }
 
     @Override
-    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
-        super.onSizeChanged(w, h, oldw, oldh);
-        if (mNumberBackground != null) {
-            placeNumber();
-        }
-    }
-
-    @Override
     public void onRtlPropertiesChanged(int layoutDirection) {
         super.onRtlPropertiesChanged(layoutDirection);
         updateDrawable();
@@ -609,10 +575,6 @@
             canvas.restore();
         }
 
-        if (mNumberBackground != null) {
-            mNumberBackground.draw(canvas);
-            canvas.drawText(mNumberText, mNumberX, mNumberY, mNumberPain);
-        }
         if (mDotAppearAmount != 0.0f) {
             float radius;
             float alpha = Color.alpha(mDecorColor) / 255.f;
@@ -640,39 +602,6 @@
         Log.d("View", debugIndent(depth) + "icon=" + mIcon);
     }
 
-    void placeNumber() {
-        final String str;
-        final int tooBig = getContext().getResources().getInteger(
-                android.R.integer.status_bar_notification_info_maxnum);
-        if (mIcon.number > tooBig) {
-            str = getContext().getResources().getString(
-                        android.R.string.status_bar_notification_info_overflow);
-        } else {
-            NumberFormat f = NumberFormat.getIntegerInstance();
-            str = f.format(mIcon.number);
-        }
-        mNumberText = str;
-
-        final int w = getWidth();
-        final int h = getHeight();
-        final Rect r = new Rect();
-        mNumberPain.getTextBounds(str, 0, str.length(), r);
-        final int tw = r.right - r.left;
-        final int th = r.bottom - r.top;
-        mNumberBackground.getPadding(r);
-        int dw = r.left + tw + r.right;
-        if (dw < mNumberBackground.getMinimumWidth()) {
-            dw = mNumberBackground.getMinimumWidth();
-        }
-        mNumberX = w-r.right-((dw-r.right-r.left)/2);
-        int dh = r.top + th + r.bottom;
-        if (dh < mNumberBackground.getMinimumWidth()) {
-            dh = mNumberBackground.getMinimumWidth();
-        }
-        mNumberY = h-r.bottom-((dh-r.top-th-r.bottom)/2);
-        mNumberBackground.setBounds(w-dw, h-dh, w, h);
-    }
-
     @Override
     public String toString() {
         return "StatusBarIconView("
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
index 1adfef0..f98a88f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
@@ -68,9 +68,11 @@
 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController;
 import com.android.systemui.statusbar.notification.row.NotificationGuts;
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository;
 import com.android.systemui.statusbar.notification.row.shared.HeadsUpStatusBarModel;
 import com.android.systemui.statusbar.notification.row.shared.NotificationContentModel;
 import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor;
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel;
 import com.android.systemui.statusbar.notification.stack.PriorityBucket;
 import com.android.systemui.util.ListenerSet;
 
@@ -97,7 +99,7 @@
  * At the moment, there are many things here that shouldn't be and vice-versa. Hopefully we can
  * clean this up in the future.
  */
-public final class NotificationEntry extends ListEntry {
+public final class NotificationEntry extends ListEntry implements NotificationRowRepository {
 
     private final String mKey;
     private StatusBarNotification mSbn;
@@ -159,6 +161,8 @@
             StateFlowKt.MutableStateFlow(null);
     private final MutableStateFlow<CharSequence> mHeadsUpStatusBarTextPublic =
             StateFlowKt.MutableStateFlow(null);
+    private final MutableStateFlow<RichOngoingContentModel> mRichOngoingContentModel =
+            StateFlowKt.MutableStateFlow(null);
 
     // indicates when this entry's view was first attached to a window
     // this value will reset when the view is completely removed from the shade (ie: filtered out)
@@ -945,6 +949,7 @@
     }
 
     /** @see #setHeadsUpStatusBarText(CharSequence) */
+    @NonNull
     public StateFlow<CharSequence> getHeadsUpStatusBarText() {
         return mHeadsUpStatusBarText;
     }
@@ -959,10 +964,17 @@
     }
 
     /** @see #setHeadsUpStatusBarTextPublic(CharSequence) */
+    @NonNull
     public StateFlow<CharSequence> getHeadsUpStatusBarTextPublic() {
         return mHeadsUpStatusBarTextPublic;
     }
 
+    /** Gets the current RON content model, which may be null */
+    @NonNull
+    public StateFlow<RichOngoingContentModel> getRichOngoingContentModel() {
+        return mRichOngoingContentModel;
+    }
+
     /**
      * Sets the text to be displayed on the StatusBar, when this notification is the top pinned
      * heads up, and its content is sensitive right now.
@@ -1047,6 +1059,7 @@
         HeadsUpStatusBarModel headsUpStatusBarModel = contentModel.getHeadsUpStatusBarModel();
         this.mHeadsUpStatusBarText.setValue(headsUpStatusBarModel.getPrivateText());
         this.mHeadsUpStatusBarTextPublic.setValue(headsUpStatusBarModel.getPublicText());
+        this.mRichOngoingContentModel.setValue(contentModel.getRichOngoingContentModel());
     }
 
     /** Information about a suggestion that is being edited. */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java
index 91bb28e..762c9a1 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java
@@ -21,8 +21,8 @@
 import android.service.notification.NotificationListenerService;
 
 import com.android.internal.jank.InteractionJankMonitor;
-import com.android.settingslib.statusbar.notification.data.repository.NotificationsSoundPolicyRepository;
-import com.android.settingslib.statusbar.notification.data.repository.NotificationsSoundPolicyRepositoryImpl;
+import com.android.settingslib.statusbar.notification.data.repository.ZenModeRepository;
+import com.android.settingslib.statusbar.notification.data.repository.ZenModeRepositoryImpl;
 import com.android.settingslib.statusbar.notification.domain.interactor.NotificationsSoundPolicyInteractor;
 import com.android.systemui.CoreStartable;
 import com.android.systemui.dagger.SysUISingleton;
@@ -279,19 +279,19 @@
 
     @Provides
     @SysUISingleton
-    public static NotificationsSoundPolicyRepository provideNotificationsSoundPolicyRepository(
+    static ZenModeRepository provideZenModeRepository(
             Context context,
             NotificationManager notificationManager,
             @Application CoroutineScope coroutineScope,
             @Background CoroutineContext coroutineContext) {
-        return new NotificationsSoundPolicyRepositoryImpl(context, notificationManager,
+        return new ZenModeRepositoryImpl(context, notificationManager,
                 coroutineScope, coroutineContext);
     }
 
     @Provides
     @SysUISingleton
-    public static NotificationsSoundPolicyInteractor provideNotificationsSoundPolicyInteractror(
-            NotificationsSoundPolicyRepository repository) {
+    static NotificationsSoundPolicyInteractor provideNotificationsSoundPolicyInteractor(
+            ZenModeRepository repository) {
         return new NotificationsSoundPolicyInteractor(repository);
     }
 }
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 4ba673d..05d7196 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
@@ -2950,24 +2950,21 @@
         if (mShowingPublicInitialized && mShowingPublic == oldShowingPublic) {
             return;
         }
-        float oldAlpha = getContentView().getAlpha();
 
         if (!animated) {
-            mPublicLayout.animate().cancel();
-            mPrivateLayout.animate().cancel();
-            if (mChildrenContainer != null) {
-                mChildrenContainer.animate().cancel();
+            if (!NotificationContentAlphaOptimization.isEnabled()
+                    || mShowingPublic != oldShowingPublic) {
+                // Don't reset the alpha or cancel the animation if the showing layout doesn't
+                // change
+                mPublicLayout.animate().cancel();
+                mPrivateLayout.animate().cancel();
+                if (mChildrenContainer != null) {
+                    mChildrenContainer.animate().cancel();
+                }
+                resetAllContentAlphas();
             }
-            resetAllContentAlphas();
             mPublicLayout.setVisibility(mShowingPublic ? View.VISIBLE : View.INVISIBLE);
             updateChildrenVisibility();
-            if (NotificationContentAlphaOptimization.isEnabled()) {
-                // We want to set the old alpha to the now-showing layout to avoid breaking an
-                // on-going animation
-                if (oldAlpha != 1f) {
-                    setAlphaAndLayerType(mShowingPublic ? mPublicLayout : mPrivateLayout, oldAlpha);
-                }
-            }
         } else {
             animateShowingPublic(delay, duration, mShowingPublic);
         }
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 6f00d96..7fc331d 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
@@ -72,6 +72,8 @@
 import com.android.systemui.util.Compile;
 import com.android.systemui.util.DumpUtilsKt;
 
+import kotlinx.coroutines.DisposableHandle;
+
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -109,6 +111,8 @@
     private View mHeadsUpChild;
     private HybridNotificationView mSingleLineView;
 
+    @Nullable public DisposableHandle mContractedBinderHandle;
+
     private RemoteInputView mExpandedRemoteInput;
     private RemoteInputView mHeadsUpRemoteInput;
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt
index e704140..492d802 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt
@@ -46,6 +46,10 @@
 import com.android.systemui.statusbar.notification.ConversationNotificationProcessor
 import com.android.systemui.statusbar.notification.InflationException
 import com.android.systemui.statusbar.notification.collection.NotificationEntry
+import com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_CONTRACTED
+import com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_EXPANDED
+import com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_HEADSUP
+import com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_SINGLELINE
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.BindParams
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED
@@ -62,6 +66,7 @@
 import com.android.systemui.statusbar.notification.row.shared.NewRemoteViews
 import com.android.systemui.statusbar.notification.row.shared.NotificationContentModel
 import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
 import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineConversationViewBinder
 import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineViewBinder
 import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper
@@ -86,6 +91,8 @@
     private val remoteViewCache: NotifRemoteViewCache,
     private val remoteInputManager: NotificationRemoteInputManager,
     private val conversationProcessor: ConversationNotificationProcessor,
+    private val ronExtractor: RichOngoingNotificationContentExtractor,
+    private val ronInflater: RichOngoingNotificationViewInflater,
     @NotifInflation private val inflationExecutor: Executor,
     private val smartReplyStateInflater: SmartReplyStateInflater,
     private val notifLayoutInflaterFactoryProvider: NotifLayoutInflaterFactory.Provider,
@@ -133,6 +140,8 @@
                 remoteViewCache,
                 entry,
                 conversationProcessor,
+                ronExtractor,
+                ronInflater,
                 row,
                 bindParams.isMinimized,
                 bindParams.usesIncreasedHeight,
@@ -177,6 +186,7 @@
                 notifLayoutInflaterFactoryProvider = notifLayoutInflaterFactoryProvider,
                 headsUpStyleProvider = headsUpStyleProvider,
                 conversationProcessor = conversationProcessor,
+                ronExtractor = ronExtractor,
                 logger = logger,
             )
         inflateSmartReplyViews(
@@ -255,39 +265,31 @@
     ) {
         when (inflateFlag) {
             FLAG_CONTENT_VIEW_CONTRACTED ->
-                row.privateLayout.performWhenContentInactive(
-                    NotificationContentView.VISIBLE_TYPE_CONTRACTED
-                ) {
+                row.privateLayout.performWhenContentInactive(VISIBLE_TYPE_CONTRACTED) {
+                    row.privateLayout.mContractedBinderHandle?.dispose()
+                    row.privateLayout.mContractedBinderHandle = null
                     row.privateLayout.setContractedChild(null)
                     remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_CONTRACTED)
                 }
             FLAG_CONTENT_VIEW_EXPANDED ->
-                row.privateLayout.performWhenContentInactive(
-                    NotificationContentView.VISIBLE_TYPE_EXPANDED
-                ) {
+                row.privateLayout.performWhenContentInactive(VISIBLE_TYPE_EXPANDED) {
                     row.privateLayout.setExpandedChild(null)
                     remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
                 }
             FLAG_CONTENT_VIEW_HEADS_UP ->
-                row.privateLayout.performWhenContentInactive(
-                    NotificationContentView.VISIBLE_TYPE_HEADSUP
-                ) {
+                row.privateLayout.performWhenContentInactive(VISIBLE_TYPE_HEADSUP) {
                     row.privateLayout.setHeadsUpChild(null)
                     remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
                     row.privateLayout.setHeadsUpInflatedSmartReplies(null)
                 }
             FLAG_CONTENT_VIEW_PUBLIC ->
-                row.publicLayout.performWhenContentInactive(
-                    NotificationContentView.VISIBLE_TYPE_CONTRACTED
-                ) {
+                row.publicLayout.performWhenContentInactive(VISIBLE_TYPE_CONTRACTED) {
                     row.publicLayout.setContractedChild(null)
                     remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_PUBLIC)
                 }
             FLAG_CONTENT_VIEW_SINGLE_LINE -> {
                 if (AsyncHybridViewInflation.isEnabled) {
-                    row.privateLayout.performWhenContentInactive(
-                        NotificationContentView.VISIBLE_TYPE_SINGLELINE
-                    ) {
+                    row.privateLayout.performWhenContentInactive(VISIBLE_TYPE_SINGLELINE) {
                         row.privateLayout.setSingleLineView(null)
                     }
                 }
@@ -308,32 +310,22 @@
         @InflationFlag contentViews: Int
     ) {
         if (contentViews and FLAG_CONTENT_VIEW_CONTRACTED != 0) {
-            row.privateLayout.removeContentInactiveRunnable(
-                NotificationContentView.VISIBLE_TYPE_CONTRACTED
-            )
+            row.privateLayout.removeContentInactiveRunnable(VISIBLE_TYPE_CONTRACTED)
         }
         if (contentViews and FLAG_CONTENT_VIEW_EXPANDED != 0) {
-            row.privateLayout.removeContentInactiveRunnable(
-                NotificationContentView.VISIBLE_TYPE_EXPANDED
-            )
+            row.privateLayout.removeContentInactiveRunnable(VISIBLE_TYPE_EXPANDED)
         }
         if (contentViews and FLAG_CONTENT_VIEW_HEADS_UP != 0) {
-            row.privateLayout.removeContentInactiveRunnable(
-                NotificationContentView.VISIBLE_TYPE_HEADSUP
-            )
+            row.privateLayout.removeContentInactiveRunnable(VISIBLE_TYPE_HEADSUP)
         }
         if (contentViews and FLAG_CONTENT_VIEW_PUBLIC != 0) {
-            row.publicLayout.removeContentInactiveRunnable(
-                NotificationContentView.VISIBLE_TYPE_CONTRACTED
-            )
+            row.publicLayout.removeContentInactiveRunnable(VISIBLE_TYPE_CONTRACTED)
         }
         if (
             AsyncHybridViewInflation.isEnabled &&
                 contentViews and FLAG_CONTENT_VIEW_SINGLE_LINE != 0
         ) {
-            row.privateLayout.removeContentInactiveRunnable(
-                NotificationContentView.VISIBLE_TYPE_SINGLELINE
-            )
+            row.privateLayout.removeContentInactiveRunnable(VISIBLE_TYPE_SINGLELINE)
         }
     }
 
@@ -353,6 +345,8 @@
         private val remoteViewCache: NotifRemoteViewCache,
         private val entry: NotificationEntry,
         private val conversationProcessor: ConversationNotificationProcessor,
+        private val ronExtractor: RichOngoingNotificationContentExtractor,
+        private val ronInflater: RichOngoingNotificationViewInflater,
         private val row: ExpandableNotificationRow,
         private val isMinimized: Boolean,
         private val usesIncreasedHeight: Boolean,
@@ -432,6 +426,7 @@
                     notifLayoutInflaterFactoryProvider = notifLayoutInflaterFactoryProvider,
                     headsUpStyleProvider = headsUpStyleProvider,
                     conversationProcessor = conversationProcessor,
+                    ronExtractor = ronExtractor,
                     logger = logger
                 )
             logger.logAsyncTaskProgress(
@@ -463,6 +458,21 @@
                         )
                     }
             }
+
+            if (reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING != 0) {
+                logger.logAsyncTaskProgress(entry, "inflating RON view")
+                inflationProgress.richOngoingNotificationViewHolder =
+                    inflationProgress.contentModel.richOngoingContentModel?.let {
+                        ronInflater.inflateView(
+                            contentModel = it,
+                            existingView = row.privateLayout.contractedChild,
+                            entry = entry,
+                            systemUiContext = context,
+                            parentView = row.privateLayout
+                        )
+                    }
+            }
+
             logger.logAsyncTaskProgress(entry, "getting row image resolver (on wrong thread!)")
             val imageResolver = row.imageResolver
             // wait for image resolver to finish preloading
@@ -568,6 +578,7 @@
         var inflatedSmartReplyState: InflatedSmartReplyState? = null
         var expandedInflatedSmartReplies: InflatedSmartReplyViewHolder? = null
         var headsUpInflatedSmartReplies: InflatedSmartReplyViewHolder? = null
+        var richOngoingNotificationViewHolder: InflatedContentViewHolder? = null
 
         // Inflated SingleLineView that lacks the UI State
         var inflatedSingleLineView: HybridNotificationView? = null
@@ -602,6 +613,7 @@
             val inflateHeadsUp =
                 (reInflateFlags and FLAG_CONTENT_VIEW_HEADS_UP != 0 &&
                     result.remoteViews.headsUp != null)
+
             if (inflateContracted || inflateExpanded || inflateHeadsUp) {
                 logger.logAsyncTaskProgress(entry, "inflating contracted smart reply state")
                 result.inflatedSmartReplyState = inflater.inflateSmartReplyState(entry)
@@ -643,6 +655,7 @@
             notifLayoutInflaterFactoryProvider: NotifLayoutInflaterFactory.Provider,
             headsUpStyleProvider: HeadsUpStyleProvider,
             conversationProcessor: ConversationNotificationProcessor,
+            ronExtractor: RichOngoingNotificationContentExtractor,
             logger: NotificationRowContentBinderLogger
         ): InflationProgress {
             // process conversations and extract the messaging style
@@ -651,9 +664,24 @@
                     conversationProcessor.processNotification(entry, builder, logger)
                 } else null
 
+            val richOngoingContentModel =
+                if (reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING != 0) {
+                    ronExtractor.extractContentModel(
+                        entry = entry,
+                        builder = builder,
+                        systemUIContext = systemUIContext,
+                        packageContext = packageContext
+                    )
+                } else {
+                    // if we're not re-inflating any RON views, make sure the model doesn't change
+                    entry.richOngoingContentModel.value
+                }
+
+            val remoteViewsFlags = getRemoteViewsFlags(reInflateFlags, richOngoingContentModel)
+
             val remoteViews =
                 createRemoteViews(
-                    reInflateFlags = reInflateFlags,
+                    reInflateFlags = remoteViewsFlags,
                     builder = builder,
                     isMinimized = isMinimized,
                     usesIncreasedHeight = usesIncreasedHeight,
@@ -688,6 +716,7 @@
                 NotificationContentModel(
                     headsUpStatusBarModel = headsUpStatusBarModel,
                     singleLineViewModel = singleLineViewModel,
+                    richOngoingContentModel = richOngoingContentModel,
                 )
 
             return InflationProgress(
@@ -815,7 +844,7 @@
             val publicLayout = row.publicLayout
             val runningInflations = HashMap<Int, CancellationSignal>()
             var flag = FLAG_CONTENT_VIEW_CONTRACTED
-            if (reInflateFlags and flag != 0) {
+            if (reInflateFlags and flag != 0 && result.remoteViews.contracted != null) {
                 val isNewView =
                     !canReapplyRemoteView(
                         newView = result.remoteViews.contracted,
@@ -829,7 +858,7 @@
                         }
 
                         override val remoteView: RemoteViews
-                            get() = result.remoteViews.contracted!!
+                            get() = result.remoteViews.contracted
                     }
                 logger.logAsyncTaskProgress(entry, "applying contracted view")
                 applyRemoteView(
@@ -847,104 +876,89 @@
                     callback = callback,
                     parentLayout = privateLayout,
                     existingView = privateLayout.contractedChild,
-                    existingWrapper =
-                        privateLayout.getVisibleWrapper(
-                            NotificationContentView.VISIBLE_TYPE_CONTRACTED
-                        ),
+                    existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_CONTRACTED),
                     runningInflations = runningInflations,
                     applyCallback = applyCallback,
                     logger = logger
                 )
             }
             flag = FLAG_CONTENT_VIEW_EXPANDED
-            if (reInflateFlags and flag != 0) {
-                if (result.remoteViews.expanded != null) {
-                    val isNewView =
-                        !canReapplyRemoteView(
-                            newView = result.remoteViews.expanded,
-                            oldView =
-                                remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
-                        )
-                    val applyCallback: ApplyCallback =
-                        object : ApplyCallback() {
-                            override fun setResultView(v: View) {
-                                logger.logAsyncTaskProgress(entry, "expanded view applied")
-                                result.inflatedExpandedView = v
-                            }
-
-                            override val remoteView: RemoteViews
-                                get() = result.remoteViews.expanded
-                        }
-                    logger.logAsyncTaskProgress(entry, "applying expanded view")
-                    applyRemoteView(
-                        inflationExecutor = inflationExecutor,
-                        inflateSynchronously = inflateSynchronously,
-                        isMinimized = isMinimized,
-                        result = result,
-                        reInflateFlags = reInflateFlags,
-                        inflationId = flag,
-                        remoteViewCache = remoteViewCache,
-                        entry = entry,
-                        row = row,
-                        isNewView = isNewView,
-                        remoteViewClickHandler = remoteViewClickHandler,
-                        callback = callback,
-                        parentLayout = privateLayout,
-                        existingView = privateLayout.expandedChild,
-                        existingWrapper =
-                            privateLayout.getVisibleWrapper(
-                                NotificationContentView.VISIBLE_TYPE_EXPANDED
-                            ),
-                        runningInflations = runningInflations,
-                        applyCallback = applyCallback,
-                        logger = logger
+            if (reInflateFlags and flag != 0 && result.remoteViews.expanded != null) {
+                val isNewView =
+                    !canReapplyRemoteView(
+                        newView = result.remoteViews.expanded,
+                        oldView = remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
                     )
-                }
+                val applyCallback: ApplyCallback =
+                    object : ApplyCallback() {
+                        override fun setResultView(v: View) {
+                            logger.logAsyncTaskProgress(entry, "expanded view applied")
+                            result.inflatedExpandedView = v
+                        }
+
+                        override val remoteView: RemoteViews
+                            get() = result.remoteViews.expanded
+                    }
+                logger.logAsyncTaskProgress(entry, "applying expanded view")
+                applyRemoteView(
+                    inflationExecutor = inflationExecutor,
+                    inflateSynchronously = inflateSynchronously,
+                    isMinimized = isMinimized,
+                    result = result,
+                    reInflateFlags = reInflateFlags,
+                    inflationId = flag,
+                    remoteViewCache = remoteViewCache,
+                    entry = entry,
+                    row = row,
+                    isNewView = isNewView,
+                    remoteViewClickHandler = remoteViewClickHandler,
+                    callback = callback,
+                    parentLayout = privateLayout,
+                    existingView = privateLayout.expandedChild,
+                    existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_EXPANDED),
+                    runningInflations = runningInflations,
+                    applyCallback = applyCallback,
+                    logger = logger
+                )
             }
             flag = FLAG_CONTENT_VIEW_HEADS_UP
-            if (reInflateFlags and flag != 0) {
-                if (result.remoteViews.headsUp != null) {
-                    val isNewView =
-                        !canReapplyRemoteView(
-                            newView = result.remoteViews.headsUp,
-                            oldView =
-                                remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
-                        )
-                    val applyCallback: ApplyCallback =
-                        object : ApplyCallback() {
-                            override fun setResultView(v: View) {
-                                logger.logAsyncTaskProgress(entry, "heads up view applied")
-                                result.inflatedHeadsUpView = v
-                            }
-
-                            override val remoteView: RemoteViews
-                                get() = result.remoteViews.headsUp
-                        }
-                    logger.logAsyncTaskProgress(entry, "applying heads up view")
-                    applyRemoteView(
-                        inflationExecutor = inflationExecutor,
-                        inflateSynchronously = inflateSynchronously,
-                        isMinimized = isMinimized,
-                        result = result,
-                        reInflateFlags = reInflateFlags,
-                        inflationId = flag,
-                        remoteViewCache = remoteViewCache,
-                        entry = entry,
-                        row = row,
-                        isNewView = isNewView,
-                        remoteViewClickHandler = remoteViewClickHandler,
-                        callback = callback,
-                        parentLayout = privateLayout,
-                        existingView = privateLayout.headsUpChild,
-                        existingWrapper =
-                            privateLayout.getVisibleWrapper(
-                                NotificationContentView.VISIBLE_TYPE_HEADSUP
-                            ),
-                        runningInflations = runningInflations,
-                        applyCallback = applyCallback,
-                        logger = logger
+            if (reInflateFlags and flag != 0 && result.remoteViews.headsUp != null) {
+                val isNewView =
+                    !canReapplyRemoteView(
+                        newView = result.remoteViews.headsUp,
+                        oldView = remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
                     )
-                }
+                val applyCallback: ApplyCallback =
+                    object : ApplyCallback() {
+                        override fun setResultView(v: View) {
+                            logger.logAsyncTaskProgress(entry, "heads up view applied")
+                            result.inflatedHeadsUpView = v
+                        }
+
+                        override val remoteView: RemoteViews
+                            get() = result.remoteViews.headsUp
+                    }
+                logger.logAsyncTaskProgress(entry, "applying heads up view")
+                applyRemoteView(
+                    inflationExecutor = inflationExecutor,
+                    inflateSynchronously = inflateSynchronously,
+                    isMinimized = isMinimized,
+                    result = result,
+                    reInflateFlags = reInflateFlags,
+                    inflationId = flag,
+                    remoteViewCache = remoteViewCache,
+                    entry = entry,
+                    row = row,
+                    isNewView = isNewView,
+                    remoteViewClickHandler = remoteViewClickHandler,
+                    callback = callback,
+                    parentLayout = privateLayout,
+                    existingView = privateLayout.headsUpChild,
+                    existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_HEADSUP),
+                    runningInflations = runningInflations,
+                    applyCallback = applyCallback,
+                    logger = logger
+                )
             }
             flag = FLAG_CONTENT_VIEW_PUBLIC
             if (reInflateFlags and flag != 0) {
@@ -979,10 +993,7 @@
                     callback = callback,
                     parentLayout = publicLayout,
                     existingView = publicLayout.contractedChild,
-                    existingWrapper =
-                        publicLayout.getVisibleWrapper(
-                            NotificationContentView.VISIBLE_TYPE_CONTRACTED
-                        ),
+                    existingWrapper = publicLayout.getVisibleWrapper(VISIBLE_TYPE_CONTRACTED),
                     runningInflations = runningInflations,
                     applyCallback = applyCallback,
                     logger = logger
@@ -1359,79 +1370,34 @@
             if (runningInflations.isNotEmpty()) {
                 return false
             }
-            val privateLayout = row.privateLayout
-            val publicLayout = row.publicLayout
             logger.logAsyncTaskProgress(entry, "finishing")
-            if (reInflateFlags and FLAG_CONTENT_VIEW_CONTRACTED != 0) {
-                if (result.inflatedContentView != null) {
-                    // New view case
-                    privateLayout.setContractedChild(result.inflatedContentView)
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_CONTRACTED,
-                        result.remoteViews.contracted
-                    )
-                } else if (remoteViewCache.hasCachedView(entry, FLAG_CONTENT_VIEW_CONTRACTED)) {
-                    // Reinflation case. Only update if it's still cached (i.e. view has not been
-                    // freed while inflating).
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_CONTRACTED,
-                        result.remoteViews.contracted
-                    )
-                }
+
+            // before updating the content model, stop existing binding if necessary
+            val hasRichOngoingContentModel = result.contentModel.richOngoingContentModel != null
+            val requestedRichOngoing = reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING != 0
+            val rejectedRichOngoing = requestedRichOngoing && !hasRichOngoingContentModel
+            if (result.richOngoingNotificationViewHolder != null || rejectedRichOngoing) {
+                row.privateLayout.mContractedBinderHandle?.dispose()
+                row.privateLayout.mContractedBinderHandle = null
             }
-            if (reInflateFlags and FLAG_CONTENT_VIEW_EXPANDED != 0) {
-                if (result.inflatedExpandedView != null) {
-                    privateLayout.setExpandedChild(result.inflatedExpandedView)
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_EXPANDED,
-                        result.remoteViews.expanded
-                    )
-                } else if (result.remoteViews.expanded == null) {
-                    privateLayout.setExpandedChild(null)
-                    remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
-                } else if (remoteViewCache.hasCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)) {
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_EXPANDED,
-                        result.remoteViews.expanded
-                    )
-                }
-                if (result.remoteViews.expanded != null) {
-                    privateLayout.setExpandedInflatedSmartReplies(
-                        result.expandedInflatedSmartReplies
-                    )
-                } else {
-                    privateLayout.setExpandedInflatedSmartReplies(null)
-                }
-                row.setExpandable(result.remoteViews.expanded != null)
-            }
-            if (reInflateFlags and FLAG_CONTENT_VIEW_HEADS_UP != 0) {
-                if (result.inflatedHeadsUpView != null) {
-                    privateLayout.setHeadsUpChild(result.inflatedHeadsUpView)
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_HEADS_UP,
-                        result.remoteViews.headsUp
-                    )
-                } else if (result.remoteViews.headsUp == null) {
-                    privateLayout.setHeadsUpChild(null)
-                    remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
-                } else if (remoteViewCache.hasCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)) {
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_HEADS_UP,
-                        result.remoteViews.headsUp
-                    )
-                }
-                if (result.remoteViews.headsUp != null) {
-                    privateLayout.setHeadsUpInflatedSmartReplies(result.headsUpInflatedSmartReplies)
-                } else {
-                    privateLayout.setHeadsUpInflatedSmartReplies(null)
-                }
-            }
+
+            // set the content model after disposal and before setting new rich ongoing view
+            entry.setContentModel(result.contentModel)
+            result.inflatedSmartReplyState?.let { row.privateLayout.setInflatedSmartReplyState(it) }
+
+            // set normal remote views (skipping rich ongoing states when that model exists)
+            val remoteViewsFlags =
+                getRemoteViewsFlags(reInflateFlags, result.contentModel.richOngoingContentModel)
+            setContentViewsFromRemoteViews(
+                remoteViewsFlags,
+                entry,
+                remoteViewCache,
+                result,
+                row,
+                isMinimized,
+            )
+
+            // set single line view
             if (
                 AsyncHybridViewInflation.isEnabled &&
                     reInflateFlags and FLAG_CONTENT_VIEW_SINGLE_LINE != 0
@@ -1444,80 +1410,144 @@
                     } else {
                         SingleLineViewBinder.bind(viewModel, singleLineView)
                     }
-                    privateLayout.setSingleLineView(result.inflatedSingleLineView)
+                    row.privateLayout.setSingleLineView(result.inflatedSingleLineView)
                 }
             }
-            result.inflatedSmartReplyState?.let { privateLayout.setInflatedSmartReplyState(it) }
-            if (reInflateFlags and FLAG_CONTENT_VIEW_PUBLIC != 0) {
-                if (result.inflatedPublicView != null) {
-                    publicLayout.setContractedChild(result.inflatedPublicView)
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_PUBLIC,
-                        result.remoteViews.public
-                    )
-                } else if (remoteViewCache.hasCachedView(entry, FLAG_CONTENT_VIEW_PUBLIC)) {
-                    remoteViewCache.putCachedView(
-                        entry,
-                        FLAG_CONTENT_VIEW_PUBLIC,
-                        result.remoteViews.public
-                    )
-                }
+
+            // after updating the content model, set the view, then start the new binder
+            result.richOngoingNotificationViewHolder?.let { viewHolder ->
+                row.privateLayout.contractedChild = viewHolder.view
+                row.privateLayout.expandedChild = null
+                row.privateLayout.headsUpChild = null
+                row.privateLayout.setExpandedInflatedSmartReplies(null)
+                row.privateLayout.setHeadsUpInflatedSmartReplies(null)
+                row.privateLayout.mContractedBinderHandle =
+                    viewHolder.binder.setupContentViewBinder()
+                row.setExpandable(false)
+                remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_CONTRACTED)
+                remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
+                remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
             }
-            if (AsyncGroupHeaderViewInflation.isEnabled) {
-                if (reInflateFlags and FLAG_GROUP_SUMMARY_HEADER != 0) {
-                    if (result.inflatedGroupHeaderView != 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.setGroupHeader(/* headerView= */ result.inflatedGroupHeaderView)
-                        remoteViewCache.putCachedView(
-                            entry,
-                            FLAG_GROUP_SUMMARY_HEADER,
-                            result.remoteViews.normalGroupHeader
-                        )
-                    } else if (remoteViewCache.hasCachedView(entry, FLAG_GROUP_SUMMARY_HEADER)) {
-                        // Re-inflation case. Only update if it's still cached (i.e. view has not
-                        // been freed while inflating).
-                        remoteViewCache.putCachedView(
-                            entry,
-                            FLAG_GROUP_SUMMARY_HEADER,
-                            result.remoteViews.normalGroupHeader
-                        )
-                    }
-                }
-                if (reInflateFlags and FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER != 0) {
-                    if (result.inflatedMinimizedGroupHeaderView != 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.inflatedMinimizedGroupHeaderView
-                        )
-                        remoteViewCache.putCachedView(
-                            entry,
-                            FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER,
-                            result.remoteViews.minimizedGroupHeader
-                        )
-                    } 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
-                        // been freed while inflating).
-                        remoteViewCache.putCachedView(
-                            entry,
-                            FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER,
-                            result.remoteViews.normalGroupHeader
-                        )
-                    }
-                }
-            }
-            entry.setContentModel(result.contentModel)
+
             Trace.endAsyncSection(APPLY_TRACE_METHOD, System.identityHashCode(row))
             endListener?.onAsyncInflationFinished(entry)
             return true
         }
 
+        private fun setContentViewsFromRemoteViews(
+            @InflationFlag reInflateFlags: Int,
+            entry: NotificationEntry,
+            remoteViewCache: NotifRemoteViewCache,
+            result: InflationProgress,
+            row: ExpandableNotificationRow,
+            isMinimized: Boolean,
+        ) {
+            val privateLayout = row.privateLayout
+            val publicLayout = row.publicLayout
+            val remoteViewsUpdater = RemoteViewsUpdater(reInflateFlags, entry, remoteViewCache)
+            remoteViewsUpdater.setContentView(
+                FLAG_CONTENT_VIEW_CONTRACTED,
+                result.remoteViews.contracted,
+                result.inflatedContentView,
+                privateLayout::setContractedChild
+            )
+            remoteViewsUpdater.setContentView(
+                FLAG_CONTENT_VIEW_EXPANDED,
+                result.remoteViews.expanded,
+                result.inflatedExpandedView,
+                privateLayout::setExpandedChild
+            )
+            remoteViewsUpdater.setSmartReplies(
+                FLAG_CONTENT_VIEW_EXPANDED,
+                result.remoteViews.expanded,
+                result.expandedInflatedSmartReplies,
+                privateLayout::setExpandedInflatedSmartReplies
+            )
+            if (reInflateFlags and FLAG_CONTENT_VIEW_EXPANDED != 0) {
+                row.setExpandable(result.remoteViews.expanded != null)
+            }
+            remoteViewsUpdater.setContentView(
+                FLAG_CONTENT_VIEW_HEADS_UP,
+                result.remoteViews.headsUp,
+                result.inflatedHeadsUpView,
+                privateLayout::setHeadsUpChild
+            )
+            remoteViewsUpdater.setSmartReplies(
+                FLAG_CONTENT_VIEW_HEADS_UP,
+                result.remoteViews.headsUp,
+                result.headsUpInflatedSmartReplies,
+                privateLayout::setHeadsUpInflatedSmartReplies
+            )
+            remoteViewsUpdater.setContentView(
+                FLAG_CONTENT_VIEW_PUBLIC,
+                result.remoteViews.public,
+                result.inflatedPublicView,
+                publicLayout::setContractedChild
+            )
+            if (AsyncGroupHeaderViewInflation.isEnabled) {
+                remoteViewsUpdater.setContentView(
+                    FLAG_GROUP_SUMMARY_HEADER,
+                    result.remoteViews.normalGroupHeader,
+                    result.inflatedGroupHeaderView,
+                ) { views ->
+                    row.setIsMinimized(isMinimized)
+                    row.setGroupHeader(views)
+                }
+                remoteViewsUpdater.setContentView(
+                    FLAG_LOW_PRIORITY_GROUP_SUMMARY_HEADER,
+                    result.remoteViews.minimizedGroupHeader,
+                    result.inflatedMinimizedGroupHeaderView,
+                ) { views ->
+                    row.setIsMinimized(isMinimized)
+                    row.setMinimizedGroupHeader(views)
+                }
+            }
+        }
+
+        private class RemoteViewsUpdater(
+            @InflationFlag private val reInflateFlags: Int,
+            private val entry: NotificationEntry,
+            private val remoteViewCache: NotifRemoteViewCache,
+        ) {
+            fun <V : View> setContentView(
+                @InflationFlag flagState: Int,
+                remoteViews: RemoteViews?,
+                view: V?,
+                setView: (V?) -> Unit,
+            ) {
+                val clearViewFlags = FLAG_CONTENT_VIEW_HEADS_UP or FLAG_CONTENT_VIEW_EXPANDED
+                val shouldClearView = flagState and clearViewFlags != 0
+                if (reInflateFlags and flagState != 0) {
+                    if (view != null) {
+                        setView(view)
+                        remoteViewCache.putCachedView(entry, flagState, remoteViews)
+                    } else if (shouldClearView && remoteViews == null) {
+                        setView(null)
+                        remoteViewCache.removeCachedView(entry, flagState)
+                    } else if (remoteViewCache.hasCachedView(entry, flagState)) {
+                        // Re-inflation case. Only update if it's still cached (i.e. view has not
+                        // been freed while inflating).
+                        remoteViewCache.putCachedView(entry, flagState, remoteViews)
+                    }
+                }
+            }
+
+            fun setSmartReplies(
+                @InflationFlag flagState: Int,
+                remoteViews: RemoteViews?,
+                smartReplies: InflatedSmartReplyViewHolder?,
+                setSmartReplies: (InflatedSmartReplyViewHolder?) -> Unit,
+            ) {
+                if (reInflateFlags and flagState != 0) {
+                    if (remoteViews != null) {
+                        setSmartReplies(smartReplies)
+                    } else {
+                        setSmartReplies(null)
+                    }
+                }
+            }
+        }
+
         private fun createExpandedView(
             builder: Notification.Builder,
             isMinimized: Boolean
@@ -1562,6 +1592,21 @@
                     !oldView.hasFlags(RemoteViews.FLAG_REAPPLY_DISALLOWED)
         }
 
+        @InflationFlag
+        private fun getRemoteViewsFlags(
+            @InflationFlag reInflateFlags: Int,
+            richOngoingContentModel: RichOngoingContentModel?
+        ): Int =
+            if (richOngoingContentModel != null) {
+                reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING.inv()
+            } else {
+                reInflateFlags
+            }
+
+        @InflationFlag
+        private const val CONTENT_VIEWS_TO_CREATE_RICH_ONGOING =
+            FLAG_CONTENT_VIEW_CONTRACTED or FLAG_CONTENT_VIEW_EXPANDED or FLAG_CONTENT_VIEW_HEADS_UP
+
         private const val ASYNC_TASK_TRACE_METHOD =
             "NotificationRowContentBinderImpl.AsyncInflationTask"
         private const val APPLY_TRACE_METHOD = "NotificationRowContentBinderImpl#apply"
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java
index 84f2f66..c630c4d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java
@@ -18,6 +18,8 @@
 
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor;
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag;
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.RichOngoingViewModelComponent;
 
 import dagger.Binds;
 import dagger.Module;
@@ -28,7 +30,7 @@
 /**
  * Dagger Module containing notification row and view inflation implementations.
  */
-@Module
+@Module(subcomponents = {RichOngoingViewModelComponent.class})
 public abstract class NotificationRowModule {
 
     /**
@@ -47,6 +49,25 @@
         }
     }
 
+    /** Provides ron content model extractor. */
+    @Provides
+    @SysUISingleton
+    public static RichOngoingNotificationContentExtractor provideRonContentExtractor(
+            Provider<RichOngoingNotificationContentExtractorImpl> realImpl
+    ) {
+        if (RichOngoingNotificationFlag.isEnabled()) {
+            return realImpl.get();
+        } else {
+            return new NoOpRichOngoingNotificationContentExtractor();
+        }
+    }
+
+    /** Provides ron view inflater. */
+    @Binds
+    @SysUISingleton
+    public abstract RichOngoingNotificationViewInflater provideRonViewInflater(
+            RichOngoingNotificationViewInflaterImpl impl);
+
     /**
      * Provides notification remote view cache instance.
      */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt
new file mode 100644
index 0000000..b5ea861
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt
@@ -0,0 +1,170 @@
+/*
+ * 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.row
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.util.Log
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.statusbar.notification.collection.NotificationEntry
+import com.android.systemui.statusbar.notification.row.shared.IconModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneId
+import javax.inject.Inject
+
+/**
+ * Interface which provides a [RichOngoingContentModel] for a given [Notification] when one is
+ * applicable to the given style.
+ */
+interface RichOngoingNotificationContentExtractor {
+    fun extractContentModel(
+        entry: NotificationEntry,
+        builder: Notification.Builder,
+        systemUIContext: Context,
+        packageContext: Context
+    ): RichOngoingContentModel?
+}
+
+class NoOpRichOngoingNotificationContentExtractor : RichOngoingNotificationContentExtractor {
+    override fun extractContentModel(
+        entry: NotificationEntry,
+        builder: Notification.Builder,
+        systemUIContext: Context,
+        packageContext: Context
+    ): RichOngoingContentModel? = null
+}
+
+@SysUISingleton
+class RichOngoingNotificationContentExtractorImpl @Inject constructor() :
+    RichOngoingNotificationContentExtractor {
+
+    init {
+        /* check if */ RichOngoingNotificationFlag.isUnexpectedlyInLegacyMode()
+    }
+
+    override fun extractContentModel(
+        entry: NotificationEntry,
+        builder: Notification.Builder,
+        systemUIContext: Context,
+        packageContext: Context
+    ): RichOngoingContentModel? =
+        try {
+            val sbn = entry.sbn
+            val notification = sbn.notification
+            val icon = IconModel(notification.smallIcon)
+            if (sbn.packageName == "com.google.android.deskclock") {
+                when (notification.channelId) {
+                    "Timers v2" -> {
+                        parseTimerNotification(notification, icon)
+                    }
+                    "Stopwatch v2" -> {
+                        Log.i("RONs", "Can't process stopwatch yet")
+                        null
+                    }
+                    else -> {
+                        Log.i("RONs", "Can't process channel '${notification.channelId}'")
+                        null
+                    }
+                }
+            } else null
+        } catch (e: Exception) {
+            Log.e("RONs", "Error parsing RON", e)
+            null
+        }
+
+    /**
+     * FOR PROTOTYPING ONLY: create a RON TimerContentModel using the time information available
+     * inside the sortKey of the clock app's timer notifications.
+     */
+    private fun parseTimerNotification(
+        notification: Notification,
+        icon: IconModel
+    ): TimerContentModel {
+        // sortKey=1 0|↺7|RUNNING|▶16:21:58.523|Σ0:05:00|Δ0:00:03|⏳0:04:57
+        // sortKey=1 0|↺7|PAUSED|Σ0:05:00|Δ0:04:54|⏳0:00:06
+        // sortKey=1 1|↺7|RUNNING|▶16:30:28.433|Σ0:04:05|Δ0:00:06|⏳0:03:59
+        // sortKey=1 0|↺7|RUNNING|▶16:36:18.350|Σ0:05:00|Δ0:01:42|⏳0:03:18
+        // sortKey=1 2|↺7|RUNNING|▶16:38:37.816|Σ0:02:00|Δ0:01:09|⏳0:00:51
+        // ▶ = "current" time (when updated)
+        // Σ = total time
+        // Δ = time elapsed
+        // ⏳ = time remaining
+        val sortKey = notification.sortKey
+        val (_, _, state, extra) = sortKey.split("|", limit = 4)
+        return when (state) {
+            "PAUSED" -> {
+                val (total, _, remaining) = extra.split("|")
+                val timeRemaining = parseTimeDelta(remaining)
+                TimerContentModel(
+                    icon = icon,
+                    name = total,
+                    state =
+                        TimerContentModel.TimerState.Paused(
+                            timeRemaining = timeRemaining,
+                            resumeIntent = notification.findActionWithName("Resume"),
+                            resetIntent = notification.findActionWithName("Reset"),
+                        )
+                )
+            }
+            "RUNNING" -> {
+                val (current, total, _, remaining) = extra.split("|")
+                val finishTime = parseCurrentTime(current) + parseTimeDelta(remaining).toMillis()
+                TimerContentModel(
+                    icon = icon,
+                    name = total,
+                    state =
+                        TimerContentModel.TimerState.Running(
+                            finishTime = finishTime,
+                            pauseIntent = notification.findActionWithName("Pause"),
+                            addOneMinuteIntent = notification.findActionWithName("Add 1 min"),
+                        )
+                )
+            }
+            else -> error("unknown state ($state) in sortKey=$sortKey")
+        }
+    }
+
+    private fun Notification.findActionWithName(name: String): PendingIntent? {
+        return actions.firstOrNull { name == it.title?.toString() }?.actionIntent
+    }
+
+    private fun parseCurrentTime(current: String): Long {
+        val (hour, minute, second, millis) = current.replace("▶", "").split(":", ".")
+        // NOTE: this won't work correctly at/around midnight.  It's just for prototyping.
+        val localDateTime =
+            LocalDateTime.of(
+                LocalDate.now(),
+                LocalTime.of(hour.toInt(), minute.toInt(), second.toInt(), millis.toInt() * 1000000)
+            )
+        val offset = ZoneId.systemDefault().rules.getOffset(localDateTime)
+        return localDateTime.toInstant(offset).toEpochMilli()
+    }
+
+    private fun parseTimeDelta(delta: String): Duration {
+        val (hour, minute, second) = delta.replace("Σ", "").replace("⏳", "").split(":")
+        return Duration.ofHours(hour.toLong())
+            .plusMinutes(minute.toLong())
+            .plusSeconds(second.toLong())
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt
new file mode 100644
index 0000000..e9c4960
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.row
+
+import android.app.Notification
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.notification.collection.NotificationEntry
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.StopwatchContentModel
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import com.android.systemui.statusbar.notification.row.ui.view.TimerView
+import com.android.systemui.statusbar.notification.row.ui.viewbinder.TimerViewBinder
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.RichOngoingViewModelComponent
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.TimerViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.DisposableHandle
+
+fun interface DeferredContentViewBinder {
+    fun setupContentViewBinder(): DisposableHandle
+}
+
+class InflatedContentViewHolder(val view: View, val binder: DeferredContentViewBinder)
+
+/**
+ * Interface which provides a [RichOngoingContentModel] for a given [Notification] when one is
+ * applicable to the given style.
+ */
+interface RichOngoingNotificationViewInflater {
+    fun inflateView(
+        contentModel: RichOngoingContentModel,
+        existingView: View?,
+        entry: NotificationEntry,
+        systemUiContext: Context,
+        parentView: ViewGroup,
+    ): InflatedContentViewHolder?
+}
+
+@SysUISingleton
+class RichOngoingNotificationViewInflaterImpl
+@Inject
+constructor(
+    private val viewModelComponentFactory: RichOngoingViewModelComponent.Factory,
+) : RichOngoingNotificationViewInflater {
+
+    override fun inflateView(
+        contentModel: RichOngoingContentModel,
+        existingView: View?,
+        entry: NotificationEntry,
+        systemUiContext: Context,
+        parentView: ViewGroup,
+    ): InflatedContentViewHolder? {
+        if (RichOngoingNotificationFlag.isUnexpectedlyInLegacyMode()) return null
+        val component = viewModelComponentFactory.create(entry)
+        return when (contentModel) {
+            is TimerContentModel ->
+                inflateTimerView(
+                    existingView,
+                    component::createTimerViewModel,
+                    systemUiContext,
+                    parentView
+                )
+            is StopwatchContentModel -> TODO("Not yet implemented")
+        }
+    }
+
+    private fun inflateTimerView(
+        existingView: View?,
+        createViewModel: () -> TimerViewModel,
+        systemUiContext: Context,
+        parentView: ViewGroup,
+    ): InflatedContentViewHolder? {
+        if (existingView is TimerView && !existingView.isReinflateNeeded()) return null
+        val newView =
+            LayoutInflater.from(systemUiContext)
+                .inflate(
+                    R.layout.rich_ongoing_timer_notification,
+                    parentView,
+                    /* attachToRoot= */ false
+                ) as TimerView
+        return InflatedContentViewHolder(newView) {
+            TimerViewBinder.bindWhileAttached(newView, createViewModel())
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt
new file mode 100644
index 0000000..bac887b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.row.data.repository
+
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import kotlinx.coroutines.flow.StateFlow
+
+/** A repository of states relating to a specific notification row. */
+interface NotificationRowRepository {
+    /**
+     * A flow of an immutable data class with the current state of the Rich Ongoing Notification
+     * content, if applicable.
+     */
+    val richOngoingContentModel: StateFlow<RichOngoingContentModel?>
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt
new file mode 100644
index 0000000..4705ace
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ *
+ */
+
+package com.android.systemui.statusbar.notification.row.domain.interactor
+
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filterIsInstance
+
+/** Interactor specific to a particular notification row. */
+class NotificationRowInteractor @Inject constructor(repository: NotificationRowRepository) {
+    /** Content of a rich ongoing timer notification. */
+    val timerContentModel: Flow<TimerContentModel> =
+        repository.richOngoingContentModel.filterIsInstance<TimerContentModel>()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt
new file mode 100644
index 0000000..e611938
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.row.shared
+
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+
+// TODO: figure out how to support lazy resolution of the drawable, e.g. on unrelated text change
+class IconModel(val icon: Icon) {
+    var drawable: Drawable? = null
+
+    override fun equals(other: Any?): Boolean =
+        when (other) {
+            null -> false
+            (other === this) -> true
+            !is IconModel -> false
+            else -> other.icon.sameAs(icon)
+        }
+
+    override fun toString(): String = "IconModel(icon=$icon, drawable=$drawable)"
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt
index b2421bc..46010a1 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt
@@ -21,4 +21,7 @@
 data class NotificationContentModel(
     val headsUpStatusBarModel: HeadsUpStatusBarModel,
     val singleLineViewModel: SingleLineViewModel? = null,
+    val richOngoingContentModel: RichOngoingContentModel? = null,
 )
+
+sealed interface RichOngoingContentModel
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt
new file mode 100644
index 0000000..5584701
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.row.shared
+
+import android.app.PendingIntent
+import java.time.Duration
+
+/**
+ * Represents a simple timer that counts down to a time.
+ *
+ * @param name the label for the timer
+ * @param state state of the timer, including time and whether it is paused or running
+ */
+data class TimerContentModel(
+    val icon: IconModel,
+    val name: String,
+    val state: TimerState,
+) : RichOngoingContentModel {
+    /** The state (paused or running) of the timer, and relevant time */
+    sealed interface TimerState {
+        /**
+         * Indicates a running timer
+         *
+         * @param finishTime the time in ms since epoch that the timer will finish
+         * @param pauseIntent the action for pausing the timer
+         */
+        data class Running(
+            val finishTime: Long,
+            val pauseIntent: PendingIntent?,
+            val addOneMinuteIntent: PendingIntent?,
+        ) : TimerState
+
+        /**
+         * Indicates a paused timer
+         *
+         * @param timeRemaining the time in ms remaining on the paused timer
+         * @param resumeIntent the action for resuming the timer
+         */
+        data class Paused(
+            val timeRemaining: Duration,
+            val resumeIntent: PendingIntent?,
+            val resetIntent: PendingIntent?,
+        ) : TimerState
+    }
+}
+
+/**
+ * Represents a simple stopwatch that counts up and allows tracking laps.
+ *
+ * @param state state of the stopwatch, including time and whether it is paused or running
+ * @param lapDurations a list of durations of each completed lap
+ */
+data class StopwatchContentModel(
+    val icon: IconModel,
+    val state: StopwatchState,
+    val lapDurations: List<Long>,
+) : RichOngoingContentModel {
+    /** The state (paused or running) of the stopwatch, and relevant time */
+    sealed interface StopwatchState {
+        /**
+         * Indicates a running stopwatch
+         *
+         * @param startTime the time in ms since epoch that the stopwatch started, plus any
+         *   accumulated pause time
+         * @param pauseIntent the action for pausing the stopwatch
+         */
+        data class Running(
+            val startTime: Long,
+            val pauseIntent: PendingIntent,
+        ) : StopwatchState
+
+        /**
+         * Indicates a paused stopwatch
+         *
+         * @param timeElapsed the time in ms elapsed on the stopwatch
+         * @param resumeIntent the action for resuming the stopwatch
+         */
+        data class Paused(
+            val timeElapsed: Duration,
+            val resumeIntent: PendingIntent,
+        ) : StopwatchState
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.kt
new file mode 100644
index 0000000..4a7f7cd
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.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.row.shared
+
+import android.app.Flags
+import com.android.systemui.flags.FlagToken
+import com.android.systemui.flags.RefactorFlagUtils
+
+/** Helper for reading or using the api rich ongoing flag state. */
+@Suppress("NOTHING_TO_INLINE")
+object RichOngoingNotificationFlag {
+    /** The aconfig flag name */
+    const val FLAG_NAME = Flags.FLAG_API_RICH_ONGOING
+
+    /** A token used for dependency declaration */
+    val token: FlagToken
+        get() = FlagToken(FLAG_NAME, isEnabled)
+
+    /** Is the refactor enabled */
+    @JvmStatic
+    inline val isEnabled
+        get() = Flags.apiRichOngoing()
+
+    /**
+     * 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/row/ui/view/ConfigurationTracker.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/ConfigurationTracker.kt
new file mode 100644
index 0000000..95c507c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/ConfigurationTracker.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.row.ui.view
+
+import android.content.pm.ActivityInfo.CONFIG_ASSETS_PATHS
+import android.content.pm.ActivityInfo.CONFIG_DENSITY
+import android.content.pm.ActivityInfo.CONFIG_FONT_SCALE
+import android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION
+import android.content.pm.ActivityInfo.CONFIG_LOCALE
+import android.content.pm.ActivityInfo.CONFIG_UI_MODE
+import android.content.res.Configuration
+import android.content.res.Resources
+
+/**
+ * Tracks the active configuration when constructed and returns (when queried) whether the
+ * configuration has unhandled changes.
+ */
+class ConfigurationTracker(
+    private val resources: Resources,
+    private val unhandledConfigChanges: Int
+) {
+    private val initialConfig = Configuration(resources.configuration)
+
+    constructor(
+        resources: Resources,
+        handlesDensityFontScale: Boolean = false,
+        handlesTheme: Boolean = false,
+        handlesLocaleAndLayout: Boolean = true,
+    ) : this(
+        resources,
+        unhandledConfigChanges =
+            (if (handlesDensityFontScale) 0 else CONFIG_DENSITY or CONFIG_FONT_SCALE) or
+                (if (handlesTheme) 0 else CONFIG_ASSETS_PATHS or CONFIG_UI_MODE) or
+                (if (handlesLocaleAndLayout) 0 else CONFIG_LOCALE or CONFIG_LAYOUT_DIRECTION)
+    )
+
+    /**
+     * Whether the current configuration has unhandled changes relative to the initial configuration
+     */
+    fun hasUnhandledConfigChange(): Boolean =
+        initialConfig.diff(resources.configuration) and unhandledConfigChanges != 0
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.kt
new file mode 100644
index 0000000..0d83ace
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.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.statusbar.notification.row.ui.view
+
+import android.annotation.DrawableRes
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.Button
+
+class TimerButtonView
+@JvmOverloads
+constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+    defStyleRes: Int = 0,
+) : Button(context, attrs, defStyleAttr, defStyleRes) {
+
+    private val Int.dp: Int
+        get() = (this * context.resources.displayMetrics.density).toInt()
+
+    fun setIcon(@DrawableRes icon: Int) {
+        val drawable = context.getDrawable(icon)
+        drawable?.setBounds(0, 0, 24.dp, 24.dp)
+        setCompoundDrawablesRelative(drawable, null, null, null)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt
new file mode 100644
index 0000000..2e164d6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.row.ui.view
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.os.SystemClock
+import android.util.AttributeSet
+import android.widget.Chronometer
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
+import com.android.systemui.res.R
+
+class TimerView
+@JvmOverloads
+constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+    defStyleRes: Int = 0,
+) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+    private val configTracker = ConfigurationTracker(resources)
+
+    private lateinit var icon: ImageView
+    private lateinit var label: TextView
+    private lateinit var chronometer: Chronometer
+    private lateinit var pausedTimeRemaining: TextView
+    lateinit var mainButton: TimerButtonView
+        private set
+
+    lateinit var altButton: TimerButtonView
+        private set
+
+    override fun onFinishInflate() {
+        super.onFinishInflate()
+        icon = requireViewById(R.id.icon)
+        label = requireViewById(R.id.label)
+        chronometer = requireViewById(R.id.chronoRemaining)
+        pausedTimeRemaining = requireViewById(R.id.pausedTimeRemaining)
+        mainButton = requireViewById(R.id.mainButton)
+        altButton = requireViewById(R.id.altButton)
+    }
+
+    /** the resources configuration has changed such that the view needs to be reinflated */
+    fun isReinflateNeeded(): Boolean = configTracker.hasUnhandledConfigChange()
+
+    fun setIcon(iconDrawable: Drawable?) {
+        this.icon.setImageDrawable(iconDrawable)
+    }
+
+    fun setLabel(label: String) {
+        this.label.text = label
+    }
+
+    fun setPausedTime(pausedTime: String?) {
+        if (pausedTime != null) {
+            pausedTimeRemaining.text = pausedTime
+            pausedTimeRemaining.isVisible = true
+        } else {
+            pausedTimeRemaining.isVisible = false
+        }
+    }
+
+    fun setCountdownTime(countdownTimeMs: Long?) {
+        if (countdownTimeMs != null) {
+            chronometer.base =
+                countdownTimeMs - System.currentTimeMillis() + SystemClock.elapsedRealtime()
+            chronometer.isVisible = true
+            chronometer.start()
+        } else {
+            chronometer.isVisible = false
+            chronometer.stop()
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.kt
new file mode 100644
index 0000000..c9ff589
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.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.statusbar.notification.row.ui.viewbinder
+
+import android.view.View
+import androidx.core.view.isGone
+import androidx.lifecycle.lifecycleScope
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.statusbar.notification.row.ui.view.TimerButtonView
+import com.android.systemui.statusbar.notification.row.ui.view.TimerView
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.TimerViewModel
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/** Binds a [TimerView] to its [view model][TimerViewModel]. */
+object TimerViewBinder {
+    fun bindWhileAttached(
+        view: TimerView,
+        viewModel: TimerViewModel,
+    ): DisposableHandle {
+        return view.repeatWhenAttached { lifecycleScope.launch { bind(view, viewModel) } }
+    }
+
+    suspend fun bind(
+        view: TimerView,
+        viewModel: TimerViewModel,
+    ) = coroutineScope {
+        launch { viewModel.icon.collect { view.setIcon(it) } }
+        launch { viewModel.label.collect { view.setLabel(it) } }
+        launch { viewModel.pausedTime.collect { view.setPausedTime(it) } }
+        launch { viewModel.countdownTime.collect { view.setCountdownTime(it) } }
+        launch { viewModel.mainButtonModel.collect { bind(view.mainButton, it) } }
+        launch { viewModel.altButtonModel.collect { bind(view.altButton, it) } }
+    }
+
+    fun bind(buttonView: TimerButtonView, model: TimerViewModel.ButtonViewModel?) {
+        if (model != null) {
+            buttonView.setIcon(model.iconRes)
+            buttonView.setText(model.labelRes)
+            buttonView.setOnClickListener(
+                model.pendingIntent?.let { pendingIntent ->
+                    View.OnClickListener { pendingIntent.send() }
+                }
+            )
+            buttonView.isEnabled = model.pendingIntent != null
+        }
+        buttonView.isGone = model == null
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt
new file mode 100644
index 0000000..dad52a3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.row.ui.viewmodel
+
+// noinspection CleanArchitectureDependencyViolation
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+import dagger.BindsInstance
+import dagger.Subcomponent
+
+@Subcomponent
+interface RichOngoingViewModelComponent {
+
+    @Subcomponent.Factory
+    interface Factory {
+        /** Creates an instance of [RichOngoingViewModelComponent]. */
+        fun create(
+            @BindsInstance repository: NotificationRowRepository
+        ): RichOngoingViewModelComponent
+    }
+
+    fun createTimerViewModel(): TimerViewModel
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt
new file mode 100644
index 0000000..a85c87f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt
@@ -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.systemui.statusbar.notification.row.ui.viewmodel
+
+import android.annotation.DrawableRes
+import android.annotation.StringRes
+import android.app.PendingIntent
+import android.graphics.drawable.Drawable
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.statusbar.notification.row.domain.interactor.NotificationRowInteractor
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel.TimerState
+import com.android.systemui.util.kotlin.FlowDumperImpl
+import java.time.Duration
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+
+/** A view model for Timer notifications. */
+class TimerViewModel
+@Inject
+constructor(
+    dumpManager: DumpManager,
+    rowInteractor: NotificationRowInteractor,
+) : FlowDumperImpl(dumpManager) {
+    init {
+        /* check if */ RichOngoingNotificationFlag.isUnexpectedlyInLegacyMode()
+    }
+
+    private val state: Flow<TimerState> = rowInteractor.timerContentModel.mapNotNull { it.state }
+
+    val icon: Flow<Drawable?> = rowInteractor.timerContentModel.mapNotNull { it.icon.drawable }
+
+    val label: Flow<String> = rowInteractor.timerContentModel.mapNotNull { it.name }
+
+    val countdownTime: Flow<Long?> = state.map { (it as? TimerState.Running)?.finishTime }
+
+    val pausedTime: Flow<String?> =
+        state.map { (it as? TimerState.Paused)?.timeRemaining?.format() }
+
+    val mainButtonModel: Flow<ButtonViewModel> =
+        state.map {
+            when (it) {
+                is TimerState.Paused ->
+                    ButtonViewModel(
+                        it.resumeIntent,
+                        com.android.systemui.res.R.string.controls_media_resume, // "Resume",
+                        com.android.systemui.res.R.drawable.ic_media_play
+                    )
+                is TimerState.Running ->
+                    ButtonViewModel(
+                        it.pauseIntent,
+                        com.android.systemui.res.R.string.controls_media_button_pause, // "Pause",
+                        com.android.systemui.res.R.drawable.ic_media_pause
+                    )
+            }
+        }
+
+    val altButtonModel: Flow<ButtonViewModel?> =
+        state.map {
+            when (it) {
+                is TimerState.Paused ->
+                    it.resetIntent?.let { resetIntent ->
+                        ButtonViewModel(
+                            resetIntent,
+                            com.android.systemui.res.R.string.reset, // "Reset",
+                            com.android.systemui.res.R.drawable.ic_close_white_rounded
+                        )
+                    }
+                is TimerState.Running ->
+                    it.addOneMinuteIntent?.let { addOneMinuteIntent ->
+                        ButtonViewModel(
+                            addOneMinuteIntent,
+                            com.android.systemui.res.R.string.add, // "Add 1 minute",
+                            com.android.systemui.res.R.drawable.ic_add
+                        )
+                    }
+            }
+        }
+
+    data class ButtonViewModel(
+        val pendingIntent: PendingIntent?,
+        @StringRes val labelRes: Int,
+        @DrawableRes val iconRes: Int,
+    )
+}
+
+private fun Duration.format(): String {
+    val hours = this.toHours()
+    return if (hours > 0) {
+        String.format("%d:%02d:%02d", hours, toMinutesPart(), toSecondsPart())
+    } else {
+        String.format("%d:%02d", toMinutes(), toSecondsPart())
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/AmbientState.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/AmbientState.java
index 2cbb6ae..fbddc06 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/AmbientState.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/AmbientState.java
@@ -131,13 +131,13 @@
     /** Distance of top of notifications panel from top of screen. */
     private float mStackY = 0;
 
-    /** Height of notifications panel. */
+    /** Height of notifications panel interpolated by the expansion fraction. */
     private float mStackHeight = 0;
 
     /** Fraction of shade expansion. */
     private float mExpansionFraction;
 
-    /** Height of the notifications panel without top padding when expansion completes. */
+    /** Height of the notifications panel when expansion completes. */
     private float mStackEndHeight;
 
     /** Whether we are swiping up. */
@@ -176,8 +176,7 @@
     }
 
     /**
-     * @param stackEndHeight Height of the notifications panel without top padding
-     *                       when expansion completes.
+     * @see #getStackEndHeight()
      */
     public void setStackEndHeight(float stackEndHeight) {
         mStackEndHeight = stackEndHeight;
@@ -257,14 +256,14 @@
     }
 
     /**
-     * @param stackHeight Height of notifications panel.
+     * @see #getStackHeight()
      */
     public void setStackHeight(float stackHeight) {
         mStackHeight = stackHeight;
     }
 
     /**
-     * @return Height of notifications panel.
+     * @return Height of notifications panel interpolated by the expansion fraction.
      */
     public float getStackHeight() {
         return mStackHeight;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java
index 1eee466..d54e66e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java
@@ -1440,22 +1440,40 @@
     @VisibleForTesting
     public void updateStackEndHeightAndStackHeight(float fraction) {
         final float oldStackHeight = mAmbientState.getStackHeight();
-        if (mQsExpansionFraction <= 0 && !shouldSkipHeightUpdate()) {
-            final float endHeight = updateStackEndHeight(
-                    getHeight(), getEmptyBottomMargin(), getTopPadding());
+        if (SceneContainerFlag.isEnabled()) {
+            final float endHeight;
+            if (!shouldSkipHeightUpdate()) {
+                endHeight = updateStackEndHeight();
+            } else {
+                endHeight = mAmbientState.getStackEndHeight();
+            }
             updateStackHeight(endHeight, fraction);
         } else {
-            // Always updateStackHeight to prevent jumps in the stack height when this fraction
-            // suddenly reapplies after a freeze.
-            final float endHeight = mAmbientState.getStackEndHeight();
-            updateStackHeight(endHeight, fraction);
+            if (mQsExpansionFraction <= 0 && !shouldSkipHeightUpdate()) {
+                final float endHeight = updateStackEndHeight(
+                        getHeight(), getEmptyBottomMargin(), getTopPadding());
+                updateStackHeight(endHeight, fraction);
+            } else {
+                // Always updateStackHeight to prevent jumps in the stack height when this fraction
+                // suddenly reapplies after a freeze.
+                final float endHeight = mAmbientState.getStackEndHeight();
+                updateStackHeight(endHeight, fraction);
+            }
         }
         if (oldStackHeight != mAmbientState.getStackHeight()) {
             requestChildrenUpdate();
         }
     }
 
+    private float updateStackEndHeight() {
+        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return 0f;
+        float height = Math.max(0f, mAmbientState.getStackCutoff() - mAmbientState.getStackTop());
+        mAmbientState.setStackEndHeight(height);
+        return height;
+    }
+
     private float updateStackEndHeight(float height, float bottomMargin, float topPadding) {
+        SceneContainerFlag.assertInLegacyMode();
         final float stackEndHeight;
         if (mMaxDisplayedNotifications != -1) {
             // The stack intrinsic height already contains the correct value when there is a limit
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 ca74c0e..ee7b5c4 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
@@ -445,12 +445,26 @@
         state.visibleChildren.clear();
         state.visibleChildren.ensureCapacity(childCount);
         int notGoneIndex = 0;
+        boolean emptyShadeVisible = false;
         for (int i = 0; i < childCount; i++) {
             ExpandableView v = (ExpandableView) mHostView.getChildAt(i);
             if (v.getVisibility() != View.GONE) {
                 if (v == ambientState.getShelf()) {
                     continue;
                 }
+                if (FooterViewRefactor.isEnabled()) {
+                    if (v instanceof EmptyShadeView) {
+                        emptyShadeVisible = true;
+                    }
+                    if (v instanceof FooterView) {
+                        if (emptyShadeVisible || notGoneIndex == 0) {
+                            // if the empty shade is visible or the footer is the first visible
+                            // view, we're in a transitory state so let's leave the footer alone.
+                            continue;
+                        }
+                    }
+                }
+
                 notGoneIndex = updateNotGoneIndex(state, notGoneIndex, v);
                 if (v instanceof ExpandableNotificationRow row) {
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/DevicePostureControllerImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/DevicePostureControllerImpl.java
index de0eb49..528ef49 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/DevicePostureControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/DevicePostureControllerImpl.java
@@ -31,8 +31,8 @@
 
 import kotlin.Unit;
 
-import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Executor;
 
 import javax.inject.Inject;
@@ -42,7 +42,13 @@
 public class DevicePostureControllerImpl implements DevicePostureController {
     /** From androidx.window.common.COMMON_STATE_USE_BASE_STATE */
     private static final int COMMON_STATE_USE_BASE_STATE = 1000;
-    private final List<Callback> mListeners = new ArrayList<>();
+    /**
+     * Despite this is always used only from the main thread, it might be that some listener
+     * unregisters itself while we're sending the update, ending up modifying this while we're
+     * iterating it.
+     * Keeping a threadsafe list of listeners helps preventing ConcurrentModificationExceptions.
+     */
+    private final List<Callback> mListeners = new CopyOnWriteArrayList<>();
     private final List<DeviceState> mSupportedStates;
     private DeviceState mCurrentDeviceState;
     private int mCurrentDevicePosture = DEVICE_POSTURE_UNKNOWN;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ZenModeController.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ZenModeController.java
index ad19729..23e40b2 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ZenModeController.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ZenModeController.java
@@ -24,7 +24,14 @@
 import android.service.notification.ZenModeConfig.ZenRule;
 
 import com.android.systemui.statusbar.policy.ZenModeController.Callback;
+import com.android.systemui.statusbar.policy.domain.interactor.ZenModeInteractor;
 
+/**
+ * Callback-based controller for listening to (or making) zen mode changes. Please prefer using the
+ * Flow-based {@link ZenModeInteractor} for new code instead of this.
+ *
+ * TODO(b/308591859): This should eventually be replaced by ZenModeInteractor/ZenModeRepository.
+ */
 public interface ZenModeController extends CallbackController<Callback> {
     void setZen(int zen, Uri conditionId, String reason);
     int getZen();
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/dagger/StatusBarPolicyModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/dagger/StatusBarPolicyModule.java
index 15200bd..e08e4d7 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/dagger/StatusBarPolicyModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/dagger/StatusBarPolicyModule.java
@@ -73,7 +73,6 @@
 import com.android.systemui.statusbar.policy.bluetooth.BluetoothRepository;
 import com.android.systemui.statusbar.policy.bluetooth.BluetoothRepositoryImpl;
 import com.android.systemui.statusbar.policy.data.repository.DeviceProvisioningRepositoryModule;
-import com.android.systemui.statusbar.policy.data.repository.ZenModeRepositoryModule;
 
 import dagger.Binds;
 import dagger.Module;
@@ -84,7 +83,7 @@
 import javax.inject.Named;
 
 /** Dagger Module for code in the statusbar.policy package. */
-@Module(includes = { DeviceProvisioningRepositoryModule.class, ZenModeRepositoryModule.class })
+@Module(includes = {DeviceProvisioningRepositoryModule.class})
 public interface StatusBarPolicyModule {
 
     String DEVICE_STATE_ROTATION_LOCK_DEFAULTS = "DEVICE_STATE_ROTATION_LOCK_DEFAULTS";
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepository.kt
deleted file mode 100644
index 94ab58a..0000000
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepository.kt
+++ /dev/null
@@ -1,77 +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.policy.data.repository
-
-import android.app.NotificationManager
-import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
-import com.android.systemui.statusbar.policy.ZenModeController
-import dagger.Binds
-import dagger.Module
-import javax.inject.Inject
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-
-/**
- * A repository that holds information about the status and configuration of Zen Mode (or Do Not
- * Disturb/DND Mode).
- */
-interface ZenModeRepository {
-    val zenMode: Flow<Int>
-    val consolidatedNotificationPolicy: Flow<NotificationManager.Policy?>
-}
-
-class ZenModeRepositoryImpl
-@Inject
-constructor(
-    private val zenModeController: ZenModeController,
-) : ZenModeRepository {
-    // TODO(b/308591859): ZenModeController should use flows instead of callbacks. The
-    // conflatedCallbackFlows here should be replaced eventually, see:
-    // https://docs.google.com/document/d/1gAiuYupwUAFdbxkDXa29A4aFNu7XoCd7sCIk31WTnHU/edit?resourcekey=0-J4ZBiUhLhhQnNobAcI2vIw
-
-    override val zenMode: Flow<Int> = conflatedCallbackFlow {
-        val callback =
-            object : ZenModeController.Callback {
-                override fun onZenChanged(zen: Int) {
-                    trySend(zen)
-                }
-            }
-        zenModeController.addCallback(callback)
-        trySend(zenModeController.zen)
-
-        awaitClose { zenModeController.removeCallback(callback) }
-    }
-
-    override val consolidatedNotificationPolicy: Flow<NotificationManager.Policy?> =
-        conflatedCallbackFlow {
-            val callback =
-                object : ZenModeController.Callback {
-                    override fun onConsolidatedPolicyChanged(policy: NotificationManager.Policy?) {
-                        trySend(policy)
-                    }
-                }
-            zenModeController.addCallback(callback)
-            trySend(zenModeController.consolidatedPolicy)
-
-            awaitClose { zenModeController.removeCallback(callback) }
-        }
-}
-
-@Module
-interface ZenModeRepositoryModule {
-    @Binds fun bindImpl(impl: ZenModeRepositoryImpl): ZenModeRepository
-}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt
index ae31851..f5d7d00 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt
@@ -17,7 +17,7 @@
 package com.android.systemui.statusbar.policy.domain.interactor
 
 import android.provider.Settings
-import com.android.systemui.statusbar.policy.data.repository.ZenModeRepository
+import com.android.settingslib.statusbar.notification.data.repository.ZenModeRepository
 import javax.inject.Inject
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.combine
@@ -30,9 +30,9 @@
  */
 class ZenModeInteractor @Inject constructor(repository: ZenModeRepository) {
     val isZenModeEnabled: Flow<Boolean> =
-        repository.zenMode
+        repository.globalZenMode
             .map {
-                when (it) {
+                when (it ?: Settings.Global.ZEN_MODE_OFF) {
                     Settings.Global.ZEN_MODE_ALARMS -> true
                     Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS -> true
                     Settings.Global.ZEN_MODE_NO_INTERRUPTIONS -> true
diff --git a/packages/SystemUI/src/com/android/systemui/unfold/FoldLightRevealOverlayAnimation.kt b/packages/SystemUI/src/com/android/systemui/unfold/FoldLightRevealOverlayAnimation.kt
index 9faa84e..c5e98a1 100644
--- a/packages/SystemUI/src/com/android/systemui/unfold/FoldLightRevealOverlayAnimation.kt
+++ b/packages/SystemUI/src/com/android/systemui/unfold/FoldLightRevealOverlayAnimation.kt
@@ -91,7 +91,7 @@
             controllerFactory.create(
                 displaySelector = { minByOrNull { it.naturalWidth } },
                 effectFactory = { LinearSideLightRevealEffect(it.isVerticalRotation()) },
-                overlayContainerName = SURFACE_CONTAINER_NAME
+                overlayContainerName = OVERLAY_TITLE
             )
         controller.init()
 
@@ -196,7 +196,7 @@
     private companion object {
         const val TAG = "FoldLightRevealOverlayAnimation"
         const val WAIT_FOR_ANIMATION_TIMEOUT_MS = 2000L
-        const val SURFACE_CONTAINER_NAME = "fold-overlay-container"
+        const val OVERLAY_TITLE = "fold-animation-overlay"
         val ANIMATION_DURATION: Long
             get() = SystemProperties.getLong("persist.fold_animation_duration", 200L)
     }
diff --git a/packages/SystemUI/src/com/android/systemui/unfold/FullscreenLightRevealAnimation.kt b/packages/SystemUI/src/com/android/systemui/unfold/FullscreenLightRevealAnimation.kt
index f368cac..a921377 100644
--- a/packages/SystemUI/src/com/android/systemui/unfold/FullscreenLightRevealAnimation.kt
+++ b/packages/SystemUI/src/com/android/systemui/unfold/FullscreenLightRevealAnimation.kt
@@ -73,7 +73,7 @@
     @Main private val executor: Executor,
     @Assisted private val displaySelector: List<DisplayInfo>.() -> DisplayInfo?,
     @Assisted private val lightRevealEffectFactory: (rotation: Int) -> LightRevealEffect,
-    @Assisted private val overlayContainerName: String
+    @Assisted private val overlayTitle: String
 ) {
 
     private lateinit var bgExecutor: Executor
@@ -156,7 +156,7 @@
         val containerBuilder =
             SurfaceControl.Builder(SurfaceSession())
                 .setContainerLayer()
-                .setName(overlayContainerName)
+                .setName("FoldUnfoldAnimationContainer")
 
         displayAreaHelper
             .get()
@@ -224,7 +224,7 @@
             }
             format = PixelFormat.TRANSLUCENT
             type = WindowManager.LayoutParams.TYPE_DISPLAY_OVERLAY
-            title = javaClass.simpleName
+            title = overlayTitle
             layoutInDisplayCutoutMode =
                 WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
             fitInsetsTypes = 0
diff --git a/packages/SystemUI/src/com/android/systemui/unfold/UnfoldLightRevealOverlayAnimation.kt b/packages/SystemUI/src/com/android/systemui/unfold/UnfoldLightRevealOverlayAnimation.kt
index f355dd8..666e75f 100644
--- a/packages/SystemUI/src/com/android/systemui/unfold/UnfoldLightRevealOverlayAnimation.kt
+++ b/packages/SystemUI/src/com/android/systemui/unfold/UnfoldLightRevealOverlayAnimation.kt
@@ -71,7 +71,7 @@
             fullscreenLightRevealAnimationControllerFactory.create(
                 displaySelector = { maxByOrNull { it.naturalWidth } },
                 effectFactory = { LinearLightRevealEffect(it.isVerticalRotation()) },
-                overlayContainerName = SURFACE_CONTAINER_NAME,
+                overlayContainerName = OVERLAY_TITLE,
             )
         controller.init()
         bgExecutor = threadFactory.buildDelayableExecutorOnHandler(unfoldProgressHandler)
@@ -194,7 +194,7 @@
 
     private companion object {
         const val TAG = "UnfoldLightRevealOverlayAnimation"
-        const val SURFACE_CONTAINER_NAME = "unfold-overlay-container"
+        const val OVERLAY_TITLE = "unfold-animation-overlay"
         const val UNFOLD_BLOCK_TOUCHES_UNTIL_PROGRESS = 0.8f
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/composable/BouncerContentTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/composable/BouncerContentTest.kt
index d32d12c..a4936e6 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/composable/BouncerContentTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/composable/BouncerContentTest.kt
@@ -37,7 +37,7 @@
 import com.android.systemui.flags.Flags
 import com.android.systemui.flags.fakeFeatureFlagsClassic
 import com.android.systemui.motion.createSysUiComposeMotionTestRule
-import com.android.systemui.scene.domain.interactor.sceneContainerStartable
+import com.android.systemui.scene.domain.startable.sceneContainerStartable
 import com.android.systemui.testKosmos
 import org.junit.Before
 import org.junit.Rule
diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavBarHelperTest.java b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavBarHelperTest.java
index 2ff660f..bfbb7ce 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavBarHelperTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavBarHelperTest.java
@@ -36,6 +36,7 @@
 
 import android.content.ComponentName;
 import android.content.res.Configuration;
+import android.os.Handler;
 import android.view.IWindowManager;
 import android.view.accessibility.AccessibilityManager;
 
@@ -65,6 +66,8 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -120,6 +123,10 @@
     EdgeBackGestureHandler.Factory mEdgeBackGestureHandlerFactory;
     @Mock
     NotificationShadeWindowController mNotificationShadeWindowController;
+    @Mock
+    Handler mBgHandler;
+
+    @Captor ArgumentCaptor<Runnable> mRunnableArgumentCaptor;
     ConfigurationController mConfigurationController = new FakeConfigurationController();
 
     private AccessibilityManager.AccessibilityServicesStateChangeListener
@@ -149,7 +156,7 @@
                 () -> Optional.of(mock(CentralSurfaces.class)), mock(KeyguardStateController.class),
                 mNavigationModeController, mEdgeBackGestureHandlerFactory, mWm, mUserTracker,
                 mDisplayTracker, mNotificationShadeWindowController, mConfigurationController,
-                mDumpManager, mCommandQueue, mSynchronousExecutor);
+                mDumpManager, mCommandQueue, mSynchronousExecutor, mBgHandler);
     }
 
     @Test
@@ -203,8 +210,10 @@
                 .updateAccessibilityServicesState();
         verify(mNavbarTaskbarStateUpdater, times(1))
                 .updateAssistantAvailable(anyBoolean(), anyBoolean());
+        verify(mBgHandler).post(mRunnableArgumentCaptor.capture());
+        mRunnableArgumentCaptor.getValue().run();
         verify(mNavbarTaskbarStateUpdater, times(1))
-                .updateRotationWatcherState(anyInt());
+                .updateRotationWatcherState(anyInt(), anyBoolean());
         verify(mNavbarTaskbarStateUpdater, times(1))
                 .updateWallpaperVisibility(anyBoolean(), anyInt());
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarControllerImplTest.java b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarControllerImplTest.java
index d5361ac..df8eafe 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarControllerImplTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarControllerImplTest.java
@@ -21,6 +21,7 @@
 
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
 import static com.android.wm.shell.Flags.enableTaskbarNavbarUnification;
+import static com.android.wm.shell.Flags.enableTaskbarOnPhones;
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
@@ -142,10 +143,11 @@
 
     @Test
     public void testCreateNavigationBarsIncludeDefaultTrue() {
-        assumeFalse(enableTaskbarNavbarUnification());
+        assumeFalse(enableTaskbarNavbarUnification() && enableTaskbarOnPhones());
 
         // Large screens may be using taskbar and the logic is different
         mNavigationBarController.mIsLargeScreen = false;
+        mNavigationBarController.mIsPhone = true;
         doNothing().when(mNavigationBarController).createNavigationBar(any(), any(), any());
 
         mNavigationBarController.createNavigationBars(true, null);
@@ -291,6 +293,17 @@
 
     @Test
     public void testShouldRenderTaskbar_taskbarNotRenderedOnPhone() {
+        assumeFalse(enableTaskbarOnPhones());
+
+        mNavigationBarController.mIsLargeScreen = false;
+        mNavigationBarController.mIsPhone = true;
+        assertFalse(mNavigationBarController.supportsTaskbar());
+    }
+
+    @Test
+    public void testShouldRenderTaskbar_taskbarRenderedOnPhone() {
+        assumeTrue(enableTaskbarNavbarUnification() && enableTaskbarOnPhones());
+
         mNavigationBarController.mIsLargeScreen = false;
         mNavigationBarController.mIsPhone = true;
         assertFalse(mNavigationBarController.supportsTaskbar());
diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarTest.java b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarTest.java
index 2b60f65..982a269 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarTest.java
@@ -179,6 +179,9 @@
     private SysUiState mMockSysUiState;
     @Mock
     private Handler mHandler;
+
+    @Mock
+    private Handler mBgHandler;
     @Mock
     private UserTracker mUserTracker;
     @Mock
@@ -277,7 +280,8 @@
                     mEdgeBackGestureHandlerFactory, mock(IWindowManager.class),
                     mock(UserTracker.class), mock(DisplayTracker.class),
                     mNotificationShadeWindowController, mock(ConfigurationController.class),
-                    mock(DumpManager.class), mock(CommandQueue.class), mSynchronousExecutor));
+                    mock(DumpManager.class), mock(CommandQueue.class), mSynchronousExecutor,
+                    mBgHandler));
             mNavigationBar = createNavBar(mContext);
             mExternalDisplayNavigationBar = createNavBar(mSysuiTestableContextExternal);
         });
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 503c52f..ce1a885 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/recordissue/RecordIssueDialogDelegateTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/recordissue/RecordIssueDialogDelegateTest.kt
@@ -17,7 +17,6 @@
 package com.android.systemui.recordissue
 
 import android.app.Dialog
-import android.content.SharedPreferences
 import android.os.UserHandle
 import android.testing.TestableLooper
 import android.widget.Button
@@ -57,6 +56,7 @@
 import org.mockito.Mock
 import org.mockito.Mockito.never
 import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
 import org.mockito.MockitoAnnotations
 
@@ -71,7 +71,6 @@
     @Mock private lateinit var mediaProjectionMetricsLogger: MediaProjectionMetricsLogger
     @Mock private lateinit var userTracker: UserTracker
     @Mock private lateinit var state: IssueRecordingState
-    @Mock private lateinit var sharedPreferences: SharedPreferences
     @Mock
     private lateinit var screenCaptureDisabledDialogDelegate: ScreenCaptureDisabledDialogDelegate
     @Mock private lateinit var screenCaptureDisabledDialog: SystemUIDialog
@@ -192,7 +191,7 @@
                 anyInt(),
                 eq(SessionCreationSource.SYSTEM_UI_SCREEN_RECORDER)
             )
-        verify(factory).create(any<ScreenCapturePermissionDialogDelegate>())
+        verify(factory, times(2)).create(any(SystemUIDialog.Delegate::class.java))
     }
 
     @Test
@@ -213,7 +212,7 @@
                 anyInt(),
                 eq(SessionCreationSource.SYSTEM_UI_SCREEN_RECORDER)
             )
-        verify(factory, never()).create(any<ScreenCapturePermissionDialogDelegate>())
+        verify(factory, never()).create()
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepositoryTest.kt
index 8bfb07b..69cc9d5 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/shared/notifications/data/repository/NotificationSettingsRepositoryTest.kt
@@ -56,7 +56,7 @@
     @Test
     fun testGetIsShowNotificationsOnLockscreenEnabled() =
         testScope.runTest {
-            val showNotifs by collectLastValue(underTest.isShowNotificationsOnLockScreenEnabled)
+            val showNotifs by collectLastValue(underTest.isShowNotificationsOnLockScreenEnabled())
 
             secureSettingsRepository.setInt(
                 name = Settings.Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS,
@@ -74,7 +74,7 @@
     @Test
     fun testSetIsShowNotificationsOnLockscreenEnabled() =
         testScope.runTest {
-            val showNotifs by collectLastValue(underTest.isShowNotificationsOnLockScreenEnabled)
+            val showNotifs by collectLastValue(underTest.isShowNotificationsOnLockScreenEnabled())
 
             underTest.setShowNotificationsOnLockscreenEnabled(true)
             assertThat(showNotifs).isEqualTo(true)
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 0766afc..b8f8026 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
@@ -328,7 +328,7 @@
 
     @Test
     @EnableFlags(NotificationContentAlphaOptimization.FLAG_NAME)
-    public void setHideSensitive_changeContent_shouldNotDisturbAnimation() throws Exception {
+    public void setHideSensitive_changeContent_shouldResetAlpha() throws Exception {
 
         // Given: A sensitive row that has public version but is not hiding sensitive,
         // and is during an animation that sets its alpha value to be 0.5f
@@ -351,12 +351,12 @@
 
         // Then: The alpha value of private layout should be reset to 1, private layout be
         // INVISIBLE;
-        // The alpha value of public layout should be 0.5 to preserve the animation state, public
-        // layout should be VISIBLE
+        // The alpha value of public layout should be reset to 1 to avoid remaining transparent,
+        // public layout should be VISIBLE
         assertEquals(View.INVISIBLE, row.getPrivateLayout().getVisibility());
         assertEquals(1f, row.getPrivateLayout().getAlpha(), 0);
         assertEquals(View.VISIBLE, row.getPublicLayout().getVisibility());
-        assertEquals(0.5f, row.getPublicLayout().getAlpha(), 0);
+        assertEquals(1f, row.getPublicLayout().getAlpha(), 0);
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt
index e6cba1c..54a26f7 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt
@@ -23,6 +23,7 @@
 import android.platform.test.annotations.EnableFlags
 import android.testing.TestableLooper.RunWithLooper
 import android.util.TypedValue
+import android.util.TypedValue.COMPLEX_UNIT_SP
 import android.view.View
 import android.view.ViewGroup
 import android.widget.RemoteViews
@@ -34,27 +35,39 @@
 import com.android.systemui.statusbar.notification.ConversationNotificationProcessor
 import com.android.systemui.statusbar.notification.collection.NotificationEntry
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.BindParams
+import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL
+import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
+import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED
+import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationCallback
 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag
 import com.android.systemui.statusbar.notification.row.shared.HeadsUpStatusBarModel
 import com.android.systemui.statusbar.notification.row.shared.NewRemoteViews
 import com.android.systemui.statusbar.notification.row.shared.NotificationContentModel
 import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
 import com.android.systemui.statusbar.policy.InflatedSmartReplyState
 import com.android.systemui.statusbar.policy.InflatedSmartReplyViewHolder
 import com.android.systemui.statusbar.policy.SmartReplyStateInflater
-import com.android.systemui.util.concurrency.mockExecutorHandler
+import com.google.common.truth.Truth.assertThat
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.Executor
 import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.DisposableHandle
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.kotlin.any
+import org.mockito.kotlin.argThat
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.doReturn
 import org.mockito.kotlin.eq
+import org.mockito.kotlin.inOrder
 import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
 import org.mockito.kotlin.spy
 import org.mockito.kotlin.times
 import org.mockito.kotlin.verify
@@ -65,20 +78,24 @@
 @RunWithLooper
 @EnableFlags(NotificationRowContentBinderRefactor.FLAG_NAME)
 class NotificationRowContentBinderImplTest : SysuiTestCase() {
-    private lateinit var mNotificationInflater: NotificationRowContentBinderImpl
-    private lateinit var mBuilder: Notification.Builder
-    private lateinit var mRow: ExpandableNotificationRow
-    private lateinit var mHelper: NotificationTestHelper
+    private lateinit var notificationInflater: NotificationRowContentBinderImpl
+    private lateinit var builder: Notification.Builder
+    private lateinit var row: ExpandableNotificationRow
+    private lateinit var testHelper: NotificationTestHelper
 
-    private var mCache: NotifRemoteViewCache = mock()
-    private var mConversationNotificationProcessor: ConversationNotificationProcessor = mock()
-    private var mInflatedSmartReplyState: InflatedSmartReplyState = mock()
-    private var mInflatedSmartReplies: InflatedSmartReplyViewHolder = mock()
-    private var mNotifLayoutInflaterFactoryProvider: NotifLayoutInflaterFactory.Provider = mock()
-    private var mHeadsUpStyleProvider: HeadsUpStyleProvider = mock()
-    private var mNotifLayoutInflaterFactory: NotifLayoutInflaterFactory = mock()
-    private val mSmartReplyStateInflater: SmartReplyStateInflater =
+    private val cache: NotifRemoteViewCache = mock()
+    private val layoutInflaterFactoryProvider =
+        object : NotifLayoutInflaterFactory.Provider {
+            override fun provide(
+                row: ExpandableNotificationRow,
+                layoutType: Int
+            ): NotifLayoutInflaterFactory = mock()
+        }
+    private val smartReplyStateInflater: SmartReplyStateInflater =
         object : SmartReplyStateInflater {
+            private val inflatedSmartReplyState: InflatedSmartReplyState = mock()
+            private val inflatedSmartReplies: InflatedSmartReplyViewHolder = mock()
+
             override fun inflateSmartReplyViewHolder(
                 sysuiContext: Context,
                 notifPackageContext: Context,
@@ -86,37 +103,61 @@
                 existingSmartReplyState: InflatedSmartReplyState?,
                 newSmartReplyState: InflatedSmartReplyState
             ): InflatedSmartReplyViewHolder {
-                return mInflatedSmartReplies
+                return inflatedSmartReplies
             }
 
             override fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState {
-                return mInflatedSmartReplyState
+                return inflatedSmartReplyState
             }
         }
 
+    private var fakeRonContentModel: RichOngoingContentModel? = null
+    private val fakeRonExtractor =
+        object : RichOngoingNotificationContentExtractor {
+            override fun extractContentModel(
+                entry: NotificationEntry,
+                builder: Notification.Builder,
+                systemUIContext: Context,
+                packageContext: Context
+            ): RichOngoingContentModel? = fakeRonContentModel
+        }
+
+    private var fakeRonViewHolder: InflatedContentViewHolder? = null
+    private val fakeRonViewInflater =
+        spy(
+            object : RichOngoingNotificationViewInflater {
+                override fun inflateView(
+                    contentModel: RichOngoingContentModel,
+                    existingView: View?,
+                    entry: NotificationEntry,
+                    systemUiContext: Context,
+                    parentView: ViewGroup
+                ): InflatedContentViewHolder? = fakeRonViewHolder
+            }
+        )
+
     @Before
     fun setUp() {
         allowTestableLooperAsMainThread()
-        mBuilder =
+        builder =
             Notification.Builder(mContext, "no-id")
                 .setSmallIcon(R.drawable.ic_person)
                 .setContentTitle("Title")
                 .setContentText("Text")
                 .setStyle(Notification.BigTextStyle().bigText("big text"))
-        mHelper = NotificationTestHelper(mContext, mDependency)
-        val row = mHelper.createRow(mBuilder.build())
-        mRow = spy(row)
-        whenever(mNotifLayoutInflaterFactoryProvider.provide(any(), any()))
-            .thenReturn(mNotifLayoutInflaterFactory)
-        mNotificationInflater =
+        testHelper = NotificationTestHelper(mContext, mDependency)
+        row = spy(testHelper.createRow(builder.build()))
+        notificationInflater =
             NotificationRowContentBinderImpl(
-                mCache,
+                cache,
                 mock(),
-                mConversationNotificationProcessor,
+                mock<ConversationNotificationProcessor>(),
+                fakeRonExtractor,
+                fakeRonViewInflater,
                 mock(),
-                mSmartReplyStateInflater,
-                mNotifLayoutInflaterFactoryProvider,
-                mHeadsUpStyleProvider,
+                smartReplyStateInflater,
+                layoutInflaterFactoryProvider,
+                mock<HeadsUpStyleProvider>(),
                 mock()
             )
     }
@@ -125,16 +166,16 @@
     fun testIncreasedHeadsUpBeingUsed() {
         val params = BindParams()
         params.usesIncreasedHeadsUpHeight = true
-        val builder = spy(mBuilder)
-        mNotificationInflater.inflateNotificationViews(
-            mRow.entry,
-            mRow,
+        val builder = spy(builder)
+        notificationInflater.inflateNotificationViews(
+            row.entry,
+            row,
             params,
             true /* inflateSynchronously */,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
+            FLAG_CONTENT_VIEW_ALL,
             builder,
             mContext,
-            mSmartReplyStateInflater
+            smartReplyStateInflater
         )
         verify(builder).createHeadsUpContentView(true)
     }
@@ -143,80 +184,68 @@
     fun testIncreasedHeightBeingUsed() {
         val params = BindParams()
         params.usesIncreasedHeight = true
-        val builder = spy(mBuilder)
-        mNotificationInflater.inflateNotificationViews(
-            mRow.entry,
-            mRow,
+        val builder = spy(builder)
+        notificationInflater.inflateNotificationViews(
+            row.entry,
+            row,
             params,
             true /* inflateSynchronously */,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
+            FLAG_CONTENT_VIEW_ALL,
             builder,
             mContext,
-            mSmartReplyStateInflater
+            smartReplyStateInflater
         )
         verify(builder).createContentView(true)
     }
 
     @Test
     fun testInflationCallsUpdated() {
-        inflateAndWait(
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
-            mRow
-        )
-        verify(mRow).onNotificationUpdated()
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
+        verify(row).onNotificationUpdated()
     }
 
     @Test
     fun testInflationOnlyInflatesSetFlags() {
-        inflateAndWait(
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP,
-            mRow
-        )
-        Assert.assertNotNull(mRow.privateLayout.headsUpChild)
-        verify(mRow).onNotificationUpdated()
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_HEADS_UP, row)
+        Assert.assertNotNull(row.privateLayout.headsUpChild)
+        verify(row).onNotificationUpdated()
     }
 
     @Test
     fun testInflationThrowsErrorDoesntCallUpdated() {
-        mRow.privateLayout.removeAllViews()
-        mRow.entry.sbn.notification.contentView =
+        row.privateLayout.removeAllViews()
+        row.entry.sbn.notification.contentView =
             RemoteViews(mContext.packageName, R.layout.status_bar)
         inflateAndWait(
             true /* expectingException */,
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
-            mRow
+            notificationInflater,
+            FLAG_CONTENT_VIEW_ALL,
+            row
         )
-        Assert.assertTrue(mRow.privateLayout.childCount == 0)
-        verify(mRow, times(0)).onNotificationUpdated()
+        Assert.assertTrue(row.privateLayout.childCount == 0)
+        verify(row, times(0)).onNotificationUpdated()
     }
 
     @Test
     fun testAsyncTaskRemoved() {
-        mRow.entry.abortTask()
-        inflateAndWait(
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
-            mRow
-        )
-        verify(mRow).onNotificationUpdated()
+        row.entry.abortTask()
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
+        verify(row).onNotificationUpdated()
     }
 
     @Test
     fun testRemovedNotInflated() {
-        mRow.setRemoved()
-        mNotificationInflater.setInflateSynchronously(true)
-        mNotificationInflater.bindContent(
-            mRow.entry,
-            mRow,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
+        row.setRemoved()
+        notificationInflater.setInflateSynchronously(true)
+        notificationInflater.bindContent(
+            row.entry,
+            row,
+            FLAG_CONTENT_VIEW_ALL,
             BindParams(),
             false /* forceInflate */,
             null /* callback */
         )
-        Assert.assertNull(mRow.entry.runningTask)
+        Assert.assertNull(row.entry.runningTask)
     }
 
     @Test
@@ -235,11 +264,11 @@
             inflateSynchronously = false,
             isMinimized = false,
             result = result,
-            reInflateFlags = NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED,
+            reInflateFlags = FLAG_CONTENT_VIEW_EXPANDED,
             inflationId = 0,
             remoteViewCache = mock(),
-            entry = mRow.entry,
-            row = mRow,
+            entry = row.entry,
+            row = row,
             isNewView = true, /* isNewView */
             remoteViewClickHandler = { _, _, _ -> true },
             callback =
@@ -253,7 +282,7 @@
                         countDownLatch.countDown()
                     }
                 },
-            parentLayout = mRow.privateLayout,
+            parentLayout = row.privateLayout,
             existingView = null,
             existingWrapper = null,
             runningInflations = HashMap(),
@@ -275,13 +304,13 @@
 
     @Test
     fun doesntReapplyDisallowedRemoteView() {
-        mBuilder.setStyle(Notification.MediaStyle())
-        val mediaView = mBuilder.createContentView()
-        mBuilder.setStyle(Notification.DecoratedCustomViewStyle())
-        mBuilder.setCustomContentView(
+        builder.setStyle(Notification.MediaStyle())
+        val mediaView = builder.createContentView()
+        builder.setStyle(Notification.DecoratedCustomViewStyle())
+        builder.setCustomContentView(
             RemoteViews(context.packageName, com.android.systemui.tests.R.layout.custom_view_dark)
         )
-        val decoratedMediaView = mBuilder.createContentView()
+        val decoratedMediaView = builder.createContentView()
         Assert.assertFalse(
             "The decorated media style doesn't allow a view to be reapplied!",
             NotificationRowContentBinderImpl.canReapplyRemoteView(mediaView, decoratedMediaView)
@@ -292,112 +321,167 @@
     @Ignore("b/345418902")
     fun testUsesSameViewWhenCachedPossibleToReuse() {
         // GIVEN a cached view.
-        val contractedRemoteView = mBuilder.createContentView()
-        whenever(
-                mCache.hasCachedView(
-                    mRow.entry,
-                    NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
-                )
-            )
-            .thenReturn(true)
-        whenever(
-                mCache.getCachedView(
-                    mRow.entry,
-                    NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
-                )
-            )
+        val contractedRemoteView = builder.createContentView()
+        whenever(cache.hasCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED)).thenReturn(true)
+        whenever(cache.getCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED))
             .thenReturn(contractedRemoteView)
 
         // GIVEN existing bound view with same layout id.
         val view = contractedRemoteView.apply(mContext, null /* parent */)
-        mRow.privateLayout.setContractedChild(view)
+        row.privateLayout.setContractedChild(view)
 
         // WHEN inflater inflates
-        inflateAndWait(
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED,
-            mRow
-        )
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
 
         // THEN the view should be re-used
         Assert.assertEquals(
             "Binder inflated a new view even though the old one was cached and usable.",
             view,
-            mRow.privateLayout.contractedChild
+            row.privateLayout.contractedChild
         )
     }
 
     @Test
     fun testInflatesNewViewWhenCachedNotPossibleToReuse() {
         // GIVEN a cached remote view.
-        val contractedRemoteView = mBuilder.createHeadsUpContentView()
-        whenever(
-                mCache.hasCachedView(
-                    mRow.entry,
-                    NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
-                )
-            )
-            .thenReturn(true)
-        whenever(
-                mCache.getCachedView(
-                    mRow.entry,
-                    NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
-                )
-            )
+        val contractedRemoteView = builder.createHeadsUpContentView()
+        whenever(cache.hasCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED)).thenReturn(true)
+        whenever(cache.getCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED))
             .thenReturn(contractedRemoteView)
 
         // GIVEN existing bound view with different layout id.
         val view: View = TextView(mContext)
-        mRow.privateLayout.setContractedChild(view)
+        row.privateLayout.setContractedChild(view)
 
         // WHEN inflater inflates
-        inflateAndWait(
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED,
-            mRow
-        )
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
 
         // THEN the view should be a new view
         Assert.assertNotEquals(
             "Binder (somehow) used the same view when inflating.",
             view,
-            mRow.privateLayout.contractedChild
+            row.privateLayout.contractedChild
         )
     }
 
     @Test
     fun testInflationCachesCreatedRemoteView() {
         // WHEN inflater inflates
-        inflateAndWait(
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED,
-            mRow
-        )
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
 
         // THEN inflater informs cache of the new remote view
-        verify(mCache)
-            .putCachedView(
-                eq(mRow.entry),
-                eq(NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED),
-                any()
-            )
+        verify(cache).putCachedView(eq(row.entry), eq(FLAG_CONTENT_VIEW_CONTRACTED), any())
     }
 
     @Test
     fun testUnbindRemovesCachedRemoteView() {
         // WHEN inflated unbinds content
-        mNotificationInflater.unbindContent(
-            mRow.entry,
-            mRow,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP
-        )
+        notificationInflater.unbindContent(row.entry, row, FLAG_CONTENT_VIEW_HEADS_UP)
 
         // THEN inflated informs cache to remove remote view
-        verify(mCache)
-            .removeCachedView(
-                eq(mRow.entry),
-                eq(NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP)
-            )
+        verify(cache).removeCachedView(eq(row.entry), eq(FLAG_CONTENT_VIEW_HEADS_UP))
+    }
+
+    @Test
+    fun testRonModelRequiredForRonView() {
+        fakeRonContentModel = null
+        val ronView = View(context)
+        fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mock())
+        // WHEN inflater inflates
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+        verify(fakeRonViewInflater, never()).inflateView(any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun testRonModelTriggersInflationOfRonView() {
+        val mockRonModel = mock<TimerContentModel>()
+        val ronView = View(context)
+        val mockBinder = mock<DeferredContentViewBinder>()
+
+        val entry = row.entry
+        val privateLayout = row.privateLayout
+
+        fakeRonContentModel = mockRonModel
+        fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mockBinder)
+        // WHEN inflater inflates
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+        // VERIFY that the inflater is invoked
+        verify(fakeRonViewInflater)
+            .inflateView(eq(mockRonModel), any(), eq(entry), any(), eq(privateLayout))
+        assertThat(row.privateLayout.contractedChild).isSameInstanceAs(ronView)
+        verify(mockBinder).setupContentViewBinder()
+    }
+
+    @Test
+    fun ronViewAppliesElementsInOrder() {
+        val oldHandle = mock<DisposableHandle>()
+        val mockRonModel = mock<TimerContentModel>()
+        val ronView = View(context)
+        val mockBinder = mock<DeferredContentViewBinder>()
+
+        row.privateLayout.mContractedBinderHandle = oldHandle
+        val entry = spy(row.entry)
+        row.entry = entry
+        val privateLayout = spy(row.privateLayout)
+        row.privateLayout = privateLayout
+
+        fakeRonContentModel = mockRonModel
+        fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mockBinder)
+        // WHEN inflater inflates
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+
+        // Validate that these 4 steps happen in this precise order
+        inOrder(oldHandle, entry, privateLayout, mockBinder) {
+            verify(oldHandle).dispose()
+            verify(entry).setContentModel(argThat { richOngoingContentModel === mockRonModel })
+            verify(privateLayout).setContractedChild(eq(ronView))
+            verify(mockBinder).setupContentViewBinder()
+        }
+    }
+
+    @Test
+    fun testRonNotReinflating() {
+        val handle0 = mock<DisposableHandle>()
+        val handle1 = mock<DisposableHandle>()
+        val ronView = View(context)
+        val mockRonModel1 = mock<TimerContentModel>()
+        val mockRonModel2 = mock<TimerContentModel>()
+        val mockBinder1 = mock<DeferredContentViewBinder>()
+        doReturn(handle1).whenever(mockBinder1).setupContentViewBinder()
+
+        row.privateLayout.mContractedBinderHandle = handle0
+        val entry = spy(row.entry)
+        row.entry = entry
+        val privateLayout = spy(row.privateLayout)
+        row.privateLayout = privateLayout
+
+        // WHEN inflater inflates both a model and a view
+        fakeRonContentModel = mockRonModel1
+        fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mockBinder1)
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+
+        // Validate that these 4 steps happen in this precise order
+        inOrder(handle0, entry, privateLayout, mockBinder1, handle1) {
+            verify(handle0).dispose()
+            verify(entry).setContentModel(argThat { richOngoingContentModel === mockRonModel1 })
+            verify(privateLayout).setContractedChild(eq(ronView))
+            verify(mockBinder1).setupContentViewBinder()
+            verify(handle1, never()).dispose()
+        }
+
+        clearInvocations(handle0, entry, privateLayout, mockBinder1, handle1)
+
+        // THEN when the inflater inflates just a model
+        fakeRonContentModel = mockRonModel2
+        fakeRonViewHolder = null
+        inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+
+        // Validate that for reinflation, the only thing we do us update the model
+        verify(handle1, never()).dispose()
+        verify(entry).setContentModel(argThat { richOngoingContentModel === mockRonModel2 })
+        verify(privateLayout, never()).setContractedChild(any())
+        verify(mockBinder1, never()).setupContentViewBinder()
+        verify(handle1, never()).dispose()
     }
 
     @Test
@@ -453,46 +537,36 @@
         whenever(view.measuredHeight)
             .thenReturn(
                 TypedValue.applyDimension(
-                        TypedValue.COMPLEX_UNIT_SP,
+                        COMPLEX_UNIT_SP,
                         measuredHeightDp,
                         mContext.resources.displayMetrics
                     )
                     .toInt()
             )
-        mRow.entry.targetSdk = targetSdk
-        mRow.entry.sbn.notification.contentView = contentView
-        return NotificationRowContentBinderImpl.isValidView(view, mRow.entry, mContext.resources)
+        row.entry.targetSdk = targetSdk
+        row.entry.sbn.notification.contentView = contentView
+        return NotificationRowContentBinderImpl.isValidView(view, row.entry, mContext.resources)
     }
 
     @Test
     fun testInvalidNotificationDoesNotInvokeCallback() {
-        mRow.privateLayout.removeAllViews()
-        mRow.entry.sbn.notification.contentView =
+        row.privateLayout.removeAllViews()
+        row.entry.sbn.notification.contentView =
             RemoteViews(
                 mContext.packageName,
                 com.android.systemui.tests.R.layout.invalid_notification_height
             )
-        inflateAndWait(
-            true,
-            mNotificationInflater,
-            NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL,
-            mRow
-        )
-        Assert.assertEquals(0, mRow.privateLayout.childCount.toLong())
-        verify(mRow, times(0)).onNotificationUpdated()
+        inflateAndWait(true, notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
+        Assert.assertEquals(0, row.privateLayout.childCount.toLong())
+        verify(row, times(0)).onNotificationUpdated()
     }
 
     private class ExceptionHolder {
-        var mException: Exception? = null
-
-        fun setException(exception: Exception?) {
-            mException = exception
-        }
+        var exception: Exception? = null
     }
 
     private class AsyncFailRemoteView(packageName: String?, layoutId: Int) :
         RemoteViews(packageName, layoutId) {
-        var mHandler = mockExecutorHandler { p0 -> p0.run() }
 
         override fun apply(context: Context, parent: ViewGroup): View {
             return super.apply(context, parent)
@@ -505,7 +579,7 @@
             listener: OnViewAppliedListener,
             handler: InteractionHandler?
         ): CancellationSignal {
-            mHandler.post { listener.onError(RuntimeException("Failed to inflate async")) }
+            executor.execute { listener.onError(RuntimeException("Failed to inflate async")) }
             return CancellationSignal()
         }
 
@@ -541,18 +615,17 @@
                 object : InflationCallback {
                     override fun handleInflationException(entry: NotificationEntry, e: Exception) {
                         if (!expectingException) {
-                            exceptionHolder.setException(e)
+                            exceptionHolder.exception = e
                         }
                         countDownLatch.countDown()
                     }
 
                     override fun onAsyncInflationFinished(entry: NotificationEntry) {
                         if (expectingException) {
-                            exceptionHolder.setException(
+                            exceptionHolder.exception =
                                 RuntimeException(
                                     "Inflation finished even though there should be an error"
                                 )
-                            )
                         }
                         countDownLatch.countDown()
                     }
@@ -566,7 +639,7 @@
                 callback /* callback */
             )
             Assert.assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS))
-            exceptionHolder.mException?.let { throw it }
+            exceptionHolder.exception?.let { throw it }
         }
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
index 21d586b..c74a04f 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
@@ -199,6 +199,8 @@
                                 mock(NotifRemoteViewCache.class),
                                 mock(NotificationRemoteInputManager.class),
                                 mock(ConversationNotificationProcessor.class),
+                                mock(RichOngoingNotificationContentExtractor.class),
+                                mock(RichOngoingNotificationViewInflater.class),
                                 mock(Executor.class),
                                 new MockSmartReplyInflater(),
                                 mock(NotifLayoutInflaterFactory.Provider.class),
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationShelfTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationShelfTest.kt
index 9b0fd96..c1f2cb77 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationShelfTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationShelfTest.kt
@@ -367,14 +367,14 @@
     @EnableSceneContainer
     fun updateState_withViewInShelf_showShelf() {
         // GIVEN a view is scrolled into the shelf
-        val stackCutoff = 200f
-        val scrimPadding =
-            context.resources.getDimensionPixelSize(R.dimen.notification_side_paddings)
-        val shelfTop = stackCutoff - scrimPadding - shelf.height
+        val stackTop = 200f
+        val stackHeight = 800f
+        whenever(ambientState.stackTop).thenReturn(stackTop)
+        whenever(ambientState.stackHeight).thenReturn(stackHeight)
+        val shelfTop = stackTop + stackHeight - shelf.height
         val stackScrollAlgorithmState = StackScrollAlgorithmState()
         val viewInShelf = mock(ExpandableView::class.java)
 
-        whenever(ambientState.stackCutoff).thenReturn(stackCutoff)
         whenever(ambientState.isShadeExpanded).thenReturn(true)
         whenever(ambientState.lastVisibleBackgroundChild).thenReturn(viewInShelf)
         whenever(viewInShelf.viewState).thenReturn(ExpandableViewState())
@@ -401,57 +401,14 @@
 
     @Test
     @EnableSceneContainer
-    fun updateState_withViewInShelfDuringExpansion_showShelf() {
-        // GIVEN a view is scrolled into the shelf
-        val stackCutoff = 200f
-        val scrimPadding =
-            context.resources.getDimensionPixelSize(R.dimen.notification_side_paddings)
-        val stackBottom = stackCutoff - scrimPadding
-        val shelfTop = stackBottom - shelf.height
-        val stackScrollAlgorithmState = StackScrollAlgorithmState()
-        val viewInShelf = mock(ExpandableView::class.java)
-
-        // AND a shade expansion is in progress
-        val shadeExpansionFraction = 0.5f
-
-        whenever(ambientState.stackCutoff).thenReturn(stackCutoff)
-        whenever(ambientState.isShadeExpanded).thenReturn(true)
-        whenever(ambientState.lastVisibleBackgroundChild).thenReturn(viewInShelf)
-        whenever(ambientState.isExpansionChanging).thenReturn(true)
-        whenever(ambientState.expansionFraction).thenReturn(shadeExpansionFraction)
-        whenever(viewInShelf.viewState).thenReturn(ExpandableViewState())
-        whenever(viewInShelf.shelfIcon).thenReturn(mock(StatusBarIconView::class.java))
-        whenever(viewInShelf.translationY).thenReturn(shelfTop)
-        whenever(viewInShelf.actualHeight).thenReturn(10)
-        whenever(viewInShelf.isInShelf).thenReturn(true)
-        whenever(viewInShelf.minHeight).thenReturn(10)
-        whenever(viewInShelf.shelfTransformationTarget).thenReturn(null) // use translationY
-        whenever(viewInShelf.isInShelf).thenReturn(true)
-
-        stackScrollAlgorithmState.visibleChildren.add(viewInShelf)
-        stackScrollAlgorithmState.firstViewInShelf = viewInShelf
-
-        // WHEN Shelf's ViewState is updated
-        shelf.updateState(stackScrollAlgorithmState, ambientState)
-
-        // THEN the shelf is visible
-        val shelfState = shelf.viewState as NotificationShelf.ShelfState
-        assertEquals(false, shelfState.hidden)
-        assertEquals(shelf.height, shelfState.height)
-        // AND its translation is scaled by the shade expansion
-        assertEquals((stackBottom * 0.75f) - shelf.height, shelfState.yTranslation)
-    }
-
-    @Test
-    @EnableSceneContainer
     fun updateState_withNullLastVisibleBackgroundChild_hideShelf_withSceneContainer() {
         // GIVEN
-        val stackCutoff = 200f
-        val scrimPadding =
-            context.resources.getDimensionPixelSize(R.dimen.notification_side_paddings)
+        val stackTop = 200f
+        val stackHeight = 800f
+        whenever(ambientState.stackTop).thenReturn(stackTop)
+        whenever(ambientState.stackHeight).thenReturn(stackHeight)
         val paddingBetweenElements =
             context.resources.getDimensionPixelSize(R.dimen.notification_divider_height)
-        whenever(ambientState.stackCutoff).thenReturn(stackCutoff)
         whenever(ambientState.isShadeExpanded).thenReturn(true)
         val lastVisibleBackgroundChild = mock<ExpandableView>()
         val expandableViewState = ExpandableViewState()
@@ -467,7 +424,7 @@
         // THEN
         val shelfState = shelf.viewState as NotificationShelf.ShelfState
         assertEquals(true, shelfState.hidden)
-        assertEquals(stackCutoff - scrimPadding + paddingBetweenElements, shelfState.yTranslation)
+        assertEquals(stackTop + stackHeight + paddingBetweenElements, shelfState.yTranslation)
     }
 
     @Test
@@ -501,12 +458,12 @@
     @EnableSceneContainer
     fun updateState_withNullFirstViewInShelf_hideShelf_withSceneContainer() {
         // GIVEN
-        val stackCutoff = 200f
-        val scrimPadding =
-            context.resources.getDimensionPixelSize(R.dimen.notification_side_paddings)
+        val stackTop = 200f
+        val stackHeight = 800f
+        whenever(ambientState.stackTop).thenReturn(stackTop)
+        whenever(ambientState.stackHeight).thenReturn(stackHeight)
         val paddingBetweenElements =
             context.resources.getDimensionPixelSize(R.dimen.notification_divider_height)
-        whenever(ambientState.stackCutoff).thenReturn(stackCutoff)
         whenever(ambientState.isShadeExpanded).thenReturn(true)
         val lastVisibleBackgroundChild = mock<ExpandableView>()
         val expandableViewState = ExpandableViewState()
@@ -522,7 +479,7 @@
         // THEN
         val shelfState = shelf.viewState as NotificationShelf.ShelfState
         assertEquals(true, shelfState.hidden)
-        assertEquals(stackCutoff - scrimPadding + paddingBetweenElements, shelfState.yTranslation)
+        assertEquals(stackTop + stackHeight + paddingBetweenElements, shelfState.yTranslation)
     }
 
     @Test
@@ -556,12 +513,12 @@
     @EnableSceneContainer
     fun updateState_withCollapsedShade_hideShelf_withSceneContainer() {
         // GIVEN
-        val stackCutoff = 200f
-        val scrimPadding =
-            context.resources.getDimensionPixelSize(R.dimen.notification_side_paddings)
+        val stackTop = 200f
+        val stackHeight = 800f
+        whenever(ambientState.stackTop).thenReturn(stackTop)
+        whenever(ambientState.stackHeight).thenReturn(stackHeight)
         val paddingBetweenElements =
             context.resources.getDimensionPixelSize(R.dimen.notification_divider_height)
-        whenever(ambientState.stackCutoff).thenReturn(stackCutoff)
         val lastVisibleBackgroundChild = mock<ExpandableView>()
         val expandableViewState = ExpandableViewState()
         whenever(lastVisibleBackgroundChild.viewState).thenReturn(expandableViewState)
@@ -577,7 +534,7 @@
         // THEN
         val shelfState = shelf.viewState as NotificationShelf.ShelfState
         assertEquals(true, shelfState.hidden)
-        assertEquals(stackCutoff - scrimPadding + paddingBetweenElements, shelfState.yTranslation)
+        assertEquals(stackTop + stackHeight + paddingBetweenElements, shelfState.yTranslation)
     }
 
     @Test
@@ -609,12 +566,12 @@
 
     @Test
     @EnableSceneContainer
-    fun updateState_withHiddenSectionBeforeShelf_hideShelf_withSceneContianer() {
+    fun updateState_withHiddenSectionBeforeShelf_hideShelf_withSceneContainer() {
         // GIVEN
-        val stackCutoff = 200f
-        whenever(ambientState.stackCutoff).thenReturn(stackCutoff)
-        val scrimPadding =
-            context.resources.getDimensionPixelSize(R.dimen.notification_side_paddings)
+        val stackTop = 200f
+        val stackHeight = 800f
+        whenever(ambientState.stackTop).thenReturn(stackTop)
+        whenever(ambientState.stackHeight).thenReturn(stackHeight)
         val paddingBetweenElements =
             context.resources.getDimensionPixelSize(R.dimen.notification_divider_height)
         whenever(ambientState.isShadeExpanded).thenReturn(true)
@@ -646,7 +603,7 @@
         // THEN
         val shelfState = shelf.viewState as NotificationShelf.ShelfState
         assertEquals(true, shelfState.hidden)
-        assertEquals(stackCutoff - scrimPadding + paddingBetweenElements, shelfState.yTranslation)
+        assertEquals(stackTop + stackHeight + paddingBetweenElements, shelfState.yTranslation)
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutTest.java
index 967f95e..770c424 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutTest.java
@@ -262,15 +262,62 @@
     }
 
     @Test
-    public void updateStackEndHeightAndStackHeight_normallyUpdatesBoth() {
-        final float expansionFraction = 0.5f;
+    @EnableSceneContainer
+    public void updateStackEndHeightAndStackHeight_shadeFullyExpanded_withSceneContainer() {
+        final float stackTop = 200f;
+        final float stackCutoff = 1000f;
+        final float stackEndHeight = stackCutoff - stackTop;
+        mAmbientState.setStackTop(stackTop);
+        mAmbientState.setStackCutoff(stackCutoff);
         mAmbientState.setStatusBarState(StatusBarState.KEYGUARD);
-
-        // Validate that by default we update everything
         clearInvocations(mAmbientState);
+
+        // WHEN shade is fully expanded
+        mStackScroller.updateStackEndHeightAndStackHeight(/* fraction = */ 1.0f);
+
+        // THEN stackHeight and stackEndHeight are the same
+        verify(mAmbientState).setStackEndHeight(stackEndHeight);
+        verify(mAmbientState).setStackHeight(stackEndHeight);
+    }
+
+    @Test
+    @EnableSceneContainer
+    public void updateStackEndHeightAndStackHeight_shadeExpanding_withSceneContainer() {
+        final float stackTop = 200f;
+        final float stackCutoff = 1000f;
+        final float stackEndHeight = stackCutoff - stackTop;
+        mAmbientState.setStackTop(stackTop);
+        mAmbientState.setStackCutoff(stackCutoff);
+        mAmbientState.setStatusBarState(StatusBarState.KEYGUARD);
+        clearInvocations(mAmbientState);
+
+        // WHEN shade is expanding
+        final float expansionFraction = 0.5f;
         mStackScroller.updateStackEndHeightAndStackHeight(expansionFraction);
-        verify(mAmbientState).setStackEndHeight(anyFloat());
-        verify(mAmbientState).setStackHeight(anyFloat());
+
+        // THEN stackHeight is changed by the expansion frac
+        verify(mAmbientState).setStackEndHeight(stackEndHeight);
+        verify(mAmbientState).setStackHeight(stackEndHeight * 0.75f);
+    }
+
+    @Test
+    @EnableSceneContainer
+    public void updateStackEndHeightAndStackHeight_shadeOverscrolledToTop_withSceneContainer() {
+        // GIVEN stack scrolled over the top, stack top is negative
+        final float stackTop = -2000f;
+        final float stackCutoff = 1000f;
+        final float stackEndHeight = stackCutoff - stackTop;
+        mAmbientState.setStackTop(stackTop);
+        mAmbientState.setStackCutoff(stackCutoff);
+        mAmbientState.setStatusBarState(StatusBarState.KEYGUARD);
+        clearInvocations(mAmbientState);
+
+        // WHEN stack is updated
+        mStackScroller.updateStackEndHeightAndStackHeight(/* fraction = */ 1.0f);
+
+        // THEN stackHeight is measured from the stack top
+        verify(mAmbientState).setStackEndHeight(stackEndHeight);
+        verify(mAmbientState).setStackHeight(stackEndHeight);
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithmTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithmTest.kt
index 8401a19..b12c098 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithmTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/StackScrollAlgorithmTest.kt
@@ -8,6 +8,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.android.keyguard.BouncerPanelExpansionCalculator.aboutToShowBouncerProgress
+import com.android.systemui.Flags
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.animation.ShadeInterpolation.getContentAlpha
 import com.android.systemui.dump.DumpManager
@@ -549,6 +550,7 @@
         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
     }
 
+    @DisableFlags(Flags.FLAG_NOTIFICATIONS_FOOTER_VIEW_REFACTOR)
     @Test
     fun resetViewStates_clearAllInProgress_allRowsRemoved_emptyShade_footerHidden() {
         ambientState.isClearAllInProgress = true
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryImplTest.kt
deleted file mode 100644
index 004f679..0000000
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryImplTest.kt
+++ /dev/null
@@ -1,91 +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.
- */
-
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
-package com.android.systemui.statusbar.policy.data.repository
-
-import android.app.NotificationManager
-import android.provider.Settings
-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.statusbar.policy.ZenModeController
-import com.android.systemui.util.mockito.whenever
-import com.android.systemui.util.mockito.withArgCaptor
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runCurrent
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.Mock
-import org.mockito.Mockito
-import org.mockito.MockitoAnnotations
-
-@SmallTest
-@RunWith(AndroidJUnit4::class)
-class ZenModeRepositoryImplTest : SysuiTestCase() {
-    @Mock lateinit var zenModeController: ZenModeController
-
-    lateinit var underTest: ZenModeRepositoryImpl
-
-    private val testPolicy = NotificationManager.Policy(0, 1, 0)
-
-    @Before
-    fun setUp() {
-        MockitoAnnotations.initMocks(this)
-        underTest = ZenModeRepositoryImpl(zenModeController)
-    }
-
-    @Test
-    fun zenMode_reflectsCurrentControllerState() = runTest {
-        whenever(zenModeController.zen).thenReturn(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
-        val zenMode by collectLastValue(underTest.zenMode)
-        assertThat(zenMode).isEqualTo(Settings.Global.ZEN_MODE_NO_INTERRUPTIONS)
-    }
-
-    @Test
-    fun zenMode_updatesWhenControllerStateChanges() = runTest {
-        val zenMode by collectLastValue(underTest.zenMode)
-        runCurrent()
-        whenever(zenModeController.zen).thenReturn(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-        withArgCaptor { Mockito.verify(zenModeController).addCallback(capture()) }
-            .onZenChanged(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-        assertThat(zenMode).isEqualTo(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS)
-    }
-
-    @Test
-    fun policy_reflectsCurrentControllerState() {
-        runTest {
-            whenever(zenModeController.consolidatedPolicy).thenReturn(testPolicy)
-            val policy by collectLastValue(underTest.consolidatedNotificationPolicy)
-            assertThat(policy).isEqualTo(testPolicy)
-        }
-    }
-
-    @Test
-    fun policy_updatesWhenControllerStateChanges() = runTest {
-        val policy by collectLastValue(underTest.consolidatedNotificationPolicy)
-        runCurrent()
-        whenever(zenModeController.consolidatedPolicy).thenReturn(testPolicy)
-        withArgCaptor { Mockito.verify(zenModeController).addCallback(capture()) }
-            .onConsolidatedPolicyChanged(testPolicy)
-        assertThat(policy).isEqualTo(testPolicy)
-    }
-}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
deleted file mode 100644
index 8981009..0000000
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
+++ /dev/null
@@ -1,164 +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.policy.domain.interactor
-
-import android.app.NotificationManager.Policy
-import android.provider.Settings
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SmallTest
-import com.android.systemui.SysUITestComponent
-import com.android.systemui.SysUITestModule
-import com.android.systemui.SysuiTestCase
-import com.android.systemui.collectLastValue
-import com.android.systemui.dagger.SysUISingleton
-import com.android.systemui.runCurrent
-import com.android.systemui.runTest
-import com.android.systemui.statusbar.policy.data.repository.FakeZenModeRepository
-import com.android.systemui.user.domain.UserDomainLayerModule
-import com.google.common.truth.Truth.assertThat
-import dagger.BindsInstance
-import dagger.Component
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@SmallTest
-@RunWith(AndroidJUnit4::class)
-class ZenModeInteractorTest : SysuiTestCase() {
-    @SysUISingleton
-    @Component(
-        modules =
-            [
-                SysUITestModule::class,
-                UserDomainLayerModule::class,
-            ]
-    )
-    interface TestComponent : SysUITestComponent<ZenModeInteractor> {
-
-        val repository: FakeZenModeRepository
-
-        @Component.Factory
-        interface Factory {
-            fun create(@BindsInstance test: SysuiTestCase): TestComponent
-        }
-    }
-
-    private val testComponent: TestComponent =
-        DaggerZenModeInteractorTest_TestComponent.factory().create(test = this)
-
-    @Test
-    fun testIsZenModeEnabled_off() =
-        testComponent.runTest {
-            val enabled by collectLastValue(underTest.isZenModeEnabled)
-
-            repository.zenMode.value = Settings.Global.ZEN_MODE_OFF
-            runCurrent()
-
-            assertThat(enabled).isFalse()
-        }
-
-    @Test
-    fun testIsZenModeEnabled_alarms() =
-        testComponent.runTest {
-            val enabled by collectLastValue(underTest.isZenModeEnabled)
-
-            repository.zenMode.value = Settings.Global.ZEN_MODE_ALARMS
-            runCurrent()
-
-            assertThat(enabled).isTrue()
-        }
-
-    @Test
-    fun testIsZenModeEnabled_importantInterruptions() =
-        testComponent.runTest {
-            val enabled by collectLastValue(underTest.isZenModeEnabled)
-
-            repository.zenMode.value = Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS
-            runCurrent()
-
-            assertThat(enabled).isTrue()
-        }
-
-    @Test
-    fun testIsZenModeEnabled_noInterruptions() =
-        testComponent.runTest {
-            val enabled by collectLastValue(underTest.isZenModeEnabled)
-
-            repository.zenMode.value = Settings.Global.ZEN_MODE_NO_INTERRUPTIONS
-            runCurrent()
-
-            assertThat(enabled).isTrue()
-        }
-
-    @Test
-    fun testIsZenModeEnabled_unknown() =
-        testComponent.runTest {
-            val enabled by collectLastValue(underTest.isZenModeEnabled)
-
-            repository.zenMode.value = 4 // this should fail if we ever add another zen mode type
-            runCurrent()
-
-            assertThat(enabled).isFalse()
-        }
-
-    @Test
-    fun testAreNotificationsHiddenInShade_noPolicy() =
-        testComponent.runTest {
-            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
-
-            repository.consolidatedNotificationPolicy.value = null
-            repository.zenMode.value = Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS
-            runCurrent()
-
-            assertThat(hidden).isFalse()
-        }
-
-    @Test
-    fun testAreNotificationsHiddenInShade_zenOffShadeSuppressed() =
-        testComponent.runTest {
-            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
-
-            repository.setSuppressedVisualEffects(Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST)
-            repository.zenMode.value = Settings.Global.ZEN_MODE_OFF
-            runCurrent()
-
-            assertThat(hidden).isFalse()
-        }
-
-    @Test
-    fun testAreNotificationsHiddenInShade_zenOnShadeNotSuppressed() =
-        testComponent.runTest {
-            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
-
-            repository.setSuppressedVisualEffects(Policy.SUPPRESSED_EFFECT_STATUS_BAR)
-            repository.zenMode.value = Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS
-            runCurrent()
-
-            assertThat(hidden).isFalse()
-        }
-
-    @Test
-    fun testAreNotificationsHiddenInShade_zenOnShadeSuppressed() =
-        testComponent.runTest {
-            val hidden by collectLastValue(underTest.areNotificationsHiddenInShade)
-
-            repository.setSuppressedVisualEffects(Policy.SUPPRESSED_EFFECT_NOTIFICATION_LIST)
-            repository.zenMode.value = Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS
-            runCurrent()
-
-            assertThat(hidden).isTrue()
-        }
-}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeTrustRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeTrustRepository.kt
index cd83c2f..39a1a63 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeTrustRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeTrustRepository.kt
@@ -33,9 +33,10 @@
     private val _isTrustUsuallyManaged = MutableStateFlow(false)
     override val isCurrentUserTrustUsuallyManaged: StateFlow<Boolean>
         get() = _isTrustUsuallyManaged
+
     private val _isCurrentUserTrusted = MutableStateFlow(false)
-    override val isCurrentUserTrusted: Flow<Boolean>
-        get() = _isCurrentUserTrusted
+    override val isCurrentUserTrusted: StateFlow<Boolean>
+        get() = _isCurrentUserTrusted.asStateFlow()
 
     private val _isCurrentUserActiveUnlockAvailable = MutableStateFlow(false)
     override val isCurrentUserActiveUnlockRunning: StateFlow<Boolean> =
@@ -48,6 +49,13 @@
     private val _requestDismissKeyguard = MutableStateFlow(TrustModel(false, 0, TrustGrantFlags(0)))
     override val trustAgentRequestingToDismissKeyguard: Flow<TrustModel> = _requestDismissKeyguard
 
+    var keyguardShowingChangeEventCount: Int = 0
+        private set
+
+    override suspend fun reportKeyguardShowingChanged() {
+        keyguardShowingChangeEventCount++
+    }
+
     fun setCurrentUserTrusted(trust: Boolean) {
         _isCurrentUserTrusted.value = trust
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/domain/interactor/TrustInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/domain/interactor/TrustInteractorKosmos.kt
index 0ebf164..d60326c 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/domain/interactor/TrustInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/domain/interactor/TrustInteractorKosmos.kt
@@ -19,5 +19,11 @@
 import com.android.systemui.keyguard.data.repository.trustRepository
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.applicationCoroutineScope
 
-val Kosmos.trustInteractor by Fixture { TrustInteractor(repository = trustRepository) }
+val Kosmos.trustInteractor by Fixture {
+    TrustInteractor(
+        applicationScope = applicationCoroutineScope,
+        repository = trustRepository,
+    )
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/FixedColumnsRepositoryKosmos.kt
similarity index 88%
rename from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/FixedColumnsRepositoryKosmos.kt
index d8af3fa..2f5daaa 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/FixedColumnsRepositoryKosmos.kt
@@ -18,4 +18,4 @@
 
 import com.android.systemui.kosmos.Kosmos
 
-val Kosmos.infiniteGridSizeRepository by Kosmos.Fixture { InfiniteGridSizeRepository() }
+val Kosmos.fixedColumnsRepository by Kosmos.Fixture { FixedColumnsRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepositoryKosmos.kt
similarity index 69%
copy from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
copy to packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepositoryKosmos.kt
index d8af3fa..696c4bf 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/InfiniteGridSizeRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/data/repository/PaginatedGridRepositoryKosmos.kt
@@ -16,6 +16,12 @@
 
 package com.android.systemui.qs.panels.data.repository
 
+import com.android.systemui.common.ui.data.repository.configurationRepository
 import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testCase
 
-val Kosmos.infiniteGridSizeRepository by Kosmos.Fixture { InfiniteGridSizeRepository() }
+val Kosmos.paginatedGridRepository by
+    Kosmos.Fixture {
+        testCase.context.orCreateTestableResources
+        PaginatedGridRepository(testCase.context.resources, configurationRepository)
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/FixedColumnsSizeInteractorKosmos.kt
similarity index 78%
rename from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/FixedColumnsSizeInteractorKosmos.kt
index 6e11977..f4d281d 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/FixedColumnsSizeInteractorKosmos.kt
@@ -17,7 +17,7 @@
 package com.android.systemui.qs.panels.domain.interactor
 
 import com.android.systemui.kosmos.Kosmos
-import com.android.systemui.qs.panels.data.repository.infiniteGridSizeRepository
+import com.android.systemui.qs.panels.data.repository.fixedColumnsRepository
 
-val Kosmos.infiniteGridSizeInteractor by
-    Kosmos.Fixture { InfiniteGridSizeInteractor(infiniteGridSizeRepository) }
+val Kosmos.fixedColumnsSizeInteractor by
+    Kosmos.Fixture { FixedColumnsSizeInteractor(fixedColumnsRepository) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractorKosmos.kt
index 7f387d7..320c2ec 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridConsistencyInteractorKosmos.kt
@@ -20,5 +20,5 @@
 
 val Kosmos.infiniteGridConsistencyInteractor by
     Kosmos.Fixture {
-        InfiniteGridConsistencyInteractor(iconTilesInteractor, infiniteGridSizeInteractor)
+        InfiniteGridConsistencyInteractor(iconTilesInteractor, fixedColumnsSizeInteractor)
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt
index 82cfaf5..be00152 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt
@@ -18,8 +18,8 @@
 
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.qs.panels.ui.compose.InfiniteGridLayout
+import com.android.systemui.qs.panels.ui.viewmodel.fixedColumnsSizeViewModel
 import com.android.systemui.qs.panels.ui.viewmodel.iconTilesViewModel
-import com.android.systemui.qs.panels.ui.viewmodel.infiniteGridSizeViewModel
 
 val Kosmos.infiniteGridLayout by
-    Kosmos.Fixture { InfiniteGridLayout(iconTilesViewModel, infiniteGridSizeViewModel) }
+    Kosmos.Fixture { InfiniteGridLayout(iconTilesViewModel, fixedColumnsSizeViewModel) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/PaginatedGridInteractorKosmos.kt
similarity index 78%
copy from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt
copy to packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/PaginatedGridInteractorKosmos.kt
index 6e11977..a922e5d 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/PaginatedGridInteractorKosmos.kt
@@ -17,7 +17,7 @@
 package com.android.systemui.qs.panels.domain.interactor
 
 import com.android.systemui.kosmos.Kosmos
-import com.android.systemui.qs.panels.data.repository.infiniteGridSizeRepository
+import com.android.systemui.qs.panels.data.repository.paginatedGridRepository
 
-val Kosmos.infiniteGridSizeInteractor by
-    Kosmos.Fixture { InfiniteGridSizeInteractor(infiniteGridSizeRepository) }
+val Kosmos.paginatedGridInteractor by
+    Kosmos.Fixture { PaginatedGridInteractor(paginatedGridRepository) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/FixedColumnsSizeViewModelKosmos.kt
similarity index 79%
rename from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModelKosmos.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/FixedColumnsSizeViewModelKosmos.kt
index f6dfb8b..feadc91 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/FixedColumnsSizeViewModelKosmos.kt
@@ -17,7 +17,7 @@
 package com.android.systemui.qs.panels.ui.viewmodel
 
 import com.android.systemui.kosmos.Kosmos
-import com.android.systemui.qs.panels.domain.interactor.infiniteGridSizeInteractor
+import com.android.systemui.qs.panels.domain.interactor.fixedColumnsSizeInteractor
 
-val Kosmos.infiniteGridSizeViewModel by
-    Kosmos.Fixture { InfiniteGridSizeViewModelImpl(infiniteGridSizeInteractor) }
+val Kosmos.fixedColumnsSizeViewModel by
+    Kosmos.Fixture { FixedColumnsSizeViewModelImpl(fixedColumnsSizeInteractor) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/MockTileViewModel.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/MockTileViewModel.kt
new file mode 100644
index 0000000..5386ece
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/MockTileViewModel.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.qs.panels.ui.viewmodel
+
+import com.android.systemui.plugins.qs.QSTile
+import com.android.systemui.qs.pipeline.shared.TileSpec
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+fun MockTileViewModel(
+    spec: TileSpec,
+    state: StateFlow<QSTile.State> = MutableStateFlow(QSTile.State())
+): TileViewModel = mock {
+    whenever(this.spec).thenReturn(spec)
+    whenever(this.state).thenReturn(state)
+    whenever(this.currentState).thenReturn(state.value)
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PaginatedGridViewModelKosmos.kt
similarity index 62%
copy from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModelKosmos.kt
copy to packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PaginatedGridViewModelKosmos.kt
index f6dfb8b..85e9265 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/InfiniteGridSizeViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PaginatedGridViewModelKosmos.kt
@@ -17,7 +17,16 @@
 package com.android.systemui.qs.panels.ui.viewmodel
 
 import com.android.systemui.kosmos.Kosmos
-import com.android.systemui.qs.panels.domain.interactor.infiniteGridSizeInteractor
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.qs.panels.domain.interactor.paginatedGridInteractor
 
-val Kosmos.infiniteGridSizeViewModel by
-    Kosmos.Fixture { InfiniteGridSizeViewModelImpl(infiniteGridSizeInteractor) }
+val Kosmos.paginatedGridViewModel by
+    Kosmos.Fixture {
+        PaginatedGridViewModel(
+            iconTilesViewModel,
+            fixedColumnsSizeViewModel,
+            iconLabelVisibilityViewModel,
+            paginatedGridInteractor,
+            applicationCoroutineScope,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModelKosmos.kt
index b07cc7d..fde174d 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModelKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/PartitionedGridViewModelKosmos.kt
@@ -22,7 +22,7 @@
     Kosmos.Fixture {
         PartitionedGridViewModel(
             iconTilesViewModel,
-            infiniteGridSizeViewModel,
+            fixedColumnsSizeViewModel,
             iconLabelVisibilityViewModel,
         )
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartableKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartableKosmos.kt
new file mode 100644
index 0000000..f9111bf
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/startable/KeyguardStateCallbackStartableKosmos.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.scene.domain.startable
+
+import com.android.systemui.bouncer.domain.interactor.simBouncerInteractor
+import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor
+import com.android.systemui.keyguard.domain.interactor.trustInteractor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.scene.domain.interactor.sceneInteractor
+import com.android.systemui.user.domain.interactor.selectedUserInteractor
+
+val Kosmos.keyguardStateCallbackStartable by Fixture {
+    KeyguardStateCallbackStartable(
+        applicationScope = applicationCoroutineScope,
+        backgroundDispatcher = testDispatcher,
+        sceneInteractor = sceneInteractor,
+        selectedUserInteractor = selectedUserInteractor,
+        deviceEntryInteractor = deviceEntryInteractor,
+        simBouncerInteractor = simBouncerInteractor,
+        trustInteractor = trustInteractor,
+    )
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/interactor/SceneContainerStartableKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/startable/SceneContainerStartableKosmos.kt
similarity index 92%
rename from packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/interactor/SceneContainerStartableKosmos.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/startable/SceneContainerStartableKosmos.kt
index cf18c0e..8b887d3 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/interactor/SceneContainerStartableKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/domain/startable/SceneContainerStartableKosmos.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.android.systemui.scene.domain.interactor
+package com.android.systemui.scene.domain.startable
 
 import com.android.internal.logging.uiEventLogger
 import com.android.systemui.authentication.domain.interactor.authenticationInteractor
@@ -32,7 +32,9 @@
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.model.sysUiState
 import com.android.systemui.power.domain.interactor.powerInteractor
-import com.android.systemui.scene.domain.startable.SceneContainerStartable
+import com.android.systemui.scene.domain.interactor.sceneBackInteractor
+import com.android.systemui.scene.domain.interactor.sceneContainerOcclusionInteractor
+import com.android.systemui.scene.domain.interactor.sceneInteractor
 import com.android.systemui.scene.session.shared.shadeSessionStorage
 import com.android.systemui.scene.shared.logger.sceneLogger
 import com.android.systemui.settings.displayTracker
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/data/NotificationsDataKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/data/NotificationsDataKosmos.kt
deleted file mode 100644
index a61f7ec..0000000
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/data/NotificationsDataKosmos.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- *  Licensed under the Apache License, Version 2.0 (the "License");
- *  you may not use this file except in compliance with the License.
- *  You may obtain a copy of the License at
- *
- *       http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
- */
-
-package com.android.systemui.statusbar.notification.data
-
-import com.android.settingslib.statusbar.notification.data.repository.FakeNotificationsSoundPolicyRepository
-import com.android.systemui.kosmos.Kosmos
-
-val Kosmos.notificationsSoundPolicyRepository by
-    Kosmos.Fixture { FakeNotificationsSoundPolicyRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorKosmos.kt
index 0614309..e6ca458 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/domain/interactor/NotificationsSoundPolicyInteractorKosmos.kt
@@ -16,12 +16,9 @@
 
 package com.android.systemui.statusbar.notification.domain.interactor
 
-import com.android.settingslib.statusbar.notification.data.repository.FakeNotificationsSoundPolicyRepository
 import com.android.settingslib.statusbar.notification.domain.interactor.NotificationsSoundPolicyInteractor
 import com.android.systemui.kosmos.Kosmos
-
-var Kosmos.notificationsSoundPolicyRepository by
-    Kosmos.Fixture { FakeNotificationsSoundPolicyRepository() }
+import com.android.systemui.statusbar.policy.data.repository.zenModeRepository
 
 val Kosmos.notificationsSoundPolicyInteractor: NotificationsSoundPolicyInteractor by
-    Kosmos.Fixture { NotificationsSoundPolicyInteractor(notificationsSoundPolicyRepository) }
+    Kosmos.Fixture { NotificationsSoundPolicyInteractor(zenModeRepository) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt
new file mode 100644
index 0000000..16dc50f
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt
@@ -0,0 +1,390 @@
+/*
+ * 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.row
+
+import android.app.ActivityManager
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.LauncherApps
+import android.os.UserHandle
+import android.provider.DeviceConfig
+import androidx.core.os.bundleOf
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags
+import com.android.internal.logging.MetricsLogger
+import com.android.internal.statusbar.IStatusBarService
+import com.android.systemui.TestableDependency
+import com.android.systemui.classifier.FalsingManagerFake
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.flags.FakeFeatureFlagsClassic
+import com.android.systemui.flags.FeatureFlags
+import com.android.systemui.flags.Flags
+import com.android.systemui.media.dialog.MediaOutputDialogManager
+import com.android.systemui.plugins.statusbar.StatusBarStateController
+import com.android.systemui.shared.system.ActivityManagerWrapper
+import com.android.systemui.shared.system.DevicePolicyManagerWrapper
+import com.android.systemui.shared.system.PackageManagerWrapper
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.NotificationRemoteInputManager
+import com.android.systemui.statusbar.NotificationShadeWindowController
+import com.android.systemui.statusbar.RankingBuilder
+import com.android.systemui.statusbar.SmartReplyController
+import com.android.systemui.statusbar.notification.ColorUpdateLogger
+import com.android.systemui.statusbar.notification.ConversationNotificationProcessor
+import com.android.systemui.statusbar.notification.collection.NotificationEntry
+import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder
+import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
+import com.android.systemui.statusbar.notification.collection.provider.NotificationDismissibilityProvider
+import com.android.systemui.statusbar.notification.collection.render.GroupExpansionManager
+import com.android.systemui.statusbar.notification.collection.render.GroupExpansionManagerImpl
+import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager
+import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManagerImpl
+import com.android.systemui.statusbar.notification.icon.IconBuilder
+import com.android.systemui.statusbar.notification.icon.IconManager
+import com.android.systemui.statusbar.notification.people.PeopleNotificationIdentifier
+import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow.CoordinateOnClickListener
+import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow.ExpandableNotificationRowLogger
+import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow.OnExpandClickListener
+import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL
+import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag
+import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor
+import com.android.systemui.statusbar.notification.stack.NotificationChildrenContainerLogger
+import com.android.systemui.statusbar.phone.KeyguardBypassController
+import com.android.systemui.statusbar.policy.HeadsUpManager
+import com.android.systemui.statusbar.policy.SmartActionInflaterImpl
+import com.android.systemui.statusbar.policy.SmartReplyConstants
+import com.android.systemui.statusbar.policy.SmartReplyInflaterImpl
+import com.android.systemui.statusbar.policy.SmartReplyStateInflaterImpl
+import com.android.systemui.statusbar.policy.dagger.RemoteInputViewSubcomponent
+import com.android.systemui.util.Assert.runWithCurrentThreadAsMainThread
+import com.android.systemui.util.DeviceConfigProxyFake
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.util.time.FakeSystemClock
+import com.android.systemui.wmshell.BubblesManager
+import com.google.common.util.concurrent.MoreExecutors
+import com.google.common.util.concurrent.SettableFuture
+import java.util.Optional
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.test.TestScope
+import org.junit.Assert.assertTrue
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito
+
+class ExpandableNotificationRowBuilder(
+    private val context: Context,
+    dependency: TestableDependency,
+    private val featureFlags: FakeFeatureFlagsClassic = FakeFeatureFlagsClassic()
+) {
+
+    private val mMockLogger: ExpandableNotificationRowLogger
+    private val mStatusBarStateController: StatusBarStateController
+    private val mKeyguardBypassController: KeyguardBypassController
+    private val mGroupMembershipManager: GroupMembershipManager
+    private val mGroupExpansionManager: GroupExpansionManager
+    private val mHeadsUpManager: HeadsUpManager
+    private val mIconManager: IconManager
+    private val mContentBinder: NotificationRowContentBinder
+    private val mBindStage: RowContentBindStage
+    private val mBindPipeline: NotifBindPipeline
+    private val mBindPipelineEntryListener: NotifCollectionListener
+    private val mPeopleNotificationIdentifier: PeopleNotificationIdentifier
+    private val mOnUserInteractionCallback: OnUserInteractionCallback
+    private val mDismissibilityProvider: NotificationDismissibilityProvider
+    private val mSmartReplyController: SmartReplyController
+    private val mSmartReplyConstants: SmartReplyConstants
+    private val mTestScope: TestScope = TestScope()
+    private val mBgCoroutineContext = mTestScope.backgroundScope.coroutineContext
+    private val mMainCoroutineContext = mTestScope.coroutineContext
+    private val mFakeSystemClock = FakeSystemClock()
+    private val mMainExecutor = FakeExecutor(mFakeSystemClock)
+
+    init {
+        featureFlags.setDefault(Flags.ENABLE_NOTIFICATIONS_SIMULATE_SLOW_MEASURE)
+        featureFlags.setDefault(Flags.BIGPICTURE_NOTIFICATION_LAZY_LOADING)
+
+        dependency.injectTestDependency(FeatureFlags::class.java, featureFlags)
+        dependency.injectMockDependency(NotificationMediaManager::class.java)
+        dependency.injectMockDependency(NotificationShadeWindowController::class.java)
+        dependency.injectMockDependency(MediaOutputDialogManager::class.java)
+
+        mMockLogger = Mockito.mock(ExpandableNotificationRowLogger::class.java)
+        mStatusBarStateController = Mockito.mock(StatusBarStateController::class.java)
+        mKeyguardBypassController = Mockito.mock(KeyguardBypassController::class.java)
+        mGroupMembershipManager = GroupMembershipManagerImpl()
+        mSmartReplyController = Mockito.mock(SmartReplyController::class.java)
+
+        val dumpManager = DumpManager()
+        mGroupExpansionManager = GroupExpansionManagerImpl(dumpManager, mGroupMembershipManager)
+        mHeadsUpManager = Mockito.mock(HeadsUpManager::class.java)
+        mIconManager =
+            IconManager(
+                Mockito.mock(CommonNotifCollection::class.java),
+                Mockito.mock(LauncherApps::class.java),
+                IconBuilder(context),
+                mTestScope,
+                mBgCoroutineContext,
+                mMainCoroutineContext,
+            )
+
+        mSmartReplyConstants =
+            SmartReplyConstants(
+                /* mainExecutor = */ mMainExecutor,
+                /* context = */ context,
+                /* deviceConfig = */ DeviceConfigProxyFake().apply {
+                    setProperty(
+                        DeviceConfig.NAMESPACE_SYSTEMUI,
+                        SystemUiDeviceConfigFlags.SSIN_SHOW_IN_HEADS_UP,
+                        "true",
+                        true
+                    )
+                    setProperty(
+                        DeviceConfig.NAMESPACE_SYSTEMUI,
+                        SystemUiDeviceConfigFlags.SSIN_ENABLED,
+                        "true",
+                        true
+                    )
+                    setProperty(
+                        DeviceConfig.NAMESPACE_SYSTEMUI,
+                        SystemUiDeviceConfigFlags.SSIN_REQUIRES_TARGETING_P,
+                        "false",
+                        true
+                    )
+                }
+            )
+        val remoteViewsFactories = getNotifRemoteViewsFactoryContainer(featureFlags)
+        val remoteInputManager = Mockito.mock(NotificationRemoteInputManager::class.java)
+        val smartReplyStateInflater =
+            SmartReplyStateInflaterImpl(
+                constants = mSmartReplyConstants,
+                activityManagerWrapper = ActivityManagerWrapper.getInstance(),
+                packageManagerWrapper = PackageManagerWrapper.getInstance(),
+                devicePolicyManagerWrapper = DevicePolicyManagerWrapper.getInstance(),
+                smartRepliesInflater =
+                    SmartReplyInflaterImpl(
+                        constants = mSmartReplyConstants,
+                        keyguardDismissUtil = mock(),
+                        remoteInputManager = remoteInputManager,
+                        smartReplyController = mSmartReplyController,
+                        context = context
+                    ),
+                smartActionsInflater =
+                    SmartActionInflaterImpl(
+                        constants = mSmartReplyConstants,
+                        activityStarter = mock(),
+                        smartReplyController = mSmartReplyController,
+                        headsUpManager = mHeadsUpManager
+                    )
+            )
+        val notifLayoutInflaterFactoryProvider =
+            object : NotifLayoutInflaterFactory.Provider {
+                override fun provide(
+                    row: ExpandableNotificationRow,
+                    layoutType: Int
+                ): NotifLayoutInflaterFactory =
+                    NotifLayoutInflaterFactory(row, layoutType, remoteViewsFactories)
+            }
+        val conversationProcessor =
+            ConversationNotificationProcessor(
+                mock(),
+                mock(),
+            )
+        mContentBinder =
+            if (NotificationRowContentBinderRefactor.isEnabled)
+                NotificationRowContentBinderImpl(
+                    mock(),
+                    remoteInputManager,
+                    conversationProcessor,
+                    mock(),
+                    mock(),
+                    mock(),
+                    smartReplyStateInflater,
+                    notifLayoutInflaterFactoryProvider,
+                    mock(),
+                    mock(),
+                )
+            else
+                NotificationContentInflater(
+                    mock(),
+                    remoteInputManager,
+                    conversationProcessor,
+                    mock(),
+                    mock(),
+                    smartReplyStateInflater,
+                    notifLayoutInflaterFactoryProvider,
+                    mock(),
+                    mock(),
+                )
+        mContentBinder.setInflateSynchronously(true)
+        mBindStage =
+            RowContentBindStage(
+                mContentBinder,
+                mock(),
+                mock(),
+            )
+
+        val collection = Mockito.mock(CommonNotifCollection::class.java)
+
+        mBindPipeline =
+            NotifBindPipeline(
+                collection,
+                Mockito.mock(NotifBindPipelineLogger::class.java),
+                NotificationEntryProcessorFactoryExecutorImpl(mMainExecutor),
+            )
+        mBindPipeline.setStage(mBindStage)
+
+        val collectionListenerCaptor = ArgumentCaptor.forClass(NotifCollectionListener::class.java)
+        Mockito.verify(collection).addCollectionListener(collectionListenerCaptor.capture())
+        mBindPipelineEntryListener = collectionListenerCaptor.value
+        mPeopleNotificationIdentifier = Mockito.mock(PeopleNotificationIdentifier::class.java)
+        mOnUserInteractionCallback = Mockito.mock(OnUserInteractionCallback::class.java)
+        mDismissibilityProvider = Mockito.mock(NotificationDismissibilityProvider::class.java)
+        val mFutureDismissalRunnable = Mockito.mock(Runnable::class.java)
+        whenever(
+                mOnUserInteractionCallback.registerFutureDismissal(
+                    ArgumentMatchers.any(),
+                    ArgumentMatchers.anyInt()
+                )
+            )
+            .thenReturn(mFutureDismissalRunnable)
+    }
+
+    private fun getNotifRemoteViewsFactoryContainer(
+        featureFlags: FeatureFlags,
+    ): NotifRemoteViewsFactoryContainer {
+        return NotifRemoteViewsFactoryContainerImpl(
+            featureFlags,
+            PrecomputedTextViewFactory(),
+            BigPictureLayoutInflaterFactory(),
+            NotificationOptimizedLinearLayoutFactory(),
+            { Mockito.mock(NotificationViewFlipperFactory::class.java) },
+        )
+    }
+
+    fun createRow(notification: Notification): ExpandableNotificationRow {
+        val channel =
+            NotificationChannel(
+                notification.channelId,
+                notification.channelId,
+                NotificationManager.IMPORTANCE_DEFAULT
+            )
+        channel.isBlockable = true
+        val entry =
+            NotificationEntryBuilder()
+                .setPkg(PKG)
+                .setOpPkg(PKG)
+                .setId(123321)
+                .setUid(UID)
+                .setInitialPid(2000)
+                .setNotification(notification)
+                .setUser(USER_HANDLE)
+                .setPostTime(System.currentTimeMillis())
+                .setChannel(channel)
+                .build()
+
+        // it is for mitigating Rank building process.
+        if (notification.isConversationStyleNotification) {
+            val rb = RankingBuilder(entry.ranking)
+            rb.setIsConversation(true)
+            entry.ranking = rb.build()
+        }
+
+        return generateRow(entry, FLAG_CONTENT_VIEW_ALL)
+    }
+
+    private fun generateRow(
+        entry: NotificationEntry,
+        @InflationFlag extraInflationFlags: Int
+    ): ExpandableNotificationRow {
+        // NOTE: This flag is read when the ExpandableNotificationRow is inflated, so it needs to be
+        //  set, but we do not want to override an existing value that is needed by a specific test.
+
+        val rowFuture: SettableFuture<ExpandableNotificationRow> = SettableFuture.create()
+        val rowInflaterTask =
+            RowInflaterTask(mFakeSystemClock, Mockito.mock(RowInflaterTaskLogger::class.java))
+        rowInflaterTask.inflate(context, null, entry, MoreExecutors.directExecutor()) { inflatedRow
+            ->
+            rowFuture.set(inflatedRow)
+        }
+        val row = rowFuture.get(1, TimeUnit.SECONDS)
+
+        entry.row = row
+        mIconManager.createIcons(entry)
+        mBindPipelineEntryListener.onEntryInit(entry)
+        mBindPipeline.manageRow(entry, row)
+        row.initialize(
+            entry,
+            Mockito.mock(RemoteInputViewSubcomponent.Factory::class.java),
+            APP_NAME,
+            entry.key,
+            mMockLogger,
+            mKeyguardBypassController,
+            mGroupMembershipManager,
+            mGroupExpansionManager,
+            mHeadsUpManager,
+            mBindStage,
+            Mockito.mock(OnExpandClickListener::class.java),
+            Mockito.mock(CoordinateOnClickListener::class.java),
+            FalsingManagerFake(),
+            mStatusBarStateController,
+            mPeopleNotificationIdentifier,
+            mOnUserInteractionCallback,
+            Optional.of(Mockito.mock(BubblesManager::class.java)),
+            Mockito.mock(NotificationGutsManager::class.java),
+            mDismissibilityProvider,
+            Mockito.mock(MetricsLogger::class.java),
+            Mockito.mock(NotificationChildrenContainerLogger::class.java),
+            Mockito.mock(ColorUpdateLogger::class.java),
+            mSmartReplyConstants,
+            mSmartReplyController,
+            featureFlags,
+            Mockito.mock(IStatusBarService::class.java)
+        )
+        row.setAboveShelfChangedListener { aboveShelf: Boolean -> }
+        mBindStage.getStageParams(entry).requireContentViews(extraInflationFlags)
+        inflateAndWait(entry)
+        return row
+    }
+
+    private fun inflateAndWait(entry: NotificationEntry) {
+        val countDownLatch = CountDownLatch(1)
+        mBindStage.requestRebind(entry) { en: NotificationEntry? -> countDownLatch.countDown() }
+        runWithCurrentThreadAsMainThread(mMainExecutor::runAllReady)
+        assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS))
+    }
+
+    companion object {
+        private const val APP_NAME = "appName"
+        private const val PKG = "com.android.systemui"
+        private const val UID = 1000
+        private val USER_HANDLE = UserHandle.of(ActivityManager.getCurrentUser())
+
+        private const val IS_CONVERSATION_FLAG = "test.isConversation"
+
+        private val Notification.isConversationStyleNotification
+            get() = extras.getBoolean(IS_CONVERSATION_FLAG, false)
+
+        fun markAsConversation(builder: Notification.Builder) {
+            builder.addExtras(bundleOf(IS_CONVERSATION_FLAG to true))
+        }
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt
new file mode 100644
index 0000000..84ef4b5
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.row.data.repository
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import kotlinx.coroutines.flow.MutableStateFlow
+
+val Kosmos.fakeNotificationRowRepository by Fixture { FakeNotificationRowRepository() }
+
+class FakeNotificationRowRepository : NotificationRowRepository {
+    override val richOngoingContentModel = MutableStateFlow<RichOngoingContentModel?>(null)
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt
similarity index 68%
copy from packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt
copy to packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt
index 6e11977..3a7d7ba 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridSizeInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt
@@ -14,10 +14,10 @@
  * limitations under the License.
  */
 
-package com.android.systemui.qs.panels.domain.interactor
+package com.android.systemui.statusbar.notification.row.domain.interactor
 
 import com.android.systemui.kosmos.Kosmos
-import com.android.systemui.qs.panels.data.repository.infiniteGridSizeRepository
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
 
-val Kosmos.infiniteGridSizeInteractor by
-    Kosmos.Fixture { InfiniteGridSizeInteractor(infiniteGridSizeRepository) }
+fun Kosmos.getNotificationRowInteractor(repository: NotificationRowRepository) =
+    NotificationRowInteractor(repository = repository)
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt
new file mode 100644
index 0000000..00f45b2
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.row.ui.viewmodel
+
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+import com.android.systemui.statusbar.notification.row.domain.interactor.getNotificationRowInteractor
+
+fun Kosmos.getTimerViewModel(repository: NotificationRowRepository) =
+    TimerViewModel(
+        dumpManager = dumpManager,
+        rowInteractor = getNotificationRowInteractor(repository),
+    )
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/phone/SystemUIDialogFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/phone/SystemUIDialogFactoryKosmos.kt
index 1851c89..6574946 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/phone/SystemUIDialogFactoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/phone/SystemUIDialogFactoryKosmos.kt
@@ -36,3 +36,14 @@
 
 val Kosmos.mockSystemUIDialogFactory: SystemUIDialog.Factory by
     Kosmos.Fixture { mock<SystemUIDialog.Factory>() }
+
+val Kosmos.systemUIDialogDotFactory by
+    Kosmos.Fixture {
+        SystemUIDialog.Factory(
+            applicationContext,
+            systemUIDialogManager,
+            sysUiState,
+            broadcastDispatcher,
+            dialogTransitionAnimator,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/FakeStatusBarPolicyDataLayerModule.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/FakeStatusBarPolicyDataLayerModule.kt
index 16dab40..5aece1b 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/FakeStatusBarPolicyDataLayerModule.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/FakeStatusBarPolicyDataLayerModule.kt
@@ -16,14 +16,7 @@
 package com.android.systemui.statusbar.policy.data
 
 import com.android.systemui.statusbar.policy.data.repository.FakeDeviceProvisioningRepositoryModule
-import com.android.systemui.statusbar.policy.data.repository.FakeZenModeRepositoryModule
 import dagger.Module
 
-@Module(
-    includes =
-        [
-            FakeDeviceProvisioningRepositoryModule::class,
-            FakeZenModeRepositoryModule::class,
-        ]
-)
+@Module(includes = [FakeDeviceProvisioningRepositoryModule::class])
 object FakeStatusBarPolicyDataLayerModule
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/FakeZenModeRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/FakeZenModeRepository.kt
deleted file mode 100644
index c4d7867..0000000
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/FakeZenModeRepository.kt
+++ /dev/null
@@ -1,53 +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.policy.data.repository
-
-import android.app.NotificationManager.Policy
-import android.provider.Settings
-import com.android.systemui.dagger.SysUISingleton
-import dagger.Binds
-import dagger.Module
-import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
-
-@SysUISingleton
-class FakeZenModeRepository @Inject constructor() : ZenModeRepository {
-    override val zenMode: MutableStateFlow<Int> = MutableStateFlow(Settings.Global.ZEN_MODE_OFF)
-    override val consolidatedNotificationPolicy: MutableStateFlow<Policy?> =
-        MutableStateFlow(
-            Policy(
-                /* priorityCategories = */ 0,
-                /* priorityCallSenders = */ 0,
-                /* priorityMessageSenders = */ 0,
-            )
-        )
-
-    fun setSuppressedVisualEffects(suppressedVisualEffects: Int) {
-        consolidatedNotificationPolicy.value =
-            Policy(
-                /* priorityCategories = */ 0,
-                /* priorityCallSenders = */ 0,
-                /* priorityMessageSenders = */ 0,
-                /* suppressedVisualEffects = */ suppressedVisualEffects,
-            )
-    }
-}
-
-@Module
-interface FakeZenModeRepositoryModule {
-    @Binds fun bindFake(fake: FakeZenModeRepository): ZenModeRepository
-}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryKosmos.kt
index 1ec7511..c7fda54 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/data/repository/ZenModeRepositoryKosmos.kt
@@ -16,6 +16,7 @@
 
 package com.android.systemui.statusbar.policy.data.repository
 
+import com.android.settingslib.statusbar.notification.data.repository.FakeZenModeRepository
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.Kosmos.Fixture
 
diff --git a/proto/src/system_messages.proto b/proto/src/system_messages.proto
index e8db80a..994bdb5 100644
--- a/proto/src/system_messages.proto
+++ b/proto/src/system_messages.proto
@@ -314,6 +314,10 @@
     // Package: com.android.systemui
     NOTE_ADAPTIVE_NOTIFICATIONS = 76;
 
+    // Warn the user that the device's Headless System User Mode status doesn't match the build's.
+    // Package: android
+    NOTE_WRONG_HSUM_STATUS = 77;
+
     // ADD_NEW_IDS_ABOVE_THIS_LINE
     // Legacy IDs with arbitrary values appear below
     // Legacy IDs existed as stable non-conflicting constants prior to the O release
diff --git a/services/contextualsearch/java/com/android/server/contextualsearch/ContextualSearchManagerService.java b/services/contextualsearch/java/com/android/server/contextualsearch/ContextualSearchManagerService.java
index b64aa8a..ea6351b 100644
--- a/services/contextualsearch/java/com/android/server/contextualsearch/ContextualSearchManagerService.java
+++ b/services/contextualsearch/java/com/android/server/contextualsearch/ContextualSearchManagerService.java
@@ -364,7 +364,7 @@
     }
 
     @RequiresPermission(android.Manifest.permission.START_TASKS_FROM_RECENTS)
-    private int invokeContextualSearchIntent(Intent launchIntent) {
+    private int invokeContextualSearchIntent(Intent launchIntent, final int userId) {
         // Contextual search starts with a frozen screen - so we launch without
         // any system animations or starting window.
         final ActivityOptions opts = ActivityOptions.makeCustomTaskAnimation(mContext,
@@ -372,7 +372,7 @@
         opts.setDisableStartingWindow(true);
         return mAtmInternal.startActivityWithScreenshot(launchIntent,
                 mContext.getPackageName(), Binder.getCallingUid(), Binder.getCallingPid(), null,
-                opts.toBundle(), Binder.getCallingUserHandle().getIdentifier());
+                opts.toBundle(), userId);
     }
 
     private void enforcePermission(@NonNull final String func) {
@@ -446,6 +446,8 @@
             synchronized (this) {
                 if (DEBUG_USER) Log.d(TAG, "startContextualSearch");
                 enforcePermission("startContextualSearch");
+                final int callingUserId = Binder.getCallingUserHandle().getIdentifier();
+
                 mAssistDataRequester.cancel();
                 // Creates a new CallbackToken at mToken and an expiration handler.
                 issueToken();
@@ -455,7 +457,7 @@
                 Binder.withCleanCallingIdentity(() -> {
                     Intent launchIntent = getContextualSearchIntent(entrypoint, mToken);
                     if (launchIntent != null) {
-                        int result = invokeContextualSearchIntent(launchIntent);
+                        int result = invokeContextualSearchIntent(launchIntent, callingUserId);
                         if (DEBUG_USER) Log.d(TAG, "Launch result: " + result);
                     }
                 });
diff --git a/services/core/java/com/android/server/StorageManagerService.java b/services/core/java/com/android/server/StorageManagerService.java
index b35959f..19279a8 100644
--- a/services/core/java/com/android/server/StorageManagerService.java
+++ b/services/core/java/com/android/server/StorageManagerService.java
@@ -104,6 +104,7 @@
 import android.os.ServiceSpecificException;
 import android.os.SystemClock;
 import android.os.SystemProperties;
+import android.os.Trace;
 import android.os.UserHandle;
 import android.os.UserManager;
 import android.os.storage.DiskInfo;
@@ -1180,6 +1181,7 @@
 
     private void onUserUnlocking(int userId) {
         Slog.d(TAG, "onUserUnlocking " + userId);
+        Trace.instant(Trace.TRACE_TAG_SYSTEM_SERVER, "SMS.onUserUnlocking: " + userId);
 
         if (userId != UserHandle.USER_SYSTEM) {
             // Check if this user shares media with another user
@@ -1466,6 +1468,8 @@
         @Override
         public void onVolumeCreated(String volId, int type, String diskId, String partGuid,
                 int userId) {
+            Trace.instant(Trace.TRACE_TAG_SYSTEM_SERVER,
+                    "SMS.onVolumeCreated: " + volId + ", " + userId);
             synchronized (mLock) {
                 final DiskInfo disk = mDisks.get(diskId);
                 final VolumeInfo vol = new VolumeInfo(volId, type, disk, partGuid);
@@ -2352,6 +2356,7 @@
 
     private void mount(VolumeInfo vol) {
         try {
+            Trace.traceBegin(Trace.TRACE_TAG_SYSTEM_SERVER, "SMS.mount: " + vol.id);
             // TODO(b/135341433): Remove cautious logging when FUSE is stable
             Slog.i(TAG, "Mounting volume " + vol);
             extendWatchdogTimeout("#mount might be slow");
@@ -2363,6 +2368,8 @@
                     vol.internalPath = internalPath;
                     ParcelFileDescriptor pfd = new ParcelFileDescriptor(fd);
                     try {
+                        Trace.traceBegin(Trace.TRACE_TAG_SYSTEM_SERVER,
+                                "SMS.startFuseFileSystem: " + vol.id);
                         mStorageSessionController.onVolumeMount(pfd, vol);
                         return true;
                     } catch (ExternalStorageServiceException e) {
@@ -2375,6 +2382,7 @@
                                 TimeUnit.SECONDS.toMillis(nextResetSeconds));
                         return false;
                     } finally {
+                        Trace.traceEnd(Trace.TRACE_TAG_SYSTEM_SERVER);
                         try {
                             pfd.close();
                         } catch (Exception e) {
@@ -2386,6 +2394,8 @@
             Slog.i(TAG, "Mounted volume " + vol);
         } catch (Exception e) {
             Slog.wtf(TAG, e);
+        } finally {
+            Trace.traceEnd(Trace.TRACE_TAG_SYSTEM_SERVER);
         }
     }
 
diff --git a/services/core/java/com/android/server/audio/AudioDeviceBroker.java b/services/core/java/com/android/server/audio/AudioDeviceBroker.java
index bce1830..27fda15 100644
--- a/services/core/java/com/android/server/audio/AudioDeviceBroker.java
+++ b/services/core/java/com/android/server/audio/AudioDeviceBroker.java
@@ -414,7 +414,8 @@
         }
         if (!mScoManagedByAudio) {
             boolean isBtScoRequested = isBluetoothScoRequested();
-            if (isBtScoRequested && (!wasBtScoRequested || !isBluetoothScoActive())) {
+            if (isBtScoRequested && (!wasBtScoRequested || !isBluetoothScoActive()
+                    || !mBtHelper.isBluetoothScoRequestedInternally())) {
                 if (!mBtHelper.startBluetoothSco(scoAudioMode, eventSource)) {
                     Log.w(TAG, "setCommunicationRouteForClient: failure to start BT SCO for uid: "
                             + uid);
@@ -1148,13 +1149,14 @@
     }
 
     /*package*/ void setBluetoothScoOn(boolean on, String eventSource) {
-        if (AudioService.DEBUG_COMM_RTE) {
-            Log.v(TAG, "setBluetoothScoOn: " + on + " " + eventSource);
-        }
         synchronized (mBluetoothAudioStateLock) {
+            boolean isBtScoRequested = isBluetoothScoRequested();
+            Log.i(TAG, "setBluetoothScoOn: " + on + ", mBluetoothScoOn: "
+                    + mBluetoothScoOn + ", isBtScoRequested: " + isBtScoRequested
+                    + ", from: " + eventSource);
             mBluetoothScoOn = on;
             updateAudioHalBluetoothState();
-            postUpdateCommunicationRouteClient(isBluetoothScoRequested(), eventSource);
+            postUpdateCommunicationRouteClient(isBtScoRequested, eventSource);
         }
     }
 
diff --git a/services/core/java/com/android/server/audio/BtHelper.java b/services/core/java/com/android/server/audio/BtHelper.java
index 991f94b..8008717 100644
--- a/services/core/java/com/android/server/audio/BtHelper.java
+++ b/services/core/java/com/android/server/audio/BtHelper.java
@@ -378,7 +378,6 @@
     /*package*/ synchronized void onReceiveBtEvent(Intent intent) {
         final String action = intent.getAction();
 
-        Log.i(TAG, "onReceiveBtEvent action: " + action + " mScoAudioState: " + mScoAudioState);
         if (action.equals(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED)) {
             BluetoothDevice btDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE,
                     android.bluetooth.BluetoothDevice.class);
@@ -405,6 +404,7 @@
     private void onScoAudioStateChanged(int state) {
         boolean broadcast = false;
         int scoAudioState = AudioManager.SCO_AUDIO_STATE_ERROR;
+        Log.i(TAG, "onScoAudioStateChanged state: " + state + " mScoAudioState: " + mScoAudioState);
         if (mDeviceBroker.isScoManagedByAudio()) {
             switch (state) {
                 case BluetoothHeadset.STATE_AUDIO_CONNECTED:
@@ -488,6 +488,11 @@
                 == BluetoothHeadset.STATE_AUDIO_CONNECTED;
     }
 
+    /*package*/ synchronized boolean isBluetoothScoRequestedInternally() {
+        return mScoAudioState == SCO_STATE_ACTIVE_INTERNAL
+              || mScoAudioState == SCO_STATE_ACTIVATE_REQ;
+    }
+
     // @GuardedBy("mDeviceBroker.mSetModeLock")
     @GuardedBy("AudioDeviceBroker.this.mDeviceStateLock")
     /*package*/ synchronized boolean startBluetoothSco(int scoAudioMode,
diff --git a/services/core/java/com/android/server/inputmethod/DefaultImeVisibilityApplier.java b/services/core/java/com/android/server/inputmethod/DefaultImeVisibilityApplier.java
index 7d48527..b77f47d 100644
--- a/services/core/java/com/android/server/inputmethod/DefaultImeVisibilityApplier.java
+++ b/services/core/java/com/android/server/inputmethod/DefaultImeVisibilityApplier.java
@@ -69,7 +69,6 @@
     @NonNull
     private final ImeTargetVisibilityPolicy mImeTargetVisibilityPolicy;
 
-
     DefaultImeVisibilityApplier(InputMethodManagerService service) {
         mService = service;
         mWindowManagerInternal = LocalServices.getService(WindowManagerInternal.class);
@@ -80,8 +79,9 @@
     @Override
     public void performShowIme(IBinder showInputToken, @NonNull ImeTracker.Token statsToken,
             @InputMethod.ShowFlags int showFlags, ResultReceiver resultReceiver,
-            @SoftInputShowHideReason int reason) {
-        final IInputMethodInvoker curMethod = mService.getCurMethodLocked();
+            @SoftInputShowHideReason int reason, @UserIdInt int userId) {
+        final var bindingController = mService.getInputMethodBindingController(userId);
+        final IInputMethodInvoker curMethod = bindingController.getCurMethod();
         if (curMethod != null) {
             if (DEBUG) {
                 Slog.v(TAG, "Calling " + curMethod + ".showSoftInput(" + showInputToken
@@ -99,7 +99,7 @@
                                     mService.mImeBindingState.mFocusedWindowSoftInputMode));
                 }
                 mService.onShowHideSoftInputRequested(true /* show */, showInputToken, reason,
-                        statsToken);
+                        statsToken, userId);
             }
         }
     }
@@ -107,8 +107,10 @@
     @GuardedBy("ImfLock.class")
     @Override
     public void performHideIme(IBinder hideInputToken, @NonNull ImeTracker.Token statsToken,
-            ResultReceiver resultReceiver, @SoftInputShowHideReason int reason) {
-        final IInputMethodInvoker curMethod = mService.getCurMethodLocked();
+            ResultReceiver resultReceiver, @SoftInputShowHideReason int reason,
+            @UserIdInt int userId) {
+        final var bindingController = mService.getInputMethodBindingController(userId);
+        final IInputMethodInvoker curMethod = bindingController.getCurMethod();
         if (curMethod != null) {
             // The IME will report its visible state again after the following message finally
             // delivered to the IME process as an IPC.  Hence the inconsistency between
@@ -130,7 +132,7 @@
                                     mService.mImeBindingState.mFocusedWindowSoftInputMode));
                 }
                 mService.onShowHideSoftInputRequested(false /* show */, hideInputToken, reason,
-                        statsToken);
+                        statsToken, userId);
             }
         }
     }
@@ -180,7 +182,7 @@
                     setImeVisibilityOnFocusedWindowClient(false);
                 } else {
                     mService.hideCurrentInputLocked(windowToken, statsToken,
-                        0 /* flags */, null /* resultReceiver */, reason);
+                            0 /* flags */, null /* resultReceiver */, reason);
                 }
                 break;
             case STATE_HIDE_IME_NOT_ALWAYS:
@@ -199,14 +201,14 @@
                 } else {
                     mService.showCurrentInputLocked(windowToken, statsToken,
                             InputMethodManager.SHOW_IMPLICIT, MotionEvent.TOOL_TYPE_UNKNOWN,
-                        null /* resultReceiver */, reason);
+                            null /* resultReceiver */, reason);
                 }
                 break;
             case STATE_SHOW_IME_SNAPSHOT:
-                showImeScreenshot(windowToken, displayIdToShowIme);
+                showImeScreenshot(windowToken, displayIdToShowIme, userId);
                 break;
             case STATE_REMOVE_IME_SNAPSHOT:
-                removeImeScreenshot(displayIdToShowIme);
+                removeImeScreenshot(displayIdToShowIme, userId);
                 break;
             default:
                 throw new IllegalArgumentException("Invalid IME visibility state: " + state);
@@ -215,10 +217,11 @@
 
     @GuardedBy("ImfLock.class")
     @Override
-    public boolean showImeScreenshot(@NonNull IBinder imeTarget, int displayId) {
+    public boolean showImeScreenshot(@NonNull IBinder imeTarget, int displayId,
+            @UserIdInt int userId) {
         if (mImeTargetVisibilityPolicy.showImeScreenshot(imeTarget, displayId)) {
             mService.onShowHideSoftInputRequested(false /* show */, imeTarget,
-                    SHOW_IME_SCREENSHOT_FROM_IMMS, null /* statsToken */);
+                    SHOW_IME_SCREENSHOT_FROM_IMMS, null /* statsToken */, userId);
             return true;
         }
         return false;
@@ -226,11 +229,11 @@
 
     @GuardedBy("ImfLock.class")
     @Override
-    public boolean removeImeScreenshot(int displayId) {
+    public boolean removeImeScreenshot(int displayId, @UserIdInt int userId) {
         if (mImeTargetVisibilityPolicy.removeImeScreenshot(displayId)) {
             mService.onShowHideSoftInputRequested(false /* show */,
                     mService.mImeBindingState.mFocusedWindow,
-                    REMOVE_IME_SCREENSHOT_FROM_IMMS, null /* statsToken */);
+                    REMOVE_IME_SCREENSHOT_FROM_IMMS, null /* statsToken */, userId);
             return true;
         }
         return false;
diff --git a/services/core/java/com/android/server/inputmethod/HardwareKeyboardShortcutController.java b/services/core/java/com/android/server/inputmethod/HardwareKeyboardShortcutController.java
index 62adb25..a6b07de 100644
--- a/services/core/java/com/android/server/inputmethod/HardwareKeyboardShortcutController.java
+++ b/services/core/java/com/android/server/inputmethod/HardwareKeyboardShortcutController.java
@@ -19,7 +19,6 @@
 import android.annotation.AnyThread;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.annotation.UserIdInt;
 import android.view.inputmethod.InputMethodInfo;
 import android.view.inputmethod.InputMethodSubtype;
 
@@ -34,24 +33,13 @@
     @GuardedBy("ImfLock.class")
     private final ArrayList<InputMethodSubtypeHandle> mSubtypeHandles = new ArrayList<>();
 
-    @UserIdInt
-    private final int mUserId;
-
-    @AnyThread
-    @UserIdInt
-    int getUserId() {
-        return mUserId;
-    }
-
-    HardwareKeyboardShortcutController(@NonNull InputMethodMap methodMap, @UserIdInt int userId) {
-        mUserId = userId;
-        reset(methodMap);
+    HardwareKeyboardShortcutController(@NonNull InputMethodSettings settings) {
+        update(settings);
     }
 
     @GuardedBy("ImfLock.class")
-    void reset(@NonNull InputMethodMap methodMap) {
+    void update(@NonNull InputMethodSettings settings) {
         mSubtypeHandles.clear();
-        final InputMethodSettings settings = InputMethodSettings.create(methodMap, mUserId);
         final List<InputMethodInfo> inputMethods = settings.getEnabledInputMethodList();
         for (int i = 0; i < inputMethods.size(); ++i) {
             final InputMethodInfo imi = inputMethods.get(i);
diff --git a/services/core/java/com/android/server/inputmethod/ImeVisibilityApplier.java b/services/core/java/com/android/server/inputmethod/ImeVisibilityApplier.java
index a5f9b7a..c1069f2 100644
--- a/services/core/java/com/android/server/inputmethod/ImeVisibilityApplier.java
+++ b/services/core/java/com/android/server/inputmethod/ImeVisibilityApplier.java
@@ -33,38 +33,45 @@
     /**
      * Performs showing IME on top of the given window.
      *
-     * @param showInputToken A token that represents the requester to show IME.
-     * @param statsToken     The token tracking the current IME request.
-     * @param resultReceiver If non-null, this will be called back to the caller when
-     *                       it has processed request to tell what it has done.
-     * @param reason         The reason for requesting to show IME.
+     * @param showInputToken a token that represents the requester to show IME
+     * @param statsToken     the token tracking the current IME request
+     * @param resultReceiver if non-null, this will be called back to the caller when
+     *                       it has processed request to tell what it has done
+     * @param reason         yhe reason for requesting to show IME
+     * @param userId         the target user when performing show IME
      */
     default void performShowIme(IBinder showInputToken, @NonNull ImeTracker.Token statsToken,
             @InputMethod.ShowFlags int showFlags, ResultReceiver resultReceiver,
-            @SoftInputShowHideReason int reason) {}
+            @SoftInputShowHideReason int reason, @UserIdInt int userId) {
+    }
 
     /**
      * Performs hiding IME to the given window
      *
-     * @param hideInputToken A token that represents the requester to hide IME.
-     * @param statsToken     The token tracking the current IME request.
-     * @param resultReceiver If non-null, this will be called back to the caller when
-     *                       it has processed request to tell what it has done.
-     * @param reason         The reason for requesting to hide IME.
+     * @param hideInputToken a token that represents the requester to hide IME
+     * @param statsToken     the token tracking the current IME request
+     * @param resultReceiver if non-null, this will be called back to the caller when
+     *                       it has processed request to tell what it has done
+     * @param reason         the reason for requesting to hide IME
+     * @param userId         the target user when performing hide IME
      */
     default void performHideIme(IBinder hideInputToken, @NonNull ImeTracker.Token statsToken,
-            ResultReceiver resultReceiver, @SoftInputShowHideReason int reason) {}
+            ResultReceiver resultReceiver, @SoftInputShowHideReason int reason,
+            @UserIdInt int userId) {
+    }
 
     /**
      * Applies the IME visibility from {@link android.inputmethodservice.InputMethodService} with
      * according to the given visibility state.
      *
-     * @param windowToken The token of a window for applying the IME visibility
-     * @param statsToken  The token tracking the current IME request.
-     * @param state       The new IME visibility state for the applier to handle
+     * @param windowToken the token of a window for applying the IME visibility
+     * @param statsToken  the token tracking the current IME request
+     * @param state       the new IME visibility state for the applier to handle
+     * @param userId      the target user when applying the IME visibility state
      */
     default void applyImeVisibility(IBinder windowToken, @NonNull ImeTracker.Token statsToken,
-            @ImeVisibilityStateComputer.VisibilityState int state, @UserIdInt int userId) {}
+            @ImeVisibilityStateComputer.VisibilityState int state, @UserIdInt int userId) {
+    }
 
     /**
      * Updates the IME Z-ordering relative to the given window.
@@ -72,7 +79,7 @@
      * This used to adjust the IME relative layer of the window during
      * {@link InputMethodManagerService} is in switching IME clients.
      *
-     * @param windowToken The token of a window to update the Z-ordering relative to the IME.
+     * @param windowToken the token of a window to update the Z-ordering relative to the IME
      */
     default void updateImeLayeringByTarget(IBinder windowToken) {
         // TODO: add a method in WindowManagerInternal to call DC#updateImeInputAndControlTarget
@@ -82,21 +89,24 @@
     /**
      * Shows the IME screenshot and attach it to the given IME target window.
      *
-     * @param windowToken The token of a window to show the IME screenshot.
-     * @param displayId The unique id to identify the display
-     * @return {@code true} if success, {@code false} otherwise.
+     * @param windowToken the token of a window to show the IME screenshot
+     * @param displayId   the unique id to identify the display
+     * @param userId      the target user when when showing the IME screenshot
+     * @return {@code true} if success, {@code false} otherwise
      */
-    default boolean showImeScreenshot(@NonNull IBinder windowToken, int displayId) {
+    default boolean showImeScreenshot(@NonNull IBinder windowToken, int displayId,
+            @UserIdInt int userId) {
         return false;
     }
 
     /**
      * Removes the IME screenshot on the given display.
      *
-     * @param displayId The target display of showing IME screenshot.
-     * @return {@code true} if success, {@code false} otherwise.
+     * @param displayId the target display of showing IME screenshot
+     * @param userId    the target user of showing IME screenshot
+     * @return {@code true} if success, {@code false} otherwise
      */
-    default boolean removeImeScreenshot(int displayId) {
+    default boolean removeImeScreenshot(int displayId, @UserIdInt int userId) {
         return false;
     }
 }
diff --git a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
index 39262c5..440343d 100644
--- a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
+++ b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
@@ -381,10 +381,6 @@
     @NonNull
     @MultiUserUnawareField
     private InputMethodSubtypeSwitchingController mSwitchingController;
-    // TODO: Instantiate mHardwareKeyboardShortcutController for each user.
-    @NonNull
-    @MultiUserUnawareField
-    private HardwareKeyboardShortcutController mHardwareKeyboardShortcutController;
 
     @Nullable
     private StatusBarManagerInternal mStatusBarManagerInternal;
@@ -1300,11 +1296,8 @@
 
             final InputMethodSettings settings = InputMethodSettingsRepository.get(mCurrentUserId);
 
-            mSwitchingController = new InputMethodSubtypeSwitchingController(context,
-                    settings.getMethodMap(), settings.getUserId());
-            mHardwareKeyboardShortcutController =
-                    new HardwareKeyboardShortcutController(settings.getMethodMap(),
-                            settings.getUserId());
+            mSwitchingController = new InputMethodSubtypeSwitchingController(context, settings);
+            getUserData(mCurrentUserId).mHardwareKeyboardShortcutController.update(settings);
             mMenuController = new InputMethodMenuController(this);
             mVisibilityStateComputer = new ImeVisibilityStateComputer(this);
             mVisibilityApplier = new DefaultImeVisibilityApplier(this);
@@ -2936,7 +2929,6 @@
      *     </li>
      *     <li>{@link InputMethodBindingController#getDeviceIdToShowIme()} is ignored.</li>
      *     <li>{@link #mSwitchingController} is ignored.</li>
-     *     <li>{@link #mHardwareKeyboardShortcutController} is ignored.</li>
      *     <li>{@link #mPreventImeStartupUnlessTextEditor} is ignored.</li>
      *     <li>and so on.</li>
      * </ul>
@@ -2969,6 +2961,9 @@
             id = imi.getId();
             settings.putSelectedInputMethod(id);
         }
+
+        final var userData = getUserData(userId);
+        userData.mHardwareKeyboardShortcutController.update(settings);
     }
 
     @GuardedBy("ImfLock.class")
@@ -3046,18 +3041,11 @@
 
         // TODO: Instantiate mSwitchingController for each user.
         if (userId == mSwitchingController.getUserId()) {
-            mSwitchingController.resetCircularListLocked(settings.getMethodMap());
+            mSwitchingController.resetCircularListLocked(settings);
         } else {
-            mSwitchingController = new InputMethodSubtypeSwitchingController(mContext,
-                    settings.getMethodMap(), userId);
+            mSwitchingController = new InputMethodSubtypeSwitchingController(mContext, settings);
         }
-        // TODO: Instantiate mHardwareKeyboardShortcutController for each user.
-        if (userId == mHardwareKeyboardShortcutController.getUserId()) {
-            mHardwareKeyboardShortcutController.reset(settings.getMethodMap());
-        } else {
-            mHardwareKeyboardShortcutController = new HardwareKeyboardShortcutController(
-                    settings.getMethodMap(), userId);
-        }
+        getUserData(userId).mHardwareKeyboardShortcutController.update(settings);
         sendOnNavButtonFlagsChangedLocked();
     }
 
@@ -3519,10 +3507,11 @@
 
         mVisibilityStateComputer.requestImeVisibility(windowToken, true);
 
+        final int userId = mCurrentUserId;
         // Ensure binding the connection when IME is going to show.
-        final var bindingController = getInputMethodBindingController(mCurrentUserId);
+        final var bindingController = getInputMethodBindingController(userId);
         bindingController.setCurrentMethodVisible();
-        final IInputMethodInvoker curMethod = getCurMethodLocked();
+        final IInputMethodInvoker curMethod = bindingController.getCurMethod();
         ImeTracker.forLogging().onCancelled(mCurStatsToken, ImeTracker.PHASE_SERVER_WAIT_IME);
         final boolean readyToDispatchToIme;
         if (Flags.deferShowSoftInputUntilSessionCreation()) {
@@ -3542,7 +3531,7 @@
             }
             mVisibilityApplier.performShowIme(windowToken, statsToken,
                     mVisibilityStateComputer.getShowFlagsForInputMethodServiceOnly(),
-                    resultReceiver, reason);
+                    resultReceiver, reason, userId);
             mVisibilityStateComputer.setInputShown(true);
             return true;
         } else {
@@ -3654,7 +3643,9 @@
         // since Android Eclair.  That's why we need to accept IMM#hideSoftInput() even when only
         // IMMS#InputShown indicates that the software keyboard is shown.
         // TODO(b/246309664): Clean up IMMS#mImeWindowVis
-        IInputMethodInvoker curMethod = getCurMethodLocked();
+        final int userId = mCurrentUserId;
+        final var bindingController = getInputMethodBindingController(userId);
+        IInputMethodInvoker curMethod = bindingController.getCurMethod();
         final boolean shouldHideSoftInput = curMethod != null
                 && (isInputShownLocked() || (mImeWindowVis & InputMethodService.IME_ACTIVE) != 0);
 
@@ -3665,11 +3656,11 @@
             // IMMS#mInputShown and IMMS#mImeWindowVis should be resolved spontaneously in
             // the final state.
             ImeTracker.forLogging().onProgress(statsToken, ImeTracker.PHASE_SERVER_SHOULD_HIDE);
-            mVisibilityApplier.performHideIme(windowToken, statsToken, resultReceiver, reason);
+            mVisibilityApplier.performHideIme(windowToken, statsToken, resultReceiver, reason,
+                    userId);
         } else {
             ImeTracker.forLogging().onCancelled(statsToken, ImeTracker.PHASE_SERVER_SHOULD_HIDE);
         }
-        final var bindingController = getInputMethodBindingController(mCurrentUserId);
         bindingController.setCurrentMethodNotVisible();
         mVisibilityStateComputer.clearImeShowFlags();
         // Cancel existing statsToken for show IME as we got a hide request.
@@ -4735,12 +4726,14 @@
      */
     @GuardedBy("ImfLock.class")
     void onShowHideSoftInputRequested(boolean show, IBinder requestImeToken,
-            @SoftInputShowHideReason int reason, @Nullable ImeTracker.Token statsToken) {
+            @SoftInputShowHideReason int reason, @Nullable ImeTracker.Token statsToken,
+            @UserIdInt int userId) {
         final IBinder requestToken = mVisibilityStateComputer.getWindowTokenFrom(requestImeToken);
+        final var bindingController = getInputMethodBindingController(userId);
         final WindowManagerInternal.ImeTargetInfo info =
                 mWindowManagerInternal.onToggleImeRequested(
                         show, mImeBindingState.mFocusedWindow, requestToken,
-                        getCurTokenDisplayIdLocked());
+                        bindingController.getCurTokenDisplayId());
         mSoftInputShowHideHistory.addEntry(new SoftInputShowHideHistory.Entry(
                 mImeBindingState.mFocusedWindowClient, mImeBindingState.mFocusedWindowEditorInfo,
                 info.focusedWindowName, mImeBindingState.mFocusedWindowSoftInputMode, reason,
@@ -4927,7 +4920,7 @@
                     final List<ImeSubtypeListItem> imList = InputMethodSubtypeSwitchingController
                             .getSortedInputMethodAndSubtypeList(
                                     showAuxSubtypes, isScreenLocked, true /* forImeMenu */,
-                                    mContext, settings.getMethodMap(), settings.getUserId());
+                                    mContext, settings);
                     if (imList.isEmpty()) {
                         Slog.w(TAG, "Show switching menu failed, imList is empty,"
                                 + " showAuxSubtypes: " + showAuxSubtypes
@@ -5317,18 +5310,11 @@
 
         // TODO: Instantiate mSwitchingController for each user.
         if (userId == mSwitchingController.getUserId()) {
-            mSwitchingController.resetCircularListLocked(settings.getMethodMap());
+            mSwitchingController.resetCircularListLocked(settings);
         } else {
-            mSwitchingController = new InputMethodSubtypeSwitchingController(mContext,
-                    settings.getMethodMap(), mCurrentUserId);
+            mSwitchingController = new InputMethodSubtypeSwitchingController(mContext, settings);
         }
-        // TODO: Instantiate mHardwareKeyboardShortcutController for each user.
-        if (userId == mHardwareKeyboardShortcutController.getUserId()) {
-            mHardwareKeyboardShortcutController.reset(settings.getMethodMap());
-        } else {
-            mHardwareKeyboardShortcutController = new HardwareKeyboardShortcutController(
-                    settings.getMethodMap(), userId);
-        }
+        getUserData(userId).mHardwareKeyboardShortcutController.update(settings);
 
         sendOnNavButtonFlagsChangedLocked();
 
@@ -5639,8 +5625,8 @@
         final InputMethodSubtypeHandle currentSubtypeHandle =
                 InputMethodSubtypeHandle.of(currentImi, bindingController.getCurrentSubtype());
         final InputMethodSubtypeHandle nextSubtypeHandle =
-                mHardwareKeyboardShortcutController.onSubtypeSwitch(currentSubtypeHandle,
-                        direction > 0);
+                getUserData(userId).mHardwareKeyboardShortcutController.onSubtypeSwitch(
+                        currentSubtypeHandle, direction > 0);
         if (nextSubtypeHandle == null) {
             return;
         }
diff --git a/services/core/java/com/android/server/inputmethod/InputMethodSubtypeSwitchingController.java b/services/core/java/com/android/server/inputmethod/InputMethodSubtypeSwitchingController.java
index bf9621f..f97a516 100644
--- a/services/core/java/com/android/server/inputmethod/InputMethodSubtypeSwitchingController.java
+++ b/services/core/java/com/android/server/inputmethod/InputMethodSubtypeSwitchingController.java
@@ -163,13 +163,12 @@
     @NonNull
     static List<ImeSubtypeListItem> getSortedInputMethodAndSubtypeList(
             boolean includeAuxiliarySubtypes, boolean isScreenLocked, boolean forImeMenu,
-            @NonNull Context context, @NonNull InputMethodMap methodMap,
-            @UserIdInt int userId) {
+            @NonNull Context context, @NonNull InputMethodSettings settings) {
+        final int userId = settings.getUserId();
         final Context userAwareContext = context.getUserId() == userId
                 ? context
                 : context.createContextAsUser(UserHandle.of(userId), 0 /* flags */);
         final String mSystemLocaleStr = SystemLocaleWrapper.get(userId).get(0).toLanguageTag();
-        final InputMethodSettings settings = InputMethodSettings.create(methodMap, userId);
 
         final ArrayList<InputMethodInfo> imis = settings.getEnabledInputMethodList();
         if (imis.isEmpty()) {
@@ -487,13 +486,13 @@
     private ControllerImpl mController;
 
     InputMethodSubtypeSwitchingController(@NonNull Context context,
-            @NonNull InputMethodMap methodMap, @UserIdInt int userId) {
+            @NonNull InputMethodSettings settings) {
         mContext = context;
-        mUserId = userId;
+        mUserId = settings.getUserId();
         mController = ControllerImpl.createFrom(null,
                 getSortedInputMethodAndSubtypeList(
                         false /* includeAuxiliarySubtypes */, false /* isScreenLocked */,
-                        false /* forImeMenu */, context, methodMap, userId));
+                        false /* forImeMenu */, context, settings));
     }
 
     @AnyThread
@@ -507,11 +506,11 @@
         mController.onUserActionLocked(imi, subtype);
     }
 
-    public void resetCircularListLocked(@NonNull InputMethodMap methodMap) {
+    public void resetCircularListLocked(@NonNull InputMethodSettings settings) {
         mController = ControllerImpl.createFrom(mController,
                 getSortedInputMethodAndSubtypeList(
                         false /* includeAuxiliarySubtypes */, false /* isScreenLocked */,
-                        false /* forImeMenu */, mContext, methodMap, mUserId));
+                        false /* forImeMenu */, mContext, settings));
     }
 
     @Nullable
diff --git a/services/core/java/com/android/server/inputmethod/UserDataRepository.java b/services/core/java/com/android/server/inputmethod/UserDataRepository.java
index 2b19d3e..5da4e89 100644
--- a/services/core/java/com/android/server/inputmethod/UserDataRepository.java
+++ b/services/core/java/com/android/server/inputmethod/UserDataRepository.java
@@ -88,6 +88,9 @@
         @NonNull
         final InputMethodBindingController mBindingController;
 
+        @NonNull
+        final HardwareKeyboardShortcutController mHardwareKeyboardShortcutController;
+
         /**
          * Intended to be instantiated only from this file.
          */
@@ -95,6 +98,8 @@
                 @NonNull InputMethodBindingController bindingController) {
             mUserId = userId;
             mBindingController = bindingController;
+            mHardwareKeyboardShortcutController = new HardwareKeyboardShortcutController(
+                    InputMethodSettings.createEmptyMap(userId));
         }
 
         @Override
diff --git a/services/core/java/com/android/server/notification/ZenModeHelper.java b/services/core/java/com/android/server/notification/ZenModeHelper.java
index 2e7295e..c078409 100644
--- a/services/core/java/com/android/server/notification/ZenModeHelper.java
+++ b/services/core/java/com/android/server/notification/ZenModeHelper.java
@@ -34,6 +34,7 @@
 import static android.service.notification.ZenModeConfig.UPDATE_ORIGIN_INIT_USER;
 import static android.service.notification.ZenModeConfig.UPDATE_ORIGIN_RESTORE_BACKUP;
 import static android.service.notification.ZenModeConfig.UPDATE_ORIGIN_SYSTEM_OR_SYSTEMUI;
+import static android.service.notification.ZenModeConfig.UPDATE_ORIGIN_UNKNOWN;
 import static android.service.notification.ZenModeConfig.UPDATE_ORIGIN_USER;
 
 import static com.android.internal.util.FrameworkStatsLog.DND_MODE_RULE;
@@ -1143,6 +1144,17 @@
                 modified = true;
             }
 
+            if (Flags.modesUi()) {
+                if (!azr.isEnabled() && (isNew || rule.enabled)) {
+                    // Creating a rule as disabled, or disabling a previously enabled rule.
+                    // Record whodunit.
+                    rule.disabledOrigin = origin;
+                } else if (azr.isEnabled()) {
+                    // Enabling or previously enabled. Clear disabler.
+                    rule.disabledOrigin = UPDATE_ORIGIN_UNKNOWN;
+                }
+            }
+
             if (!Objects.equals(rule.conditionId, azr.getConditionId())) {
                 rule.conditionId = azr.getConditionId();
                 modified = true;
diff --git a/services/core/java/com/android/server/pm/UserManagerService.java b/services/core/java/com/android/server/pm/UserManagerService.java
index d1d8993..7f4a5cb 100644
--- a/services/core/java/com/android/server/pm/UserManagerService.java
+++ b/services/core/java/com/android/server/pm/UserManagerService.java
@@ -62,6 +62,8 @@
 import android.app.IActivityManager;
 import android.app.IStopUserCallback;
 import android.app.KeyguardManager;
+import android.app.Notification;
+import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.app.StatsManager;
 import android.app.admin.DevicePolicyEventLogger;
@@ -147,6 +149,8 @@
 import com.android.internal.app.IAppOpsService;
 import com.android.internal.app.SetScreenLockDialogActivity;
 import com.android.internal.logging.MetricsLogger;
+import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
+import com.android.internal.notification.SystemNotificationChannels;
 import com.android.internal.os.BackgroundThread;
 import com.android.internal.os.RoSystemProperties;
 import com.android.internal.util.DumpUtils;
@@ -1070,6 +1074,8 @@
         if (isAutoLockingPrivateSpaceOnRestartsEnabled()) {
             autoLockPrivateSpace();
         }
+
+        showHsumNotificationIfNeeded();
     }
 
     private boolean isAutoLockingPrivateSpaceOnRestartsEnabled() {
@@ -4163,6 +4169,48 @@
         mUpdatingSystemUserMode = true;
     }
 
+    /**
+     * If the device's actual HSUM status differs from that which is defined by its build
+     * configuration, warn the user. Ignores HSUM emulated status, since that isn't relevant.
+     *
+     * The goal is to inform dogfooders that they need to factory reset the device to align their
+     * device with its build configuration.
+     */
+    private void showHsumNotificationIfNeeded() {
+        if (RoSystemProperties.MULTIUSER_HEADLESS_SYSTEM_USER == isHeadlessSystemUserMode()) {
+            // Actual state does match the configuration. Great!
+            return;
+        }
+        if (Build.isDebuggable()
+                && !TextUtils.isEmpty(SystemProperties.get(SYSTEM_USER_MODE_EMULATION_PROPERTY))) {
+            // Ignore any device that has been playing around with HSUM emulation.
+            return;
+        }
+        Slogf.w(LOG_TAG, "Posting warning that device's HSUM status doesn't match the build's.");
+
+        final String title = mContext
+                .getString(R.string.wrong_hsum_configuration_notification_title);
+        final String message = mContext
+                .getString(R.string.wrong_hsum_configuration_notification_message);
+
+        final Notification notification =
+                new Notification.Builder(mContext, SystemNotificationChannels.DEVELOPER)
+                        .setSmallIcon(R.drawable.stat_sys_adb)
+                        .setWhen(0)
+                        .setOngoing(true)
+                        .setTicker(title)
+                        .setDefaults(0)
+                        .setColor(mContext.getColor(R.color.system_notification_accent_color))
+                        .setContentTitle(title)
+                        .setContentText(message)
+                        .setVisibility(Notification.VISIBILITY_PUBLIC)
+                        .build();
+
+        final NotificationManager notificationManager =
+                mContext.getSystemService(NotificationManager.class);
+        notificationManager.notifyAsUser(
+                null, SystemMessage.NOTE_WRONG_HSUM_STATUS, notification, UserHandle.ALL);
+    }
 
     private ResilientAtomicFile getUserListFile() {
         File tempBackup = new File(mUserListFile.getParent(), mUserListFile.getName() + ".backup");
diff --git a/services/core/java/com/android/server/timezonedetector/OWNERS b/services/core/java/com/android/server/timezonedetector/OWNERS
index dfa07d8..4220d14 100644
--- a/services/core/java/com/android/server/timezonedetector/OWNERS
+++ b/services/core/java/com/android/server/timezonedetector/OWNERS
@@ -1,7 +1,6 @@
 # Bug component: 847766
 # This is the main list for platform time / time zone detection maintainers, for this dir and
 # ultimately referenced by other OWNERS files for components maintained by the same team.
-nfuller@google.com
 boullanger@google.com
 jmorace@google.com
 kanyinsola@google.com
diff --git a/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java b/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java
index b89120b..b846947 100644
--- a/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java
+++ b/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java
@@ -650,9 +650,12 @@
             synchronized (mLock) {
                 if (mLastWallpaper != null) {
                     WallpaperData targetWallpaper = null;
-                    if (mLastWallpaper.connection.containsDisplay(displayId)) {
+                    if (mLastWallpaper.connection != null &&
+                            mLastWallpaper.connection.containsDisplay(displayId)) {
                         targetWallpaper = mLastWallpaper;
-                    } else if (mFallbackWallpaper.connection.containsDisplay(displayId)) {
+                    } else if (mFallbackWallpaper != null &&
+                            mFallbackWallpaper.connection != null &&
+                            mFallbackWallpaper.connection.containsDisplay(displayId)) {
                         targetWallpaper = mFallbackWallpaper;
                     }
                     if (targetWallpaper == null) return;
diff --git a/services/core/java/com/android/server/wm/ActivityRecord.java b/services/core/java/com/android/server/wm/ActivityRecord.java
index 9bc4389..0a384e5 100644
--- a/services/core/java/com/android/server/wm/ActivityRecord.java
+++ b/services/core/java/com/android/server/wm/ActivityRecord.java
@@ -6975,14 +6975,11 @@
         updateReportedVisibilityLocked();
     }
 
-    /**
-     * Sets whether something has been visible in the task and returns {@code true} if the state
-     * is changed from invisible to visible.
-     */
-    private boolean setTaskHasBeenVisible() {
+    /** Sets whether something has been visible in the task. */
+    private void setTaskHasBeenVisible() {
         final boolean wasTaskVisible = task.getHasBeenVisible();
         if (wasTaskVisible) {
-            return false;
+            return;
         }
         if (inTransition()) {
             // The deferring will be canceled until transition is ready so it won't dispatch
@@ -6990,20 +6987,22 @@
             task.setDeferTaskAppear(true);
         }
         task.setHasBeenVisible(true);
-        return true;
     }
 
     void onStartingWindowDrawn() {
-        boolean wasTaskVisible = false;
         if (task != null) {
             mSplashScreenStyleSolidColor = true;
-            wasTaskVisible = !setTaskHasBeenVisible();
+            setTaskHasBeenVisible();
         }
+        if (mStartingData == null || mStartingData.mIsDisplayed) {
+            return;
+        }
+        mStartingData.mIsDisplayed = true;
 
         // The transition may not be executed if the starting process hasn't attached. But if the
         // starting window is drawn, the transition can start earlier. Exclude finishing and bubble
         // because it may be a trampoline.
-        if (!wasTaskVisible && mStartingData != null && !finishing && !mLaunchedFromBubble
+        if (app == null && !finishing && !mLaunchedFromBubble
                 && mVisibleRequested && !mDisplayContent.mAppTransition.isReady()
                 && !mDisplayContent.mAppTransition.isRunning()
                 && mDisplayContent.isNextTransitionForward()) {
@@ -7240,9 +7239,6 @@
                         isInterestingAndDrawn = true;
                     }
                 }
-            } else if (mStartingData != null && w.isDrawn()) {
-                // The starting window for this container is drawn.
-                mStartingData.mIsDisplayed = true;
             }
         }
 
@@ -7526,7 +7522,8 @@
      *               use an icon or solid color splash screen will be made by WmShell.
      */
     private boolean shouldUseSolidColorSplashScreen(ActivityRecord sourceRecord,
-            boolean startActivity, ActivityOptions options, int resolvedTheme) {
+            boolean startActivity, ActivityOptions options, int resolvedTheme,
+            boolean newTask) {
         if (sourceRecord == null && !startActivity) {
             // Use simple style if this activity is not top activity. This could happen when adding
             // a splash screen window to the warm start activity which is re-create because top is
@@ -7549,21 +7546,19 @@
 
         // Choose the default behavior when neither the ActivityRecord nor the activity theme have
         // specified a splash screen style.
-
-        if (mLaunchSourceType == LAUNCH_SOURCE_TYPE_HOME || launchedFromUid == Process.SHELL_UID) {
-            return false;
-        } else if (mLaunchSourceType == LAUNCH_SOURCE_TYPE_SYSTEMUI) {
+        if (mLaunchSourceType == LAUNCH_SOURCE_TYPE_SYSTEMUI) {
             return true;
         } else {
             // Need to check sourceRecord in case this activity is launched from a service.
             if (sourceRecord == null) {
                 sourceRecord = searchCandidateLaunchingActivity();
             }
-
             if (sourceRecord != null) {
-                return sourceRecord.mSplashScreenStyleSolidColor;
+                return sourceRecord.mSplashScreenStyleSolidColor; // follow previous activity
+            } else if (mLaunchSourceType == LAUNCH_SOURCE_TYPE_HOME
+                    || launchedFromUid == Process.SHELL_UID) {
+                return !newTask; // only show icon for new task
             }
-
             // Use an icon if the activity was launched from System for the first start.
             // Otherwise, must use solid color splash screen.
             return mLaunchSourceType != LAUNCH_SOURCE_TYPE_SYSTEM || !startActivity;
@@ -7631,7 +7626,7 @@
                 splashScreenTheme);
 
         mSplashScreenStyleSolidColor = shouldUseSolidColorSplashScreen(sourceRecord, startActivity,
-                startOptions, resolvedTheme);
+                startOptions, resolvedTheme, newTask);
 
         final boolean activityCreated =
                 mState.ordinal() >= STARTED.ordinal() && mState.ordinal() <= STOPPED.ordinal();
@@ -8293,7 +8288,8 @@
      */
     @Override
     protected int getOverrideOrientation() {
-        return mLetterboxUiController.overrideOrientationIfNeeded(super.getOverrideOrientation());
+        return mAppCompatController.getOrientationPolicy()
+                .overrideOrientationIfNeeded(super.getOverrideOrientation());
     }
 
     /**
@@ -10825,7 +10821,8 @@
         proto.write(SHOULD_OVERRIDE_MIN_ASPECT_RATIO,
                 mLetterboxUiController.shouldOverrideMinAspectRatio());
         proto.write(SHOULD_IGNORE_ORIENTATION_REQUEST_LOOP,
-                mLetterboxUiController.shouldIgnoreOrientationRequestLoop());
+                mAppCompatController.getAppCompatCapability().getAppCompatOrientationCapability()
+                        .shouldIgnoreOrientationRequestLoop());
         proto.write(SHOULD_OVERRIDE_FORCE_RESIZE_APP,
                 mLetterboxUiController.shouldOverrideForceResizeApp());
         proto.write(SHOULD_ENABLE_USER_ASPECT_RATIO_SETTINGS,
diff --git a/services/core/java/com/android/server/wm/AppCompatOrientationCapability.java b/services/core/java/com/android/server/wm/AppCompatOrientationCapability.java
index fbe90a2..10f3e83 100644
--- a/services/core/java/com/android/server/wm/AppCompatOrientationCapability.java
+++ b/services/core/java/com/android/server/wm/AppCompatOrientationCapability.java
@@ -38,6 +38,7 @@
 import com.android.server.wm.utils.OptPropFactory;
 
 import java.util.function.BooleanSupplier;
+import java.util.function.LongSupplier;
 
 class AppCompatOrientationCapability {
 
@@ -58,7 +59,8 @@
                                    @NonNull LetterboxConfiguration letterboxConfiguration,
                                    @NonNull ActivityRecord activityRecord) {
         mActivityRecord = activityRecord;
-        mOrientationCapabilityState = new OrientationCapabilityState(mActivityRecord);
+        mOrientationCapabilityState = new OrientationCapabilityState(mActivityRecord,
+                System::currentTimeMillis);
         final BooleanSupplier isPolicyForIgnoringRequestedOrientationEnabled = asLazy(
                 letterboxConfiguration::isPolicyForIgnoringRequestedOrientationEnabled);
         mIgnoreRequestedOrientationOptProp = optPropBuilder.create(
@@ -214,8 +216,12 @@
         private long mTimeMsLastSetOrientationRequest = 0;
         // Counter for ActivityRecord#setRequestedOrientation
         private int mSetOrientationRequestCounter = 0;
+        @VisibleForTesting
+        LongSupplier mCurrentTimeMillisSupplier;
 
-        OrientationCapabilityState(@NonNull ActivityRecord activityRecord) {
+        OrientationCapabilityState(@NonNull ActivityRecord activityRecord,
+                @NonNull LongSupplier currentTimeMillisSupplier) {
+            mCurrentTimeMillisSupplier = currentTimeMillisSupplier;
             mIsOverrideToNosensorOrientationEnabled =
                     activityRecord.info.isChangeEnabled(OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR);
             mIsOverrideToPortraitOrientationEnabled =
@@ -238,7 +244,7 @@
          * Updates the orientation request counter using a specific timeout.
          */
         void updateOrientationRequestLoopState() {
-            final long currTimeMs = System.currentTimeMillis();
+            final long currTimeMs = mCurrentTimeMillisSupplier.getAsLong();
             final long elapsedTime = currTimeMs - mTimeMsLastSetOrientationRequest;
             if (elapsedTime < SET_ORIENTATION_REQUEST_COUNTER_TIMEOUT_MS) {
                 mSetOrientationRequestCounter++;
diff --git a/services/core/java/com/android/server/wm/LetterboxUiController.java b/services/core/java/com/android/server/wm/LetterboxUiController.java
index 17547f5..e0d2035 100644
--- a/services/core/java/com/android/server/wm/LetterboxUiController.java
+++ b/services/core/java/com/android/server/wm/LetterboxUiController.java
@@ -66,7 +66,6 @@
 import android.annotation.Nullable;
 import android.app.ActivityManager.TaskDescription;
 import android.app.CameraCompatTaskInfo.FreeformCameraCompatMode;
-import android.content.pm.ActivityInfo.ScreenOrientation;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.content.res.Resources;
@@ -151,27 +150,6 @@
         }
     }
 
-    /**
-     * Whether an app is calling {@link android.app.Activity#setRequestedOrientation}
-     * in a loop and orientation request should be ignored.
-     *
-     * <p>This should only be called once in response to
-     * {@link android.app.Activity#setRequestedOrientation}. See
-     * {@link #shouldIgnoreRequestedOrientation} for more details.
-     *
-     * <p>This treatment is enabled when the following conditions are met:
-     * <ul>
-     *     <li>Flag gating the treatment is enabled
-     *     <li>Opt-out component property isn't enabled
-     *     <li>Per-app override is enabled
-     *     <li>App has requested orientation more than 2 times within 1-second
-     *     timer and activity is not letterboxed for fixed orientation
-     * </ul>
-     */
-    boolean shouldIgnoreOrientationRequestLoop() {
-        return getAppCompatCapability().getAppCompatOrientationCapability()
-                .shouldIgnoreOrientationRequestLoop();
-    }
 
     @VisibleForTesting
     int getSetOrientationRequestCounter() {
@@ -299,12 +277,6 @@
         return getAppCompatCapability().shouldUseDisplayLandscapeNaturalOrientation();
     }
 
-    @ScreenOrientation
-    int overrideOrientationIfNeeded(@ScreenOrientation int candidate) {
-        return mActivityRecord.mAppCompatController.getOrientationPolicy()
-                .overrideOrientationIfNeeded(candidate);
-    }
-
     boolean isOverrideOrientationOnlyForCameraEnabled() {
         return getAppCompatCapability().isOverrideOrientationOnlyForCameraEnabled();
     }
diff --git a/services/core/java/com/android/server/wm/Transition.java b/services/core/java/com/android/server/wm/Transition.java
index a029f38..2572128 100644
--- a/services/core/java/com/android/server/wm/Transition.java
+++ b/services/core/java/com/android/server/wm/Transition.java
@@ -2545,7 +2545,8 @@
 
             final ChangeInfo changeInfo = changes.get(wc);
             // Reject no-ops, unless wallpaper
-            if (!changeInfo.hasChanged() && wc.asWallpaperToken() == null) {
+            if (!changeInfo.hasChanged()
+                    && (!Flags.ensureWallpaperInTransitions() || wc.asWallpaperToken() == null)) {
                 ProtoLog.v(ProtoLogGroup.WM_DEBUG_WINDOW_TRANSITIONS,
                         "  Rejecting as no-op: %s", wc);
                 continue;
diff --git a/services/core/java/com/android/server/wm/WallpaperController.java b/services/core/java/com/android/server/wm/WallpaperController.java
index a3e0302..86440ac 100644
--- a/services/core/java/com/android/server/wm/WallpaperController.java
+++ b/services/core/java/com/android/server/wm/WallpaperController.java
@@ -60,6 +60,7 @@
 import com.android.internal.protolog.common.ProtoLog;
 import com.android.internal.util.ToBooleanFunction;
 import com.android.server.wallpaper.WallpaperCropper.WallpaperCropUtils;
+import com.android.window.flags.Flags;
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -764,10 +765,19 @@
 
     void collectTopWallpapers(Transition transition) {
         if (mFindResults.hasTopShowWhenLockedWallpaper()) {
-            transition.collect(mFindResults.mTopWallpaper.mTopShowWhenLockedWallpaper.mToken);
+            if (Flags.ensureWallpaperInTransitions()) {
+                transition.collect(mFindResults.mTopWallpaper.mTopShowWhenLockedWallpaper.mToken);
+            } else {
+                transition.collect(mFindResults.mTopWallpaper.mTopShowWhenLockedWallpaper);
+            }
+
         }
         if (mFindResults.hasTopHideWhenLockedWallpaper()) {
-            transition.collect(mFindResults.mTopWallpaper.mTopHideWhenLockedWallpaper.mToken);
+            if (Flags.ensureWallpaperInTransitions()) {
+                transition.collect(mFindResults.mTopWallpaper.mTopHideWhenLockedWallpaper.mToken);
+            } else {
+                transition.collect(mFindResults.mTopWallpaper.mTopHideWhenLockedWallpaper);
+            }
         }
     }
 
diff --git a/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/DefaultImeVisibilityApplierTest.java b/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/DefaultImeVisibilityApplierTest.java
index a4ca317..d4adba2 100644
--- a/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/DefaultImeVisibilityApplierTest.java
+++ b/services/tests/InputMethodSystemServerTests/src/com/android/server/inputmethod/DefaultImeVisibilityApplierTest.java
@@ -87,7 +87,7 @@
         synchronized (ImfLock.class) {
             mVisibilityApplier.performShowIme(new Binder() /* showInputToken */,
                     ImeTracker.Token.empty(), 0 /* showFlags */, null /* resultReceiver */,
-                    SHOW_SOFT_INPUT);
+                    SHOW_SOFT_INPUT, mUserId);
         }
         verifyShowSoftInput(false, true, 0 /* showFlags */);
     }
@@ -96,7 +96,7 @@
     public void testPerformHideIme() throws Exception {
         synchronized (ImfLock.class) {
             mVisibilityApplier.performHideIme(new Binder() /* hideInputToken */,
-                    ImeTracker.Token.empty(), null /* resultReceiver */, HIDE_SOFT_INPUT);
+                    ImeTracker.Token.empty(), null /* resultReceiver */, HIDE_SOFT_INPUT, mUserId);
         }
         verifyHideSoftInput(false, true);
     }
@@ -186,7 +186,7 @@
     @Test
     public void testShowImeScreenshot() {
         synchronized (ImfLock.class) {
-            mVisibilityApplier.showImeScreenshot(mWindowToken, Display.DEFAULT_DISPLAY);
+            mVisibilityApplier.showImeScreenshot(mWindowToken, Display.DEFAULT_DISPLAY, mUserId);
         }
 
         verify(mMockImeTargetVisibilityPolicy).showImeScreenshot(eq(mWindowToken),
@@ -196,7 +196,7 @@
     @Test
     public void testRemoveImeScreenshot() {
         synchronized (ImfLock.class) {
-            mVisibilityApplier.removeImeScreenshot(Display.DEFAULT_DISPLAY);
+            mVisibilityApplier.removeImeScreenshot(Display.DEFAULT_DISPLAY, mUserId);
         }
 
         verify(mMockImeTargetVisibilityPolicy).removeImeScreenshot(eq(Display.DEFAULT_DISPLAY));
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeConfigTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeConfigTest.java
index 9352c12..f5ab95c 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeConfigTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeConfigTest.java
@@ -511,6 +511,9 @@
         rule.iconResName = ICON_RES_NAME;
         rule.triggerDescription = TRIGGER_DESC;
         rule.deletionInstant = Instant.ofEpochMilli(1701790147000L);
+        if (Flags.modesUi()) {
+            rule.disabledOrigin = ZenModeConfig.UPDATE_ORIGIN_USER;
+        }
 
         Parcel parcel = Parcel.obtain();
         rule.writeToParcel(parcel, 0);
@@ -540,6 +543,9 @@
         assertEquals(rule.triggerDescription, parceled.triggerDescription);
         assertEquals(rule.zenPolicy, parceled.zenPolicy);
         assertEquals(rule.deletionInstant, parceled.deletionInstant);
+        if (Flags.modesUi()) {
+            assertEquals(rule.disabledOrigin, parceled.disabledOrigin);
+        }
 
         assertEquals(rule, parceled);
         assertEquals(rule.hashCode(), parceled.hashCode());
@@ -620,6 +626,9 @@
         rule.iconResName = ICON_RES_NAME;
         rule.triggerDescription = TRIGGER_DESC;
         rule.deletionInstant = Instant.ofEpochMilli(1701790147000L);
+        if (Flags.modesUi()) {
+            rule.disabledOrigin = ZenModeConfig.UPDATE_ORIGIN_APP;
+        }
 
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
         writeRuleXml(rule, baos);
@@ -653,6 +662,9 @@
         assertEquals(rule.triggerDescription, fromXml.triggerDescription);
         assertEquals(rule.iconResName, fromXml.iconResName);
         assertEquals(rule.deletionInstant, fromXml.deletionInstant);
+        if (Flags.modesUi()) {
+            assertEquals(rule.disabledOrigin, fromXml.disabledOrigin);
+        }
     }
 
     @Test
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java
index 26a13cb..57587f7 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java
@@ -16,6 +16,7 @@
 
 package com.android.server.notification;
 
+import static android.app.Flags.FLAG_MODES_API;
 import static android.app.Flags.FLAG_MODES_UI;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -32,6 +33,7 @@
 import android.app.NotificationManager;
 import android.content.ComponentName;
 import android.net.Uri;
+import android.platform.test.annotations.EnableFlags;
 import android.platform.test.flag.junit.FlagsParameterization;
 import android.platform.test.flag.junit.SetFlagsRule;
 import android.provider.Settings;
@@ -79,16 +81,17 @@
                     : Set.of("version", "manualRule", "automaticRules");
 
     // Differences for flagged fields are only generated if the flag is enabled.
-    // "Metadata" fields (userModifiedFields & co, deletionInstant) are not compared.
+    // "Metadata" fields (userModifiedFields, deletionInstant, disabledOrigin) are not compared.
     private static final Set<String> ZEN_RULE_EXEMPT_FIELDS =
             android.app.Flags.modesApi()
                     ? Set.of("userModifiedFields", "zenPolicyUserModifiedFields",
-                            "zenDeviceEffectsUserModifiedFields", "deletionInstant")
+                            "zenDeviceEffectsUserModifiedFields", "deletionInstant",
+                            "disabledOrigin")
                     : Set.of(RuleDiff.FIELD_TYPE, RuleDiff.FIELD_TRIGGER_DESCRIPTION,
                             RuleDiff.FIELD_ICON_RES, RuleDiff.FIELD_ALLOW_MANUAL,
                             RuleDiff.FIELD_ZEN_DEVICE_EFFECTS, "userModifiedFields",
                             "zenPolicyUserModifiedFields", "zenDeviceEffectsUserModifiedFields",
-                            "deletionInstant");
+                            "deletionInstant", "disabledOrigin");
 
     // allowPriorityChannels is flagged by android.app.modes_api
     public static final Set<String> ZEN_MODE_CONFIG_FLAGGED_FIELDS =
@@ -201,8 +204,8 @@
     }
 
     @Test
+    @EnableFlags(FLAG_MODES_API)
     public void testConfigDiff_fieldDiffs_flagOn() throws Exception {
-        mSetFlagsRule.enableFlags(Flags.FLAG_MODES_API);
         // these two start the same
         ZenModeConfig c1 = new ZenModeConfig();
         ZenModeConfig c2 = new ZenModeConfig();
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
index 40b0d78..7bb633e 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
@@ -6226,6 +6226,101 @@
         assertThat(mZenModeHelper.getConfig().manualRule.zenDeviceEffects).isEqualTo(effects);
     }
 
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void addAutomaticZenRule_startsDisabled_recordsDisabledOrigin() {
+        AutomaticZenRule startsDisabled = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setOwner(new ComponentName(mPkg, "SomeProvider"))
+                .setEnabled(false)
+                .build();
+
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, startsDisabled, UPDATE_ORIGIN_APP,
+                "new", CUSTOM_PKG_UID);
+
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_APP);
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void updateAutomaticZenRule_disabling_recordsDisabledOrigin() {
+        AutomaticZenRule startsEnabled = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setOwner(new ComponentName(mPkg, "SomeProvider"))
+                .setEnabled(true)
+                .build();
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, startsEnabled, UPDATE_ORIGIN_APP,
+                "new", CUSTOM_PKG_UID);
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_UNKNOWN);
+
+        AutomaticZenRule nowDisabled = new AutomaticZenRule.Builder(startsEnabled)
+                .setEnabled(false)
+                .build();
+        mZenModeHelper.updateAutomaticZenRule(ruleId, nowDisabled, UPDATE_ORIGIN_USER, "off",
+                Process.SYSTEM_UID);
+
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_USER);
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void updateAutomaticZenRule_keepingDisabled_preservesPreviousDisabledOrigin() {
+        AutomaticZenRule startsEnabled = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setOwner(new ComponentName(mPkg, "SomeProvider"))
+                .setEnabled(true)
+                .build();
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, startsEnabled, UPDATE_ORIGIN_APP,
+                "new", CUSTOM_PKG_UID);
+        AutomaticZenRule nowDisabled = new AutomaticZenRule.Builder(startsEnabled)
+                .setEnabled(false)
+                .build();
+        mZenModeHelper.updateAutomaticZenRule(ruleId, nowDisabled, UPDATE_ORIGIN_USER, "off",
+                Process.SYSTEM_UID);
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_USER);
+
+        // Now update it again, for an unrelated reason with a different origin.
+        AutomaticZenRule nowRenamed = new AutomaticZenRule.Builder(nowDisabled)
+                .setName("Fancy pants rule")
+                .build();
+        mZenModeHelper.updateAutomaticZenRule(ruleId, nowRenamed, UPDATE_ORIGIN_APP, "update",
+                CUSTOM_PKG_UID);
+
+        // Identity of the disabler is preserved.
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_USER);
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void updateAutomaticZenRule_enabling_clearsDisabledOrigin() {
+        AutomaticZenRule startsEnabled = new AutomaticZenRule.Builder("Rule", Uri.EMPTY)
+                .setOwner(new ComponentName(mPkg, "SomeProvider"))
+                .setEnabled(true)
+                .build();
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, startsEnabled, UPDATE_ORIGIN_APP,
+                "new", CUSTOM_PKG_UID);
+        AutomaticZenRule nowDisabled = new AutomaticZenRule.Builder(startsEnabled)
+                .setEnabled(false)
+                .build();
+        mZenModeHelper.updateAutomaticZenRule(ruleId, nowDisabled, UPDATE_ORIGIN_USER, "off",
+                Process.SYSTEM_UID);
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_USER);
+
+        // Now enable it again
+        AutomaticZenRule nowEnabled = new AutomaticZenRule.Builder(nowDisabled)
+                .setEnabled(true)
+                .build();
+        mZenModeHelper.updateAutomaticZenRule(ruleId, nowEnabled, UPDATE_ORIGIN_APP, "on",
+                CUSTOM_PKG_UID);
+
+        // Identity of the disabler was cleared.
+        assertThat(mZenModeHelper.mConfig.automaticRules.get(ruleId).disabledOrigin).isEqualTo(
+                UPDATE_ORIGIN_UNKNOWN);
+    }
+
     private static void addZenRule(ZenModeConfig config, String id, String ownerPkg, int zenMode,
             @Nullable ZenPolicy zenPolicy) {
         ZenRule rule = new ZenRule();
diff --git a/services/tests/wmtests/src/com/android/server/wm/ActivityRecordTests.java b/services/tests/wmtests/src/com/android/server/wm/ActivityRecordTests.java
index d143297..44cabac 100644
--- a/services/tests/wmtests/src/com/android/server/wm/ActivityRecordTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/ActivityRecordTests.java
@@ -3098,6 +3098,30 @@
     }
 
     @Test
+    public void testOnStartingWindowDrawn() {
+        final ActivityRecord activity = new ActivityBuilder(mAtm).setCreateTask(true).build();
+        // The task-has-been-visible should not affect the decision of making transition ready.
+        activity.getTask().setHasBeenVisible(true);
+        activity.detachFromProcess();
+        activity.mStartingData = mock(StartingData.class);
+        registerTestTransitionPlayer();
+        final Transition transition = activity.mTransitionController.requestTransitionIfNeeded(
+                WindowManager.TRANSIT_OPEN, 0 /* flags */, null /* trigger */, mDisplayContent);
+        activity.onStartingWindowDrawn();
+        assertTrue(activity.mStartingData.mIsDisplayed);
+        // The transition can be ready by the starting window of a visible-requested activity
+        // without a running process.
+        assertTrue(transition.allReady());
+
+        // If other event makes the transition unready, the reentrant of onStartingWindowDrawn
+        // should not replace the readiness again.
+        transition.setReady(mDisplayContent, false);
+        activity.onStartingWindowDrawn();
+        assertFalse(transition.allReady());
+    }
+
+
+    @Test
     public void testCloseToSquareFixedOrientation() {
         if (Flags.insetsDecoupledConfiguration()) {
             // No test needed as decor insets no longer affects orientation.
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationCapabilityTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationCapabilityTest.java
new file mode 100644
index 0000000..f1cf866
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationCapabilityTest.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.wm;
+
+import static android.content.pm.ActivityInfo.OVERRIDE_CAMERA_COMPAT_DISABLE_REFRESH;
+import static android.content.pm.ActivityInfo.OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED;
+import static android.content.pm.ActivityInfo.OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
+import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_IGNORING_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED;
+import static android.view.WindowManager.PROPERTY_COMPAT_IGNORE_REQUESTED_ORIENTATION;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+import static com.android.server.wm.AppCompatOrientationCapability.OrientationCapabilityState.MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP;
+import static com.android.server.wm.AppCompatOrientationCapability.OrientationCapabilityState.SET_ORIENTATION_REQUEST_COUNTER_TIMEOUT_MS;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+
+import android.compat.testing.PlatformCompatChangeRule;
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.wm.utils.TestComponentStack;
+
+import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+import org.junit.runner.RunWith;
+
+import java.util.function.Consumer;
+import java.util.function.IntConsumer;
+import java.util.function.LongSupplier;
+
+/**
+ * Test class for {@link AppCompatOrientationCapability}.
+ * <p>
+ * Build/Install/Run:
+ * atest WmTests:AppCompatOrientationCapabilityTest
+ */
+@Presubmit
+@RunWith(WindowTestRunner.class)
+public class AppCompatOrientationCapabilityTest extends WindowTestsBase {
+
+    @Rule
+    public TestRule compatChangeRule = new PlatformCompatChangeRule();
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION})
+    public void testShouldIgnoreRequestedOrientation_activityRelaunching_returnsTrue() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareRelaunchingAfterRequestedOrientationChanged(true);
+
+            robot.checkShouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION})
+    public void testShouldIgnoreRequestedOrientation_cameraCompatTreatment_returnsTrue() {
+        runTestScenario((robot) -> {
+            robot.prepareIsCameraCompatTreatmentEnabled(true);
+            robot.prepareIsCameraCompatTreatmentEnabledAtBuildTime(true);
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+
+            robot.createActivityWithComponentInNewTask();
+            robot.prepareRelaunchingAfterRequestedOrientationChanged(false);
+            robot.prepareIsTreatmentEnabledForTopActivity(true);
+
+            robot.checkShouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    public void testShouldIgnoreRequestedOrientation_overrideDisabled_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+
+            robot.createActivityWithComponent();
+            robot.prepareRelaunchingAfterRequestedOrientationChanged(true);
+
+            robot.checkShouldNotIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    public void testShouldIgnoreRequestedOrientation_propertyIsTrue_returnsTrue() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.enableProperty(PROPERTY_COMPAT_IGNORE_REQUESTED_ORIENTATION);
+
+            robot.createActivityWithComponent();
+            robot.prepareRelaunchingAfterRequestedOrientationChanged(true);
+
+            robot.checkShouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION})
+    public void testShouldIgnoreRequestedOrientation_propertyIsFalseAndOverride_returnsFalse()
+            throws Exception {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.disableProperty(PROPERTY_COMPAT_IGNORE_REQUESTED_ORIENTATION);
+
+            robot.createActivityWithComponent();
+            robot.prepareRelaunchingAfterRequestedOrientationChanged(true);
+
+            robot.checkShouldNotIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    public void testShouldIgnoreOrientationRequestLoop_overrideDisabled_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(false);
+
+            robot.checkRequestLoopExtended((i) -> {
+                robot.checkShouldNotIgnoreOrientationLoop();
+                robot.checkExpectedLoopCount(/* expectedCount */ 0);
+            });
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
+    public void testShouldIgnoreOrientationRequestLoop_propertyIsFalseAndOverride_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.disableProperty(
+                    PROPERTY_COMPAT_ALLOW_IGNORING_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(false);
+
+            robot.checkRequestLoopExtended((i) -> {
+                robot.checkShouldNotIgnoreOrientationLoop();
+                robot.checkExpectedLoopCount(/* expectedCount */ 0);
+            });
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
+    public void testShouldIgnoreOrientationRequestLoop_isLetterboxed_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(true);
+
+            robot.checkRequestLoopExtended((i) -> {
+                robot.checkShouldNotIgnoreOrientationLoop();
+                robot.checkExpectedLoopCount(/* expectedCount */ i);
+            });
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
+    public void testShouldIgnoreOrientationRequestLoop_noLoop_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(false);
+
+            robot.checkShouldNotIgnoreOrientationLoop();
+            robot.checkExpectedLoopCount(/* expectedCount */ 0);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
+    public void testShouldIgnoreOrientationRequestLoop_timeout_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(false);
+
+            robot.prepareMockedTime();
+            robot.checkRequestLoopExtended((i) -> {
+                robot.checkShouldNotIgnoreOrientationLoop();
+                robot.checkExpectedLoopCount(/* expectedCount */ 0);
+                robot.delay();
+            });
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
+    public void testShouldIgnoreOrientationRequestLoop_returnsTrue() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(false);
+
+            robot.checkRequestLoop((i) -> {
+                robot.checkShouldNotIgnoreOrientationLoop();
+                robot.checkExpectedLoopCount(/* expectedCount */ i);
+            });
+            robot.checkShouldIgnoreOrientationLoop();
+            robot.checkExpectedLoopCount(/* expectedCount */ MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_CAMERA_COMPAT_DISABLE_REFRESH})
+    public void testShouldIgnoreRequestedOrientation_flagIsDisabled_returnsFalse() {
+        runTestScenario((robot) -> {
+            robot.prepareIsPolicyForIgnoringRequestedOrientationEnabled(true);
+            robot.createActivityWithComponent();
+            robot.prepareIsLetterboxedForFixedOrientationAndAspectRatio(false);
+
+            robot.checkShouldNotIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    /**
+     * Runs a test scenario providing a Robot.
+     */
+    void runTestScenario(@NonNull Consumer<OrientationCapabilityRobotTest> consumer) {
+        spyOn(mWm.mLetterboxConfiguration);
+        final OrientationCapabilityRobotTest robot =
+                new OrientationCapabilityRobotTest(mWm, mAtm, mSupervisor);
+        consumer.accept(robot);
+    }
+
+    private static class OrientationCapabilityRobotTest {
+
+        @NonNull
+        private final ActivityTaskManagerService mAtm;
+        @NonNull
+        private final WindowManagerService mWm;
+        @NonNull
+        private final ActivityTaskSupervisor mSupervisor;
+        @NonNull
+        private final LetterboxConfiguration mLetterboxConfiguration;
+        @NonNull
+        private final TestComponentStack<ActivityRecord> mActivityStack;
+        @NonNull
+        private final TestComponentStack<Task> mTaskStack;
+        @NonNull
+        private final CurrentTimeMillisSupplierTest mTestCurrentTimeMillisSupplier;
+
+
+        OrientationCapabilityRobotTest(@NonNull WindowManagerService wm,
+                @NonNull ActivityTaskManagerService atm,
+                @NonNull ActivityTaskSupervisor supervisor) {
+            mAtm = atm;
+            mWm = wm;
+            mSupervisor = supervisor;
+            mActivityStack = new TestComponentStack<>();
+            mTaskStack = new TestComponentStack<>();
+            mLetterboxConfiguration = mWm.mLetterboxConfiguration;
+            mTestCurrentTimeMillisSupplier = new CurrentTimeMillisSupplierTest();
+        }
+
+        void prepareRelaunchingAfterRequestedOrientationChanged(boolean enabled) {
+            getTopOrientationCapability().setRelaunchingAfterRequestedOrientationChanged(enabled);
+        }
+
+        void prepareIsPolicyForIgnoringRequestedOrientationEnabled(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration)
+                    .isPolicyForIgnoringRequestedOrientationEnabled();
+        }
+
+        void prepareIsCameraCompatTreatmentEnabled(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
+        }
+
+        void prepareIsCameraCompatTreatmentEnabledAtBuildTime(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration)
+                    .isCameraCompatTreatmentEnabledAtBuildTime();
+        }
+
+        void prepareIsTreatmentEnabledForTopActivity(boolean enabled) {
+            final DisplayRotationCompatPolicy displayPolicy = mActivityStack.top()
+                    .mDisplayContent.mDisplayRotationCompatPolicy;
+            spyOn(displayPolicy);
+            doReturn(enabled).when(displayPolicy)
+                    .isTreatmentEnabledForActivity(eq(mActivityStack.top()));
+        }
+
+        // Useful to reduce timeout during tests
+        void prepareMockedTime() {
+            getTopOrientationCapability().mOrientationCapabilityState.mCurrentTimeMillisSupplier =
+                    mTestCurrentTimeMillisSupplier;
+        }
+
+        void delay() {
+            mTestCurrentTimeMillisSupplier.delay(SET_ORIENTATION_REQUEST_COUNTER_TIMEOUT_MS);
+        }
+
+        void enableProperty(@NonNull String propertyName) {
+            setPropertyValue(propertyName, /* enabled */ true);
+        }
+
+        void disableProperty(@NonNull String propertyName) {
+            setPropertyValue(propertyName, /* enabled */ false);
+        }
+
+        void prepareIsLetterboxedForFixedOrientationAndAspectRatio(boolean enabled) {
+            spyOn(mActivityStack.top());
+            doReturn(enabled).when(mActivityStack.top())
+                    .isLetterboxedForFixedOrientationAndAspectRatio();
+        }
+
+        void createActivityWithComponent() {
+            createActivityWithComponentInNewTask(/* inNewTask */ mTaskStack.isEmpty());
+        }
+
+        void createActivityWithComponentInNewTask() {
+            createActivityWithComponentInNewTask(/* inNewTask */ true);
+        }
+
+        private void createActivityWithComponentInNewTask(boolean inNewTask) {
+            if (inNewTask) {
+                createNewTask();
+            }
+            final ActivityRecord activity = new ActivityBuilder(mAtm)
+                    .setOnTop(true)
+                    .setTask(mTaskStack.top())
+                    // Set the component to be that of the test class in order
+                    // to enable compat changes
+                    .setComponent(ComponentName.createRelative(mAtm.mContext,
+                            com.android.server.wm.LetterboxUiControllerTest.class.getName()))
+                    .build();
+            mActivityStack.push(activity);
+        }
+
+        void checkShouldIgnoreRequestedOrientation(
+                @Configuration.Orientation int expectedOrientation) {
+            assertTrue(getTopOrientationCapability()
+                    .shouldIgnoreRequestedOrientation(expectedOrientation));
+        }
+
+        void checkShouldNotIgnoreRequestedOrientation(
+                @Configuration.Orientation int expectedOrientation) {
+            assertFalse(getTopOrientationCapability()
+                    .shouldIgnoreRequestedOrientation(expectedOrientation));
+        }
+
+        void checkExpectedLoopCount(int expectedCount) {
+            assertEquals(expectedCount, getTopOrientationCapability()
+                    .getSetOrientationRequestCounter());
+        }
+
+        void checkShouldNotIgnoreOrientationLoop() {
+            assertFalse(getTopOrientationCapability().shouldIgnoreOrientationRequestLoop());
+        }
+
+        void checkShouldIgnoreOrientationLoop() {
+            assertTrue(getTopOrientationCapability().shouldIgnoreOrientationRequestLoop());
+        }
+
+        void checkRequestLoop(IntConsumer consumer) {
+            for (int i = 0; i < MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i++) {
+                consumer.accept(i);
+            }
+        }
+
+        void checkRequestLoopExtended(IntConsumer consumer) {
+            for (int i = 0; i <= MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i++) {
+                consumer.accept(i);
+            }
+        }
+
+        private AppCompatOrientationCapability getTopOrientationCapability() {
+            return mActivityStack.top().mAppCompatController.getAppCompatCapability()
+                    .getAppCompatOrientationCapability();
+        }
+
+        private void createNewTask() {
+            final DisplayContent displayContent = new TestDisplayContent
+                    .Builder(mAtm, /* dw */ 1000, /* dh */ 2000).build();
+            final Task newTask = new TaskBuilder(mSupervisor).setDisplay(displayContent).build();
+            mTaskStack.push(newTask);
+        }
+
+        private void setPropertyValue(@NonNull String propertyName, boolean enabled) {
+            PackageManager.Property property = new PackageManager.Property(propertyName,
+                    /* value */ enabled, /* packageName */ "",
+                    /* className */ "");
+            PackageManager pm = mWm.mContext.getPackageManager();
+            spyOn(pm);
+            try {
+                doReturn(property).when(pm).getProperty(eq(propertyName), anyString());
+            } catch (PackageManager.NameNotFoundException e) {
+                fail(e.getLocalizedMessage());
+            }
+        }
+
+        private static class CurrentTimeMillisSupplierTest implements LongSupplier {
+
+            private long mCurrenTimeMillis = System.currentTimeMillis();
+
+            @Override
+            public long getAsLong() {
+                return mCurrenTimeMillis;
+            }
+
+            public void delay(long delay) {
+                mCurrenTimeMillis += delay;
+            }
+        }
+    }
+}
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java
new file mode 100644
index 0000000..2260999
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java
@@ -0,0 +1,570 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.wm;
+
+import static android.content.pm.ActivityInfo.OVERRIDE_ANY_ORIENTATION;
+import static android.content.pm.ActivityInfo.OVERRIDE_ANY_ORIENTATION_TO_USER;
+import static android.content.pm.ActivityInfo.OVERRIDE_LANDSCAPE_ORIENTATION_TO_REVERSE_LANDSCAPE;
+import static android.content.pm.ActivityInfo.OVERRIDE_ORIENTATION_ONLY_FOR_CAMERA;
+import static android.content.pm.ActivityInfo.OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR;
+import static android.content.pm.ActivityInfo.OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LOCKED;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER;
+import static android.content.pm.PackageManager.USER_MIN_ASPECT_RATIO_3_2;
+import static android.content.pm.PackageManager.USER_MIN_ASPECT_RATIO_FULLSCREEN;
+import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE;
+import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_USER_ASPECT_RATIO_FULLSCREEN_OVERRIDE;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+
+import android.compat.testing.PlatformCompatChangeRule;
+import android.content.ComponentName;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.wm.utils.TestComponentStack;
+
+import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+import org.junit.runner.RunWith;
+
+import java.util.function.Consumer;
+
+/**
+ * Test class for {@link AppCompatOrientationPolicy}.
+ * <p>
+ * Build/Install/Run:
+ * atest WmTests:AppCompatOrientationPolicyTest
+ */
+@Presubmit
+@RunWith(WindowTestRunner.class)
+public class AppCompatOrientationPolicyTest extends WindowTestsBase {
+
+    @Rule
+    public TestRule compatChangeRule = new PlatformCompatChangeRule();
+
+    @Test
+    public void testOverrideOrientationIfNeeded_mapInvokedOnRequest() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.overrideOrientationIfNeeded(SCREEN_ORIENTATION_PORTRAIT);
+            robot.checkOrientationRequestMapped();
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
+    public void testOverrideOrientationIfNeeded_fullscreenOverrideEnabled_returnsUser() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.configureSetIgnoreOrientationRequest(true);
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
+    public void testOverrideOrientationIfNeeded_fullscreenOverrideEnabled_optOut_isUnchanged() {
+        runTestScenario((robot) -> {
+            robot.disableProperty(PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE);
+            robot.createActivityWithComponent();
+            robot.configureSetIgnoreOrientationRequest(true);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
+    public void testOverrideOrientationIfNeeded_fullscreenOverrides_optOutSystem_returnsUser() {
+        runTestScenario((robot) -> {
+            robot.disableProperty(PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE);
+            robot.configureIsUserAppAspectRatioFullscreenEnabled(true);
+
+            robot.createActivityWithComponent();
+            robot.configureSetIgnoreOrientationRequest(true);
+            robot.prepareGetUserMinAspectRatioOverrideCode(USER_MIN_ASPECT_RATIO_FULLSCREEN);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
+    public void testOverrideOrientationIfNeeded_fullscreenOverrides_optOutUser_returnsUser() {
+        runTestScenario((robot) -> {
+            robot.disableProperty(PROPERTY_COMPAT_ALLOW_USER_ASPECT_RATIO_FULLSCREEN_OVERRIDE);
+            robot.configureIsUserAppAspectRatioFullscreenEnabled(true);
+
+            robot.createActivityWithComponent();
+            robot.configureSetIgnoreOrientationRequest(true);
+            robot.prepareGetUserMinAspectRatioOverrideCode(USER_MIN_ASPECT_RATIO_FULLSCREEN);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
+    public void testOverrideOrientationIfNeeded_fullscreenOverrideEnabled_returnsUnchanged()
+            throws Exception {
+        runTestScenarioWithActivity((robot) -> {
+            robot.configureSetIgnoreOrientationRequest(false);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
+    public void testOverrideOrientationIfNeeded_fullscreenAndUserOverrideEnabled_isUnchanged() {
+        runTestScenario((robot) -> {
+            robot.prepareIsUserAppAspectRatioSettingsEnabled(true);
+
+            robot.createActivityWithComponent();
+            robot.configureSetIgnoreOrientationRequest(true);
+            robot.prepareGetUserMinAspectRatioOverrideCode(USER_MIN_ASPECT_RATIO_3_2);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT})
+    public void testOverrideOrientationIfNeeded_portraitOverrideEnabled_returnsPortrait() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(
+                    /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR})
+    public void testOverrideOrientationIfNeeded_portraitOverrideEnabled_returnsNosensor() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(
+                    /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_NOSENSOR);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR})
+    public void testOverrideOrientationIfNeeded_nosensorOverride_orientationFixed_isUnchanged() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(
+                    /* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_LANDSCAPE_ORIENTATION_TO_REVERSE_LANDSCAPE})
+    public void testOverrideOrientationIfNeeded_reverseLandscape_portraitOrUndefined_isUnchanged() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(
+                    /* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+            robot.checkOverrideOrientation(
+                    /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_LANDSCAPE_ORIENTATION_TO_REVERSE_LANDSCAPE})
+    public void testOverrideOrientationIfNeeded_reverseLandscape_Landscape_getsReverseLandscape() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_LANDSCAPE,
+                    /* expected */ SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT})
+    public void testOverrideOrientationIfNeeded_portraitOverride_orientationFixed_IsUnchanged() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_NOSENSOR,
+                    /* expected */ SCREEN_ORIENTATION_NOSENSOR);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT, OVERRIDE_ANY_ORIENTATION})
+    public void testOverrideOrientationIfNeeded_portraitAndIgnoreFixedOverrides_returnsPortrait() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_NOSENSOR,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR, OVERRIDE_ANY_ORIENTATION})
+    public void testOverrideOrientationIfNeeded_noSensorAndIgnoreFixedOverrides_returnsNosensor() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_NOSENSOR);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT})
+    public void testOverrideOrientationIfNeeded_propertyIsFalse_isUnchanged()
+            throws Exception {
+        runTestScenario((robot) -> {
+            robot.disableProperty(PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE);
+
+            robot.createActivityWithComponent();
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT,
+            OVERRIDE_ORIENTATION_ONLY_FOR_CAMERA})
+    public void testOverrideOrientationIfNeeded_whenCameraNotActive_isUnchanged() {
+        runTestScenario((robot) -> {
+            robot.configureIsCameraCompatTreatmentEnabled(true);
+            robot.configureIsCameraCompatTreatmentEnabledAtBuildTime(true);
+
+            robot.createActivityWithComponentInNewTask();
+            robot.prepareIsTopActivityEligibleForOrientationOverride(false);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT,
+            OVERRIDE_ORIENTATION_ONLY_FOR_CAMERA})
+    public void testOverrideOrientationIfNeeded_whenCameraActive_returnsPortrait() {
+        runTestScenario((robot) -> {
+            robot.configureIsCameraCompatTreatmentEnabled(true);
+            robot.configureIsCameraCompatTreatmentEnabledAtBuildTime(true);
+
+            robot.createActivityWithComponentInNewTask();
+            robot.prepareIsTopActivityEligibleForOrientationOverride(true);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    public void testOverrideOrientationIfNeeded_userFullscreenOverride_returnsUser() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserFullscreenOverride(true);
+            robot.configureSetIgnoreOrientationRequest(true);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    public void testOverrideOrientationIfNeeded_fullscreenOverride_cameraActivity_unchanged() {
+        runTestScenario((robot) -> {
+            robot.configureIsCameraCompatTreatmentEnabled(true);
+            robot.configureIsCameraCompatTreatmentEnabledAtBuildTime(true);
+
+            robot.createActivityWithComponentInNewTask();
+            robot.configureIsTopActivityCameraActive(false);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    public void testOverrideOrientationIfNeeded_respectOrientationRequestOverUserFullScreen() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserFullscreenOverride(true);
+            robot.configureSetIgnoreOrientationRequest(false);
+
+            robot.checkOverrideOrientationIsNot(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* notExpected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT, OVERRIDE_ANY_ORIENTATION})
+    public void testOverrideOrientationIfNeeded_userFullScreenOverrideOverSystem_returnsUser() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserFullscreenOverride(true);
+            robot.configureSetIgnoreOrientationRequest(true);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT, OVERRIDE_ANY_ORIENTATION})
+    public void testOverrideOrientationIfNeeded_respectOrientationReqOverUserFullScreenAndSystem() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserFullscreenOverride(true);
+            robot.configureSetIgnoreOrientationRequest(false);
+
+            robot.checkOverrideOrientationIsNot(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* notExpected */ SCREEN_ORIENTATION_USER);
+        });
+    }
+
+    @Test
+    public void testOverrideOrientationIfNeeded_userFullScreenOverrideDisabled_returnsUnchanged() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserFullscreenOverride(false);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_PORTRAIT,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+        });
+    }
+
+    @Test
+    public void testOverrideOrientationIfNeeded_userAspectRatioApplied_unspecifiedOverridden() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserMinAspectRatioOverride(true);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_LOCKED,
+                    /* expected */ SCREEN_ORIENTATION_PORTRAIT);
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_LANDSCAPE,
+                    /* expected */ SCREEN_ORIENTATION_LANDSCAPE);
+        });
+    }
+
+    @Test
+    public void testOverrideOrientationIfNeeded_userAspectRatioNotApplied_isUnchanged() {
+        runTestScenarioWithActivity((robot) -> {
+            robot.prepareShouldApplyUserFullscreenOverride(false);
+
+            robot.checkOverrideOrientation(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED,
+                    /* expected */ SCREEN_ORIENTATION_UNSPECIFIED);
+        });
+    }
+
+
+    /**
+     * Runs a test scenario with an existing activity providing a Robot.
+     */
+    void runTestScenarioWithActivity(@NonNull Consumer<OrientationPolicyRobotTest> consumer) {
+        runTestScenario(/* withActivity */ true, consumer);
+    }
+
+    /**
+     * Runs a test scenario without an existing activity providing a Robot.
+     */
+    void runTestScenario(@NonNull Consumer<OrientationPolicyRobotTest> consumer) {
+        runTestScenario(/* withActivity */ false, consumer);
+    }
+
+    /**
+     * Runs a test scenario providing a Robot.
+     */
+    void runTestScenario(boolean withActivity,
+                         @NonNull Consumer<OrientationPolicyRobotTest> consumer) {
+        spyOn(mWm.mLetterboxConfiguration);
+        final OrientationPolicyRobotTest robot =
+                new OrientationPolicyRobotTest(mWm, mAtm, mSupervisor, withActivity);
+        consumer.accept(robot);
+    }
+
+    private static class OrientationPolicyRobotTest {
+
+        @NonNull
+        private final ActivityTaskManagerService mAtm;
+        @NonNull
+        private final WindowManagerService mWm;
+        @NonNull
+        private final LetterboxConfiguration mLetterboxConfiguration;
+        @NonNull
+        private final TestComponentStack<ActivityRecord> mActivityStack;
+        @NonNull
+        private final TestComponentStack<Task> mTaskStack;
+
+        @NonNull
+        private final ActivityTaskSupervisor mSupervisor;
+
+        OrientationPolicyRobotTest(@NonNull WindowManagerService wm,
+                                   @NonNull ActivityTaskManagerService atm,
+                                   @NonNull ActivityTaskSupervisor supervisor,
+                                   boolean withActivity) {
+            mAtm = atm;
+            mWm = wm;
+            spyOn(mWm);
+            mSupervisor = supervisor;
+            mActivityStack = new TestComponentStack<>();
+            mTaskStack = new TestComponentStack<>();
+            mLetterboxConfiguration = mWm.mLetterboxConfiguration;
+            if (withActivity) {
+                createActivityWithComponent();
+            }
+        }
+
+        void configureSetIgnoreOrientationRequest(boolean enabled) {
+            mActivityStack.top().mDisplayContent.setIgnoreOrientationRequest(enabled);
+        }
+
+        void configureIsUserAppAspectRatioFullscreenEnabled(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration).isUserAppAspectRatioFullscreenEnabled();
+        }
+
+        void configureIsCameraCompatTreatmentEnabled(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
+        }
+
+        void configureIsCameraCompatTreatmentEnabledAtBuildTime(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration)
+                    .isCameraCompatTreatmentEnabledAtBuildTime();
+        }
+
+        void prepareGetUserMinAspectRatioOverrideCode(int orientation) {
+            spyOn(mActivityStack.top().mLetterboxUiController);
+            doReturn(orientation).when(mActivityStack.top()
+                    .mLetterboxUiController).getUserMinAspectRatioOverrideCode();
+        }
+
+        void prepareShouldApplyUserFullscreenOverride(boolean enabled) {
+            spyOn(mActivityStack.top().mLetterboxUiController);
+            doReturn(enabled).when(mActivityStack.top()
+                    .mLetterboxUiController).shouldApplyUserFullscreenOverride();
+        }
+
+        void prepareShouldApplyUserMinAspectRatioOverride(boolean enabled) {
+            spyOn(mActivityStack.top().mLetterboxUiController);
+            doReturn(enabled).when(mActivityStack.top()
+                    .mLetterboxUiController).shouldApplyUserMinAspectRatioOverride();
+        }
+
+        void prepareIsUserAppAspectRatioSettingsEnabled(boolean enabled) {
+            doReturn(enabled).when(mLetterboxConfiguration).isUserAppAspectRatioSettingsEnabled();
+        }
+
+        void prepareIsTopActivityEligibleForOrientationOverride(boolean enabled) {
+            final DisplayRotationCompatPolicy displayPolicy =
+                    mActivityStack.top().mDisplayContent.mDisplayRotationCompatPolicy;
+            spyOn(displayPolicy);
+            doReturn(enabled).when(displayPolicy)
+                    .isActivityEligibleForOrientationOverride(eq(mActivityStack.top()));
+        }
+
+        void configureIsTopActivityCameraActive(boolean enabled) {
+            final DisplayRotationCompatPolicy displayPolicy =
+                    mActivityStack.top().mDisplayContent.mDisplayRotationCompatPolicy;
+            spyOn(displayPolicy);
+            doReturn(enabled).when(displayPolicy)
+                    .isCameraActive(eq(mActivityStack.top()), /* mustBeFullscreen= */ eq(true));
+        }
+
+        void disableProperty(@NonNull String propertyName) {
+            setPropertyValue(propertyName, /* enabled */ false);
+        }
+
+        int overrideOrientationIfNeeded(@ActivityInfo.ScreenOrientation int candidate) {
+            return mActivityStack.top().mAppCompatController.getOrientationPolicy()
+                    .overrideOrientationIfNeeded(candidate);
+        }
+
+        void checkOrientationRequestMapped() {
+            verify(mWm).mapOrientationRequest(SCREEN_ORIENTATION_PORTRAIT);
+        }
+
+        void checkOverrideOrientation(@ActivityInfo.ScreenOrientation int candidate,
+                                      @ActivityInfo.ScreenOrientation int expected) {
+            Assert.assertEquals(expected, overrideOrientationIfNeeded(candidate));
+        }
+
+        void checkOverrideOrientationIsNot(@ActivityInfo.ScreenOrientation int candidate,
+                                           @ActivityInfo.ScreenOrientation int notExpected) {
+            Assert.assertNotEquals(notExpected, overrideOrientationIfNeeded(candidate));
+        }
+
+        private void createActivityWithComponent() {
+            if (mTaskStack.isEmpty()) {
+                final DisplayContent displayContent = new TestDisplayContent
+                        .Builder(mAtm, /* dw */ 1000, /* dh */ 2000).build();
+                final Task task = new TaskBuilder(mSupervisor).setDisplay(displayContent).build();
+                mTaskStack.push(task);
+            }
+            final ActivityRecord activity = new ActivityBuilder(mAtm)
+                    .setOnTop(true)
+                    .setTask(mTaskStack.top())
+                    // Set the component to be that of the test class in order
+                    // to enable compat changes
+                    .setComponent(ComponentName.createRelative(mAtm.mContext,
+                            com.android.server.wm.LetterboxUiControllerTest.class.getName()))
+                    .build();
+            mActivityStack.push(activity);
+        }
+
+        private void createActivityWithComponentInNewTask() {
+            final DisplayContent displayContent = new TestDisplayContent
+                    .Builder(mAtm, /* dw */ 1000, /* dh */ 2000).build();
+            final Task task = new TaskBuilder(mSupervisor).setDisplay(displayContent).build();
+            final ActivityRecord activity = new ActivityBuilder(mAtm)
+                    .setOnTop(true)
+                    .setTask(task)
+                    // Set the component to be that of the test class in order
+                    // to enable compat changes
+                    .setComponent(ComponentName.createRelative(mAtm.mContext,
+                            com.android.server.wm.LetterboxUiControllerTest.class.getName()))
+                    .build();
+            mTaskStack.push(task);
+            mActivityStack.push(activity);
+        }
+
+        private void setPropertyValue(@NonNull String propertyName, boolean enabled) {
+            PackageManager.Property property = new PackageManager.Property(propertyName,
+                    /* value */ enabled, /* packageName */ "",
+                    /* className */ "");
+            PackageManager pm = mWm.mContext.getPackageManager();
+            spyOn(pm);
+            try {
+                doReturn(property).when(pm).getProperty(eq(propertyName), anyString());
+            } catch (PackageManager.NameNotFoundException e) {
+                fail(e.getLocalizedMessage());
+            }
+        }
+    }
+}
diff --git a/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java b/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java
index 0787052..bdd45c6 100644
--- a/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java
@@ -18,29 +18,14 @@
 
 import static android.content.pm.ActivityInfo.FORCE_NON_RESIZE_APP;
 import static android.content.pm.ActivityInfo.FORCE_RESIZE_APP;
-import static android.content.pm.ActivityInfo.OVERRIDE_ANY_ORIENTATION;
-import static android.content.pm.ActivityInfo.OVERRIDE_ANY_ORIENTATION_TO_USER;
 import static android.content.pm.ActivityInfo.OVERRIDE_CAMERA_COMPAT_DISABLE_FORCE_ROTATION;
 import static android.content.pm.ActivityInfo.OVERRIDE_CAMERA_COMPAT_DISABLE_FREEFORM_WINDOWING_TREATMENT;
 import static android.content.pm.ActivityInfo.OVERRIDE_CAMERA_COMPAT_DISABLE_REFRESH;
 import static android.content.pm.ActivityInfo.OVERRIDE_CAMERA_COMPAT_ENABLE_REFRESH_VIA_PAUSE;
 import static android.content.pm.ActivityInfo.OVERRIDE_ENABLE_COMPAT_FAKE_FOCUS;
-import static android.content.pm.ActivityInfo.OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED;
-import static android.content.pm.ActivityInfo.OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION;
-import static android.content.pm.ActivityInfo.OVERRIDE_LANDSCAPE_ORIENTATION_TO_REVERSE_LANDSCAPE;
 import static android.content.pm.ActivityInfo.OVERRIDE_MIN_ASPECT_RATIO;
 import static android.content.pm.ActivityInfo.OVERRIDE_MIN_ASPECT_RATIO_ONLY_FOR_CAMERA;
-import static android.content.pm.ActivityInfo.OVERRIDE_ORIENTATION_ONLY_FOR_CAMERA;
-import static android.content.pm.ActivityInfo.OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR;
-import static android.content.pm.ActivityInfo.OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT;
 import static android.content.pm.ActivityInfo.OVERRIDE_USE_DISPLAY_LANDSCAPE_NATURAL_ORIENTATION;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LOCKED;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
-import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER;
 import static android.content.pm.PackageManager.USER_MIN_ASPECT_RATIO_3_2;
 import static android.content.pm.PackageManager.USER_MIN_ASPECT_RATIO_FULLSCREEN;
 import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
@@ -50,21 +35,16 @@
 import static android.view.WindowManager.PROPERTY_CAMERA_COMPAT_ALLOW_REFRESH;
 import static android.view.WindowManager.PROPERTY_CAMERA_COMPAT_ENABLE_REFRESH_VIA_PAUSE;
 import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_DISPLAY_ORIENTATION_OVERRIDE;
-import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_IGNORING_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED;
 import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_MIN_ASPECT_RATIO_OVERRIDE;
-import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE;
 import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_RESIZEABLE_ACTIVITY_OVERRIDES;
 import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_USER_ASPECT_RATIO_FULLSCREEN_OVERRIDE;
 import static android.view.WindowManager.PROPERTY_COMPAT_ALLOW_USER_ASPECT_RATIO_OVERRIDE;
 import static android.view.WindowManager.PROPERTY_COMPAT_ENABLE_FAKE_FOCUS;
-import static android.view.WindowManager.PROPERTY_COMPAT_IGNORE_REQUESTED_ORIENTATION;
 
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.eq;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
-import static com.android.server.wm.AppCompatOrientationCapability.OrientationCapabilityState.MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP;
-import static com.android.server.wm.AppCompatOrientationCapability.OrientationCapabilityState.SET_ORIENTATION_REQUEST_COUNTER_TIMEOUT_MS;
 import static com.android.window.flags.Flags.FLAG_CAMERA_COMPAT_FOR_FREEFORM;
 
 import static org.junit.Assert.assertEquals;
@@ -149,187 +129,6 @@
         mController = new LetterboxUiController(mWm, mActivity);
     }
 
-    // shouldIgnoreRequestedOrientation
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION})
-    public void testShouldIgnoreRequestedOrientation_activityRelaunching_returnsTrue() {
-        prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch();
-
-        assertTrue(mActivity.mAppCompatController.getAppCompatCapability()
-                .getAppCompatOrientationCapability()
-                .shouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION})
-    public void testShouldIgnoreRequestedOrientation_cameraCompatTreatment_returnsTrue() {
-        doReturn(true).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
-        doReturn(true).when(mLetterboxConfiguration)
-                .isCameraCompatTreatmentEnabledAtBuildTime();
-
-        // Recreate DisplayContent with DisplayRotationCompatPolicy
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-        prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch();
-        mController.setRelaunchingAfterRequestedOrientationChanged(false);
-
-        spyOn(mDisplayContent.mDisplayRotationCompatPolicy);
-        doReturn(true).when(mDisplayContent.mDisplayRotationCompatPolicy)
-                .isTreatmentEnabledForActivity(eq(mActivity));
-
-        assertTrue(mActivity.mAppCompatController.getAppCompatCapability()
-                .getAppCompatOrientationCapability()
-                .shouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    public void testShouldIgnoreRequestedOrientation_overrideDisabled_returnsFalse() {
-        prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch();
-
-        assertFalse(mActivity.mAppCompatController.getAppCompatCapability()
-                .getAppCompatOrientationCapability()
-                .shouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    public void testShouldIgnoreRequestedOrientation_propertyIsTrue_returnsTrue()
-            throws Exception {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        mockThatProperty(PROPERTY_COMPAT_IGNORE_REQUESTED_ORIENTATION, /* value */ true);
-        mController = new LetterboxUiController(mWm, mActivity);
-        prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch();
-
-        assertTrue(mActivity.mAppCompatController.getAppCompatCapability()
-                .getAppCompatOrientationCapability()
-                .shouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_REQUESTED_ORIENTATION})
-    public void testShouldIgnoreRequestedOrientation_propertyIsFalseAndOverride_returnsFalse()
-            throws Exception {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        mockThatProperty(PROPERTY_COMPAT_IGNORE_REQUESTED_ORIENTATION, /* value */ false);
-
-        mController = new LetterboxUiController(mWm, mActivity);
-        prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch();
-
-        assertFalse(mActivity.mAppCompatController.getAppCompatCapability()
-                .getAppCompatOrientationCapability()
-                .shouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    public void testShouldIgnoreOrientationRequestLoop_overrideDisabled_returnsFalse() {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        doReturn(false).when(mActivity).isLetterboxedForFixedOrientationAndAspectRatio();
-        // Request 3 times to simulate orientation request loop
-        for (int i = 0; i <= MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i++) {
-            assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ false,
-                    /* expectedCount */ 0);
-        }
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
-    public void testShouldIgnoreOrientationRequestLoop_propertyIsFalseAndOverride_returnsFalse()
-            throws Exception {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        mockThatProperty(PROPERTY_COMPAT_ALLOW_IGNORING_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED,
-                /* value */ false);
-        doReturn(false).when(mActivity).isLetterboxedForFixedOrientationAndAspectRatio();
-
-        mController = new LetterboxUiController(mWm, mActivity);
-
-        // Request 3 times to simulate orientation request loop
-        for (int i = 0; i <= MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i++) {
-            assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ false,
-                    /* expectedCount */ 0);
-        }
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
-    public void testShouldIgnoreOrientationRequestLoop_isLetterboxed_returnsFalse() {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        doReturn(true).when(mActivity).isLetterboxedForFixedOrientationAndAspectRatio();
-
-        // Request 3 times to simulate orientation request loop
-        for (int i = 0; i <= MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i++) {
-            assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ false,
-                    /* expectedCount */ i);
-        }
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
-    public void testShouldIgnoreOrientationRequestLoop_noLoop_returnsFalse() {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        doReturn(false).when(mActivity).isLetterboxedForFixedOrientationAndAspectRatio();
-
-        // No orientation request loop
-        assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ false,
-                /* expectedCount */ 0);
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
-    public void testShouldIgnoreOrientationRequestLoop_timeout_returnsFalse()
-            throws InterruptedException {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        doReturn(false).when(mActivity).isLetterboxedForFixedOrientationAndAspectRatio();
-
-        for (int i = MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i > 0; i--) {
-            assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ false,
-                    /* expectedCount */ 0);
-            Thread.sleep(SET_ORIENTATION_REQUEST_COUNTER_TIMEOUT_MS);
-        }
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ENABLE_COMPAT_IGNORE_ORIENTATION_REQUEST_WHEN_LOOP_DETECTED})
-    public void testShouldIgnoreOrientationRequestLoop_returnsTrue() {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        doReturn(false).when(mActivity).isLetterboxedForFixedOrientationAndAspectRatio();
-
-        for (int i = 0; i < MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP; i++) {
-            assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ false,
-                    /* expectedCount */ i);
-        }
-        assertShouldIgnoreOrientationRequestLoop(/* shouldIgnore */ true,
-                /* expectedCount */ MIN_COUNT_TO_IGNORE_REQUEST_IN_LOOP);
-    }
-
-    private void assertShouldIgnoreOrientationRequestLoop(boolean shouldIgnore, int expectedCount) {
-        if (shouldIgnore) {
-            assertTrue(mController.shouldIgnoreOrientationRequestLoop());
-        } else {
-            assertFalse(mController.shouldIgnoreOrientationRequestLoop());
-        }
-        assertEquals(expectedCount, mController.getSetOrientationRequestCounter());
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_CAMERA_COMPAT_DISABLE_REFRESH})
-    public void testShouldIgnoreRequestedOrientation_flagIsDisabled_returnsFalse() {
-        prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch();
-        doReturn(false).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-
-        assertFalse(mActivity.mAppCompatController.getAppCompatCapability()
-                .getAppCompatOrientationCapability()
-                .shouldIgnoreRequestedOrientation(SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
     // shouldRefreshActivityForCameraCompat
 
     @Test
@@ -722,322 +521,6 @@
         return mainWindow;
     }
 
-    // overrideOrientationIfNeeded
-
-    @Test
-    public void testOverrideOrientationIfNeeded_mapInvokedOnRequest() throws Exception {
-        mController = new LetterboxUiController(mWm, mActivity);
-        spyOn(mWm);
-
-        mController.overrideOrientationIfNeeded(SCREEN_ORIENTATION_PORTRAIT);
-
-        verify(mWm).mapOrientationRequest(SCREEN_ORIENTATION_PORTRAIT);
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
-    public void testOverrideOrientationIfNeeded_fullscreenOverrideEnabled_returnsUser()
-            throws Exception {
-        mDisplayContent.setIgnoreOrientationRequest(true);
-        assertEquals(SCREEN_ORIENTATION_USER, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
-    public void testOverrideOrientationIfNeeded_fullscreenOverrideEnabled_optOut_returnsUnchanged()
-            throws Exception {
-        mockThatProperty(PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE, /* value */ false);
-
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-        mDisplayContent.setIgnoreOrientationRequest(true);
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
-    public void testOverrideOrientationIfNeeded_fullscreenOverrides_optOutSystem_returnsUser()
-            throws Exception {
-        mockThatProperty(PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE, /* value */ false);
-        prepareActivityThatShouldApplyUserFullscreenOverride();
-
-        // fullscreen override still applied
-        assertEquals(SCREEN_ORIENTATION_USER, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
-    public void testOverrideOrientationIfNeeded_fullscreenOverrides_optOutUser_returnsUser()
-            throws Exception {
-        mockThatProperty(PROPERTY_COMPAT_ALLOW_USER_ASPECT_RATIO_FULLSCREEN_OVERRIDE,
-                /* value */ false);
-        prepareActivityThatShouldApplyUserFullscreenOverride();
-
-        // fullscreen override still applied
-        assertEquals(SCREEN_ORIENTATION_USER, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
-    public void testOverrideOrientationIfNeeded_fullscreenOverrideEnabled_returnsUnchanged()
-            throws Exception {
-        mDisplayContent.setIgnoreOrientationRequest(false);
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_ANY_ORIENTATION_TO_USER})
-    public void testOverrideOrientationIfNeeded_fullscreenAndUserOverrideEnabled_returnsUnchanged()
-            throws Exception {
-        doReturn(true).when(mLetterboxConfiguration).isUserAppAspectRatioSettingsEnabled();
-        mActivity = setUpActivityWithComponent();
-        spyOn(mActivity.mLetterboxUiController);
-        doReturn(USER_MIN_ASPECT_RATIO_3_2).when(mActivity.mLetterboxUiController)
-                .getUserMinAspectRatioOverrideCode();
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mActivity.mLetterboxUiController
-                .overrideOrientationIfNeeded(/* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT})
-    public void testOverrideOrientationIfNeeded_portraitOverrideEnabled_returnsPortrait()
-            throws Exception {
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR})
-    public void testOverrideOrientationIfNeeded_portraitOverrideEnabled_returnsNosensor() {
-        assertEquals(SCREEN_ORIENTATION_NOSENSOR, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR})
-    public void testOverrideOrientationIfNeeded_nosensorOverride_orientationFixed_returnsUnchanged() {
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_LANDSCAPE_ORIENTATION_TO_REVERSE_LANDSCAPE})
-    public void testOverrideOrientationIfNeeded_reverseLandscapeOverride_orientationPortraitOrUndefined_returnsUnchanged() {
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-        assertEquals(SCREEN_ORIENTATION_UNSPECIFIED, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_LANDSCAPE_ORIENTATION_TO_REVERSE_LANDSCAPE})
-    public void testOverrideOrientationIfNeeded_reverseLandscapeOverride_orientationLandscape_returnsReverseLandscape() {
-        assertEquals(SCREEN_ORIENTATION_REVERSE_LANDSCAPE, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_LANDSCAPE));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT})
-    public void testOverrideOrientationIfNeeded_portraitOverride_orientationFixed_returnsUnchanged() {
-        assertEquals(SCREEN_ORIENTATION_NOSENSOR, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_NOSENSOR));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT, OVERRIDE_ANY_ORIENTATION})
-    public void testOverrideOrientationIfNeeded_portraitAndIgnoreFixedOverrides_returnsPortrait() {
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_NOSENSOR));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_NOSENSOR, OVERRIDE_ANY_ORIENTATION})
-    public void testOverrideOrientationIfNeeded_noSensorAndIgnoreFixedOverrides_returnsNosensor() {
-        assertEquals(SCREEN_ORIENTATION_NOSENSOR, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT})
-    public void testOverrideOrientationIfNeeded_propertyIsFalse_returnsUnchanged()
-            throws Exception {
-        mockThatProperty(PROPERTY_COMPAT_ALLOW_ORIENTATION_OVERRIDE, /* value */ false);
-
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-
-        assertEquals(SCREEN_ORIENTATION_UNSPECIFIED, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT,
-            OVERRIDE_ORIENTATION_ONLY_FOR_CAMERA})
-    public void testOverrideOrientationIfNeeded_whenCameraNotActive_returnsUnchanged() {
-        doReturn(true).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
-        doReturn(true).when(mLetterboxConfiguration)
-                .isCameraCompatTreatmentEnabledAtBuildTime();
-
-        // Recreate DisplayContent with DisplayRotationCompatPolicy
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-
-        spyOn(mDisplayContent.mDisplayRotationCompatPolicy);
-        doReturn(false).when(mDisplayContent.mDisplayRotationCompatPolicy)
-                .isActivityEligibleForOrientationOverride(eq(mActivity));
-
-        assertEquals(SCREEN_ORIENTATION_UNSPECIFIED, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT,
-            OVERRIDE_ORIENTATION_ONLY_FOR_CAMERA})
-    public void testOverrideOrientationIfNeeded_whenCameraActive_returnsPortrait() {
-        doReturn(true).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
-        doReturn(true).when(mLetterboxConfiguration)
-                .isCameraCompatTreatmentEnabledAtBuildTime();
-
-        // Recreate DisplayContent with DisplayRotationCompatPolicy
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-
-        spyOn(mDisplayContent.mDisplayRotationCompatPolicy);
-        doReturn(true).when(mDisplayContent.mDisplayRotationCompatPolicy)
-                .isActivityEligibleForOrientationOverride(eq(mActivity));
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_userFullscreenOverride_returnsUser() {
-        spyOn(mActivity.mLetterboxUiController);
-        doReturn(true).when(mActivity.mLetterboxUiController)
-                .shouldApplyUserFullscreenOverride();
-        mDisplayContent.setIgnoreOrientationRequest(true);
-
-        assertEquals(SCREEN_ORIENTATION_USER, mActivity.mAppCompatController
-                .getOrientationPolicy()
-                .overrideOrientationIfNeeded(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_userFullscreenOverride_cameraActivity_noChange() {
-        doReturn(true).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
-        doReturn(true).when(mLetterboxConfiguration)
-                .isCameraCompatTreatmentEnabledAtBuildTime();
-
-        // Recreate DisplayContent with DisplayRotationCompatPolicy
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-        spyOn(mController);
-        doReturn(true).when(mController).shouldApplyUserFullscreenOverride();
-
-        spyOn(mDisplayContent.mDisplayRotationCompatPolicy);
-        doReturn(true).when(mDisplayContent.mDisplayRotationCompatPolicy)
-                .isCameraActive(mActivity, /* mustBeFullscreen= */ true);
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_systemFullscreenOverride_cameraActivity_noChange() {
-        doReturn(true).when(mLetterboxConfiguration).isCameraCompatTreatmentEnabled();
-        doReturn(true).when(mLetterboxConfiguration)
-                .isCameraCompatTreatmentEnabledAtBuildTime();
-
-        // Recreate DisplayContent with DisplayRotationCompatPolicy
-        mActivity = setUpActivityWithComponent();
-        mController = new LetterboxUiController(mWm, mActivity);
-        spyOn(mController);
-        doReturn(true).when(mController).isSystemOverrideToFullscreenEnabled();
-
-        spyOn(mDisplayContent.mDisplayRotationCompatPolicy);
-        doReturn(true).when(mDisplayContent.mDisplayRotationCompatPolicy)
-                .isCameraActive(mActivity, /* mustBeFullscreen= */ true);
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_respectOrientationRequestOverUserFullScreen() {
-        spyOn(mController);
-        doReturn(true).when(mController).shouldApplyUserFullscreenOverride();
-        mDisplayContent.setIgnoreOrientationRequest(false);
-
-        assertNotEquals(SCREEN_ORIENTATION_USER, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT, OVERRIDE_ANY_ORIENTATION})
-    public void testOverrideOrientationIfNeeded_userFullScreenOverrideOverSystem_returnsUser() {
-        spyOn(mActivity.mLetterboxUiController);
-        doReturn(true).when(mActivity.mLetterboxUiController)
-                .shouldApplyUserFullscreenOverride();
-        mDisplayContent.setIgnoreOrientationRequest(true);
-
-        assertEquals(SCREEN_ORIENTATION_USER, mActivity.mAppCompatController
-                .getOrientationPolicy()
-                .overrideOrientationIfNeeded(/* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    @EnableCompatChanges({OVERRIDE_UNDEFINED_ORIENTATION_TO_PORTRAIT, OVERRIDE_ANY_ORIENTATION})
-    public void testOverrideOrientationIfNeeded_respectOrientationReqOverUserFullScreenAndSystem() {
-        spyOn(mController);
-        doReturn(true).when(mController).shouldApplyUserFullscreenOverride();
-        mDisplayContent.setIgnoreOrientationRequest(false);
-
-        assertNotEquals(SCREEN_ORIENTATION_USER, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_userFullScreenOverrideDisabled_returnsUnchanged() {
-        spyOn(mController);
-        doReturn(false).when(mController).shouldApplyUserFullscreenOverride();
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_PORTRAIT));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_userAspectRatioApplied_unspecifiedOverridden() {
-        spyOn(mActivity.mLetterboxUiController);
-        doReturn(true).when(mActivity.mLetterboxUiController)
-                .shouldApplyUserMinAspectRatioOverride();
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mActivity.mLetterboxUiController
-                .overrideOrientationIfNeeded(/* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-
-        assertEquals(SCREEN_ORIENTATION_PORTRAIT, mActivity.mLetterboxUiController
-                .overrideOrientationIfNeeded(/* candidate */ SCREEN_ORIENTATION_LOCKED));
-
-        // unchanged if orientation is specified
-        assertEquals(SCREEN_ORIENTATION_LANDSCAPE, mActivity.mLetterboxUiController
-                .overrideOrientationIfNeeded(/* candidate */ SCREEN_ORIENTATION_LANDSCAPE));
-    }
-
-    @Test
-    public void testOverrideOrientationIfNeeded_userAspectRatioNotApplied_returnsUnchanged() {
-        spyOn(mController);
-        doReturn(false).when(mController).shouldApplyUserMinAspectRatioOverride();
-
-        assertEquals(SCREEN_ORIENTATION_UNSPECIFIED, mController.overrideOrientationIfNeeded(
-                /* candidate */ SCREEN_ORIENTATION_UNSPECIFIED));
-    }
-
     // shouldApplyUser...Override
     @Test
     public void testShouldApplyUserFullscreenOverride_trueProperty_returnsFalse() throws Exception {
@@ -1715,12 +1198,6 @@
         mDisplayContent.setIgnoreOrientationRequest(true);
     }
 
-    private void prepareActivityThatShouldIgnoreRequestedOrientationDuringRelaunch() {
-        doReturn(true).when(mLetterboxConfiguration)
-                .isPolicyForIgnoringRequestedOrientationEnabled();
-        mController.setRelaunchingAfterRequestedOrientationChanged(true);
-    }
-
     private ActivityRecord setUpActivityWithComponent() {
         mDisplayContent = new TestDisplayContent
                 .Builder(mAtm, /* dw */ 1000, /* dh */ 2000).build();
diff --git a/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java
index 78cea95..628c65e 100644
--- a/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java
@@ -46,12 +46,14 @@
 import android.graphics.Rect;
 import android.platform.test.annotations.Presubmit;
 
+import androidx.annotation.NonNull;
+
+import com.android.server.wm.utils.TestComponentStack;
+
 import org.junit.Assert;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.util.ArrayList;
-import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Predicate;
@@ -318,20 +320,22 @@
      */
     private static class TransparentPolicyRobotTest {
 
+        @NonNull
         private final ActivityTaskManagerService mAtm;
-
+        @NonNull
         private final Task mTask;
-
-        private final ActivityStackTest mActivityStack;
-
+        @NonNull
+        private final TestComponentStack<ActivityRecord> mActivityStack;
+        @NonNull
         private WindowConfiguration mTopActivityWindowConfiguration;
 
-        private TransparentPolicyRobotTest(ActivityTaskManagerService atm, Task task,
-                                           ActivityRecord opaqueActivity) {
+        private TransparentPolicyRobotTest(@NonNull ActivityTaskManagerService atm,
+                                           @NonNull Task task,
+                                           @NonNull ActivityRecord opaqueActivity) {
             mAtm = atm;
             mTask = task;
-            mActivityStack = new ActivityStackTest();
-            mActivityStack.pushActivity(opaqueActivity);
+            mActivityStack = new TestComponentStack<>();
+            mActivityStack.push(opaqueActivity);
             spyOn(opaqueActivity.mAppCompatController.getTransparentPolicy());
         }
 
@@ -361,7 +365,7 @@
                 mTask.addChild(newActivity);
             }
             spyOn(newActivity.mAppCompatController.getTransparentPolicy());
-            mActivityStack.pushActivity(newActivity);
+            mActivityStack.push(newActivity);
         }
 
         void attachTopActivityToTask() {
@@ -596,45 +600,5 @@
             display.computeScreenConfiguration(c);
             display.onRequestedOverrideConfigurationChanged(c);
         }
-
-        /**
-         * Contains all the ActivityRecord launched in the test. This is different from what's in
-         * the Task because activities are added here even if not added to tasks.
-         */
-        private static class ActivityStackTest {
-            private final List<ActivityRecord> mActivities = new ArrayList<>();
-
-            void pushActivity(ActivityRecord activityRecord) {
-                mActivities.add(activityRecord);
-            }
-
-            void applyToTop(Consumer<ActivityRecord> consumer) {
-                consumer.accept(top());
-            }
-
-            ActivityRecord getFromTop(int fromTop) {
-                return mActivities.get(mActivities.size() - fromTop - 1);
-            }
-
-            ActivityRecord base() {
-                return mActivities.get(0);
-            }
-
-            ActivityRecord top() {
-                return mActivities.get(mActivities.size() - 1);
-            }
-
-            // Allows access to the activity at position beforeLast from the top.
-            // If fromTop = 0 the activity used is the top one.
-            void applyTo(int fromTop, Consumer<ActivityRecord> consumer) {
-                consumer.accept(getFromTop(fromTop));
-            }
-
-            void applyToAll(Consumer<ActivityRecord> consumer) {
-                for (int i = mActivities.size() - 1; i >= 0; i--) {
-                    consumer.accept(mActivities.get(i));
-                }
-            }
-        }
     }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/utils/TestComponentStack.java b/services/tests/wmtests/src/com/android/server/wm/utils/TestComponentStack.java
new file mode 100644
index 0000000..a06c0a2
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/utils/TestComponentStack.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.server.wm.utils;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Contains all the component created in the test.
+ */
+public class TestComponentStack<T> {
+
+    @NonNull
+    private final List<T> mItems = new ArrayList<>();
+
+    /**
+     * Adds an item to the stack.
+     *
+     * @param item The item to add.
+     */
+    public void push(@NonNull T item) {
+        mItems.add(item);
+    }
+
+    /**
+     * Consumes the top element of the stack.
+     *
+     * @param consumer Consumer for the optional top element.
+     * @throws IndexOutOfBoundsException In case that stack is empty.
+     */
+    public void applyToTop(@NonNull Consumer<T> consumer) {
+        consumer.accept(top());
+    }
+
+    /**
+     * Returns the item at fromTop position from the top one if present or it throws an
+     * exception if not present.
+     *
+     * @param fromTop The position from the top of the item to return.
+     * @return The returned item.
+     * @throws IndexOutOfBoundsException In case that position doesn't exist.
+     */
+    @NonNull
+    public T getFromTop(int fromTop) {
+        return mItems.get(mItems.size() - fromTop - 1);
+    }
+
+    /**
+     * @return The item at the base of the stack if present.
+     * @throws IndexOutOfBoundsException In case that stack is empty.
+     */
+    @NonNull
+    public T base() {
+        return mItems.get(0);
+    }
+
+    /**
+     * @return The item at the top of the stack if present.
+     * @throws IndexOutOfBoundsException In case that stack is empty.
+     */
+    @NonNull
+    public T top() {
+        return mItems.get(mItems.size() - 1);
+    }
+
+    /**
+     * @return {@code true} if the stack is empty.
+     */
+    public boolean isEmpty() {
+        return mItems.isEmpty();
+    }
+
+    /**
+     * Allows access to the item at position beforeLast from the top.
+     *
+     * @param fromTop  The position from the top of the item to return.
+     * @param consumer Consumer for the optional returned element.
+     */
+    public void applyTo(int fromTop, @NonNull Consumer<T> consumer) {
+        consumer.accept(getFromTop(fromTop));
+    }
+
+    /**
+     * Invoked the consumer iterating over all the elements in the stack.
+     *
+     * @param consumer Consumer for the elements.
+     */
+    public void applyToAll(@NonNull Consumer<T> consumer) {
+        for (int i = mItems.size() - 1; i >= 0; i--) {
+            consumer.accept(mItems.get(i));
+        }
+    }
+}
diff --git a/services/usage/java/com/android/server/usage/UsageStatsDatabase.java b/services/usage/java/com/android/server/usage/UsageStatsDatabase.java
index a8a9017..ba33eab 100644
--- a/services/usage/java/com/android/server/usage/UsageStatsDatabase.java
+++ b/services/usage/java/com/android/server/usage/UsageStatsDatabase.java
@@ -33,6 +33,7 @@
 import android.util.TimeUtils;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.ArrayUtils;
 import com.android.internal.util.IndentingPrintWriter;
 
@@ -136,6 +137,7 @@
     // The obfuscated packages to tokens mappings file
     private final File mPackageMappingsFile;
     // Holds all of the data related to the obfuscated packages and their token mappings.
+    @GuardedBy("mLock")
     final PackagesTokenData mPackagesTokenData = new PackagesTokenData();
 
     /**
@@ -771,27 +773,30 @@
      * all of the stats at once has an amortized cost for future calls.
      */
     void filterStats(IntervalStats stats) {
-        if (mPackagesTokenData.removedPackagesMap.isEmpty()) {
-            return;
-        }
-        final ArrayMap<String, Long> removedPackagesMap = mPackagesTokenData.removedPackagesMap;
-
-        // filter out package usage stats
-        final int removedPackagesSize = removedPackagesMap.size();
-        for (int i = 0; i < removedPackagesSize; i++) {
-            final String removedPackage = removedPackagesMap.keyAt(i);
-            final UsageStats usageStats = stats.packageStats.get(removedPackage);
-            if (usageStats != null && usageStats.mEndTimeStamp < removedPackagesMap.valueAt(i)) {
-                stats.packageStats.remove(removedPackage);
+        synchronized (mLock) {
+            if (mPackagesTokenData.removedPackagesMap.isEmpty()) {
+                return;
             }
-        }
+            final ArrayMap<String, Long> removedPackagesMap = mPackagesTokenData.removedPackagesMap;
 
-        // filter out events
-        for (int i = stats.events.size() - 1; i >= 0; i--) {
-            final UsageEvents.Event event = stats.events.get(i);
-            final Long timeRemoved = removedPackagesMap.get(event.mPackage);
-            if (timeRemoved != null && timeRemoved > event.mTimeStamp) {
-                stats.events.remove(i);
+            // filter out package usage stats
+            final int removedPackagesSize = removedPackagesMap.size();
+            for (int i = 0; i < removedPackagesSize; i++) {
+                final String removedPackage = removedPackagesMap.keyAt(i);
+                final UsageStats usageStats = stats.packageStats.get(removedPackage);
+                if (usageStats != null &&
+                        usageStats.mEndTimeStamp < removedPackagesMap.valueAt(i)) {
+                    stats.packageStats.remove(removedPackage);
+                }
+            }
+
+            // filter out events
+            for (int i = stats.events.size() - 1; i >= 0; i--) {
+                final UsageEvents.Event event = stats.events.get(i);
+                final Long timeRemoved = removedPackagesMap.get(event.mPackage);
+                if (timeRemoved != null && timeRemoved > event.mTimeStamp) {
+                    stats.events.remove(i);
+                }
             }
         }
     }
@@ -1226,12 +1231,14 @@
     }
 
     void obfuscateCurrentStats(IntervalStats[] currentStats) {
-        if (mCurrentVersion < 5) {
-            return;
-        }
-        for (int i = 0; i < currentStats.length; i++) {
-            final IntervalStats stats = currentStats[i];
-            stats.obfuscateData(mPackagesTokenData);
+        synchronized (mLock) {
+            if (mCurrentVersion < 5) {
+                return;
+            }
+            for (int i = 0; i < currentStats.length; i++) {
+                final IntervalStats stats = currentStats[i];
+                stats.obfuscateData(mPackagesTokenData);
+            }
         }
     }
 
diff --git a/telephony/java/android/telephony/PhoneNumberUtils.java b/telephony/java/android/telephony/PhoneNumberUtils.java
index 0ecafc7..019fb7b 100644
--- a/telephony/java/android/telephony/PhoneNumberUtils.java
+++ b/telephony/java/android/telephony/PhoneNumberUtils.java
@@ -49,6 +49,7 @@
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
 import java.util.Locale;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -1285,6 +1286,13 @@
 
     private static final String SINGAPORE_ISO_COUNTRY_CODE = "SG";
 
+    private static final String[] COUNTRY_CODES_TO_FORMAT_NATIONALLY = new String[] {
+            "KR", // Korea
+            "JP", // Japan
+            "SG", // Singapore
+            "TW", // Taiwan
+    };
+
     /**
      * Breaks the given number down and formats it according to the rules
      * for the country the number is from.
@@ -1647,45 +1655,58 @@
             defaultCountryIso = defaultCountryIso.toUpperCase(Locale.ROOT);
         }
 
+        Rlog.v(LOG_TAG, "formatNumber: defaultCountryIso: " + defaultCountryIso);
+
         PhoneNumberUtil util = PhoneNumberUtil.getInstance();
         String result = null;
         try {
             PhoneNumber pn = util.parseAndKeepRawInput(phoneNumber, defaultCountryIso);
 
-            if (KOREA_ISO_COUNTRY_CODE.equalsIgnoreCase(defaultCountryIso) &&
-                    (pn.getCountryCode() == util.getCountryCodeForRegion(KOREA_ISO_COUNTRY_CODE)) &&
-                    (pn.getCountryCodeSource() ==
-                            PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN)) {
-                /**
-                 * Need to reformat any local Korean phone numbers (when the user is in Korea) with
-                 * country code to corresponding national format which would replace the leading
-                 * +82 with 0.
-                 */
-                result = util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
-            } else if (JAPAN_ISO_COUNTRY_CODE.equalsIgnoreCase(defaultCountryIso) &&
-                    pn.getCountryCode() == util.getCountryCodeForRegion(JAPAN_ISO_COUNTRY_CODE) &&
-                    (pn.getCountryCodeSource() ==
-                            PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN)) {
-                /**
-                 * Need to reformat Japanese phone numbers (when user is in Japan) with the national
-                 * dialing format.
-                 */
-                result = util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
-            } else if (Flags.removeCountryCodeFromLocalSingaporeCalls() &&
-                    (SINGAPORE_ISO_COUNTRY_CODE.equalsIgnoreCase(defaultCountryIso) &&
-                            pn.getCountryCode() ==
-                                    util.getCountryCodeForRegion(SINGAPORE_ISO_COUNTRY_CODE) &&
-                            (pn.getCountryCodeSource() ==
-                                    PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN))) {
-                /*
-                 * Need to reformat Singaporean phone numbers (when the user is in Singapore)
-                 * with the country code (+65) removed to comply with Singaporean regulations.
-                 */
-                result = util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
+            if (Flags.nationalCountryCodeFormattingForLocalCalls()) {
+                if (Arrays.asList(COUNTRY_CODES_TO_FORMAT_NATIONALLY).contains(defaultCountryIso)
+                        && pn.getCountryCode() == util.getCountryCodeForRegion(defaultCountryIso)
+                        && pn.getCountryCodeSource()
+                        == PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN) {
+                    return util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
+                } else {
+                    return util.formatInOriginalFormat(pn, defaultCountryIso);
+                }
             } else {
-                result = util.formatInOriginalFormat(pn, defaultCountryIso);
+                if (KOREA_ISO_COUNTRY_CODE.equalsIgnoreCase(defaultCountryIso) && (
+                        pn.getCountryCode() == util.getCountryCodeForRegion(KOREA_ISO_COUNTRY_CODE))
+                        && (pn.getCountryCodeSource()
+                        == PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN)) {
+                    /**
+                     * Need to reformat any local Korean phone numbers (when the user is in
+                     * Korea) with country code to corresponding national format which would
+                     * replace the leading +82 with 0.
+                     */
+                    result = util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
+                } else if (JAPAN_ISO_COUNTRY_CODE.equalsIgnoreCase(defaultCountryIso)
+                        && pn.getCountryCode() == util.getCountryCodeForRegion(
+                        JAPAN_ISO_COUNTRY_CODE) && (pn.getCountryCodeSource()
+                        == PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN)) {
+                    /**
+                     * Need to reformat Japanese phone numbers (when user is in Japan) with the
+                     * national dialing format.
+                     */
+                    result = util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
+                } else if (Flags.removeCountryCodeFromLocalSingaporeCalls() && (
+                        SINGAPORE_ISO_COUNTRY_CODE.equalsIgnoreCase(defaultCountryIso)
+                                && pn.getCountryCode() == util.getCountryCodeForRegion(
+                                SINGAPORE_ISO_COUNTRY_CODE) && (pn.getCountryCodeSource()
+                                == PhoneNumber.CountryCodeSource.FROM_NUMBER_WITH_PLUS_SIGN))) {
+                    /*
+                     * Need to reformat Singaporean phone numbers (when the user is in Singapore)
+                     * with the country code (+65) removed to comply with Singaporean regulations.
+                     */
+                    result = util.format(pn, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
+                } else {
+                    result = util.formatInOriginalFormat(pn, defaultCountryIso);
+                }
             }
         } catch (NumberParseException e) {
+            if (DBG) log("formatNumber: NumberParseException caught " + e);
         }
         return result;
     }