Merge "Guard minSdkVersionFull attribute with android.sdk.major_minor_versioning_scheme" into main
diff --git a/AconfigFlags.bp b/AconfigFlags.bp
index 3689ff5..7977d73 100644
--- a/AconfigFlags.bp
+++ b/AconfigFlags.bp
@@ -100,6 +100,7 @@
         "com.android.media.flags.editing-aconfig-java",
         "com.android.media.flags.performance-aconfig-java",
         "com.android.media.flags.projection-aconfig-java",
+        "com.android.net.http.flags-aconfig-exported-java",
         "com.android.net.thread.platform.flags-aconfig-java",
         "com.android.ranging.flags.ranging-aconfig-java-export",
         "com.android.server.contextualsearch.flags-java",
diff --git a/core/java/android/companion/AssociationInfo.java b/core/java/android/companion/AssociationInfo.java
index 2e108a1..2f16115 100644
--- a/core/java/android/companion/AssociationInfo.java
+++ b/core/java/android/companion/AssociationInfo.java
@@ -460,8 +460,8 @@
         } else {
             mDeviceIcon = null;
         }
-
-        if (Flags.associationTag() && in.readInt() == 1) {
+        int deviceId = in.readInt();
+        if (Flags.associationTag() && deviceId == 1) {
             mDeviceId = in.readTypedObject(DeviceId.CREATOR);
         } else {
             mDeviceId = null;
diff --git a/core/java/android/hardware/display/DisplayTopology.java b/core/java/android/hardware/display/DisplayTopology.java
index 0e53d87..54d0dd0 100644
--- a/core/java/android/hardware/display/DisplayTopology.java
+++ b/core/java/android/hardware/display/DisplayTopology.java
@@ -28,6 +28,7 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.util.IndentingPrintWriter;
+import android.util.MathUtils;
 import android.util.Pair;
 import android.util.Slog;
 import android.view.Display;
@@ -284,6 +285,146 @@
     }
 
     /**
+     * Clamp offsets and remove any overlaps between displays.
+     */
+    public void normalize() {
+        if (mRoot == null) {
+            return;
+        }
+        clampOffsets(mRoot);
+
+        Map<TreeNode, RectF> bounds = new HashMap<>();
+        Map<TreeNode, Integer> depths = new HashMap<>();
+        Map<TreeNode, TreeNode> parents = new HashMap<>();
+        getInfo(bounds, depths, parents, mRoot, /* x= */ 0, /* y= */ 0, /* depth= */ 0);
+
+        // Sort the displays first by their depth in the tree, then by the distance of their top
+        // left point from the root display's origin (0, 0). This way we process the displays
+        // starting at the root and we push out a display if necessary.
+        Comparator<TreeNode> comparator = (d1, d2) -> {
+            if (d1 == d2) {
+                return 0;
+            }
+
+            int compareDepths = Integer.compare(depths.get(d1), depths.get(d2));
+            if (compareDepths != 0) {
+                return compareDepths;
+            }
+
+            RectF bounds1 = bounds.get(d1);
+            RectF bounds2 = bounds.get(d2);
+            return Double.compare(Math.hypot(bounds1.left, bounds1.top),
+                    Math.hypot(bounds2.left, bounds2.top));
+        };
+        List<TreeNode> displays = new ArrayList<>(bounds.keySet());
+        displays.sort(comparator);
+
+        for (int i = 1; i < displays.size(); i++) {
+            TreeNode targetDisplay = displays.get(i);
+            TreeNode lastIntersectingSourceDisplay = null;
+            float lastOffsetX = 0;
+            float lastOffsetY = 0;
+
+            for (int j = 0; j < i; j++) {
+                TreeNode sourceDisplay = displays.get(j);
+                RectF sourceBounds = bounds.get(sourceDisplay);
+                RectF targetBounds = bounds.get(targetDisplay);
+
+                if (!RectF.intersects(sourceBounds, targetBounds)) {
+                    continue;
+                }
+
+                // Find the offset by which to move the display. Pick the smaller one among the x
+                // and y axes.
+                float offsetX = targetBounds.left >= 0
+                        ? sourceBounds.right - targetBounds.left
+                        : sourceBounds.left - targetBounds.right;
+                float offsetY = targetBounds.top >= 0
+                        ? sourceBounds.bottom - targetBounds.top
+                        : sourceBounds.top - targetBounds.bottom;
+                if (Math.abs(offsetX) <= Math.abs(offsetY)) {
+                    targetBounds.left += offsetX;
+                    targetBounds.right += offsetX;
+                    // We need to also update the offset in the tree
+                    if (targetDisplay.mPosition == POSITION_TOP
+                            || targetDisplay.mPosition == POSITION_BOTTOM) {
+                        targetDisplay.mOffset += offsetX;
+                    }
+                    offsetY = 0;
+                } else {
+                    targetBounds.top += offsetY;
+                    targetBounds.bottom += offsetY;
+                    // We need to also update the offset in the tree
+                    if (targetDisplay.mPosition == POSITION_LEFT
+                            || targetDisplay.mPosition == POSITION_RIGHT) {
+                        targetDisplay.mOffset += offsetY;
+                    }
+                    offsetX = 0;
+                }
+
+                lastIntersectingSourceDisplay = sourceDisplay;
+                lastOffsetX = offsetX;
+                lastOffsetY = offsetY;
+            }
+
+            // Now re-parent the target display to the last intersecting source display if it no
+            // longer touches its parent.
+            if (lastIntersectingSourceDisplay == null) {
+                // There was no overlap.
+                continue;
+            }
+            TreeNode parent = parents.get(targetDisplay);
+            if (parent == lastIntersectingSourceDisplay) {
+                // The displays are moved in such a way that they're adjacent to the intersecting
+                // display. If the last intersecting display happens to be the parent then we
+                // already know that the display is adjacent to its parent.
+                continue;
+            }
+
+            RectF childBounds = bounds.get(targetDisplay);
+            RectF parentBounds = bounds.get(parent);
+            // Check that the edges are on the same line
+            boolean areTouching = switch (targetDisplay.mPosition) {
+                case POSITION_LEFT -> floatEquals(parentBounds.left, childBounds.right);
+                case POSITION_RIGHT -> floatEquals(parentBounds.right, childBounds.left);
+                case POSITION_TOP -> floatEquals(parentBounds.top, childBounds.bottom);
+                case POSITION_BOTTOM -> floatEquals(parentBounds.bottom, childBounds.top);
+                default -> throw new IllegalStateException(
+                        "Unexpected value: " + targetDisplay.mPosition);
+            };
+            // Check that the offset is within bounds
+            areTouching &= switch (targetDisplay.mPosition) {
+                case POSITION_LEFT, POSITION_RIGHT ->
+                        childBounds.bottom + EPSILON >= parentBounds.top
+                                && childBounds.top <= parentBounds.bottom + EPSILON;
+                case POSITION_TOP, POSITION_BOTTOM ->
+                        childBounds.right + EPSILON >= parentBounds.left
+                                && childBounds.left <= parentBounds.right + EPSILON;
+                default -> throw new IllegalStateException(
+                        "Unexpected value: " + targetDisplay.mPosition);
+            };
+
+            if (!areTouching) {
+                // Re-parent the display.
+                parent.mChildren.remove(targetDisplay);
+                RectF lastIntersectingSourceDisplayBounds =
+                        bounds.get(lastIntersectingSourceDisplay);
+                lastIntersectingSourceDisplay.mChildren.add(targetDisplay);
+
+                if (lastOffsetX != 0) {
+                    targetDisplay.mPosition = lastOffsetX > 0 ? POSITION_RIGHT : POSITION_LEFT;
+                    targetDisplay.mOffset =
+                            childBounds.top - lastIntersectingSourceDisplayBounds.top;
+                } else if (lastOffsetY != 0) {
+                    targetDisplay.mPosition = lastOffsetY > 0 ? POSITION_BOTTOM : POSITION_TOP;
+                    targetDisplay.mOffset =
+                            childBounds.left - lastIntersectingSourceDisplayBounds.left;
+                }
+            }
+        }
+    }
+
+    /**
      * @return A deep copy of the topology that will not be modified by the system.
      */
     public DisplayTopology copy() {
@@ -442,145 +583,6 @@
     }
 
     /**
-     * Update the topology to remove any overlaps between displays.
-     */
-    @VisibleForTesting
-    public void normalize() {
-        if (mRoot == null) {
-            return;
-        }
-        Map<TreeNode, RectF> bounds = new HashMap<>();
-        Map<TreeNode, Integer> depths = new HashMap<>();
-        Map<TreeNode, TreeNode> parents = new HashMap<>();
-        getInfo(bounds, depths, parents, mRoot, /* x= */ 0, /* y= */ 0, /* depth= */ 0);
-
-        // Sort the displays first by their depth in the tree, then by the distance of their top
-        // left point from the root display's origin (0, 0). This way we process the displays
-        // starting at the root and we push out a display if necessary.
-        Comparator<TreeNode> comparator = (d1, d2) -> {
-            if (d1 == d2) {
-                return 0;
-            }
-
-            int compareDepths = Integer.compare(depths.get(d1), depths.get(d2));
-            if (compareDepths != 0) {
-                return compareDepths;
-            }
-
-            RectF bounds1 = bounds.get(d1);
-            RectF bounds2 = bounds.get(d2);
-            return Double.compare(Math.hypot(bounds1.left, bounds1.top),
-                    Math.hypot(bounds2.left, bounds2.top));
-        };
-        List<TreeNode> displays = new ArrayList<>(bounds.keySet());
-        displays.sort(comparator);
-
-        for (int i = 1; i < displays.size(); i++) {
-            TreeNode targetDisplay = displays.get(i);
-            TreeNode lastIntersectingSourceDisplay = null;
-            float lastOffsetX = 0;
-            float lastOffsetY = 0;
-
-            for (int j = 0; j < i; j++) {
-                TreeNode sourceDisplay = displays.get(j);
-                RectF sourceBounds = bounds.get(sourceDisplay);
-                RectF targetBounds = bounds.get(targetDisplay);
-
-                if (!RectF.intersects(sourceBounds, targetBounds)) {
-                    continue;
-                }
-
-                // Find the offset by which to move the display. Pick the smaller one among the x
-                // and y axes.
-                float offsetX = targetBounds.left >= 0
-                        ? sourceBounds.right - targetBounds.left
-                        : sourceBounds.left - targetBounds.right;
-                float offsetY = targetBounds.top >= 0
-                        ? sourceBounds.bottom - targetBounds.top
-                        : sourceBounds.top - targetBounds.bottom;
-                if (Math.abs(offsetX) <= Math.abs(offsetY)) {
-                    targetBounds.left += offsetX;
-                    targetBounds.right += offsetX;
-                    // We need to also update the offset in the tree
-                    if (targetDisplay.mPosition == POSITION_TOP
-                            || targetDisplay.mPosition == POSITION_BOTTOM) {
-                        targetDisplay.mOffset += offsetX;
-                    }
-                    offsetY = 0;
-                } else {
-                    targetBounds.top += offsetY;
-                    targetBounds.bottom += offsetY;
-                    // We need to also update the offset in the tree
-                    if (targetDisplay.mPosition == POSITION_LEFT
-                            || targetDisplay.mPosition == POSITION_RIGHT) {
-                        targetDisplay.mOffset += offsetY;
-                    }
-                    offsetX = 0;
-                }
-
-                lastIntersectingSourceDisplay = sourceDisplay;
-                lastOffsetX = offsetX;
-                lastOffsetY = offsetY;
-            }
-
-            // Now re-parent the target display to the last intersecting source display if it no
-            // longer touches its parent.
-            if (lastIntersectingSourceDisplay == null) {
-                // There was no overlap.
-                continue;
-            }
-            TreeNode parent = parents.get(targetDisplay);
-            if (parent == lastIntersectingSourceDisplay) {
-                // The displays are moved in such a way that they're adjacent to the intersecting
-                // display. If the last intersecting display happens to be the parent then we
-                // already know that the display is adjacent to its parent.
-                continue;
-            }
-
-            RectF childBounds = bounds.get(targetDisplay);
-            RectF parentBounds = bounds.get(parent);
-            // Check that the edges are on the same line
-            boolean areTouching = switch (targetDisplay.mPosition) {
-                case POSITION_LEFT -> floatEquals(parentBounds.left, childBounds.right);
-                case POSITION_RIGHT -> floatEquals(parentBounds.right, childBounds.left);
-                case POSITION_TOP -> floatEquals(parentBounds.top, childBounds.bottom);
-                case POSITION_BOTTOM -> floatEquals(parentBounds.bottom, childBounds.top);
-                default -> throw new IllegalStateException(
-                        "Unexpected value: " + targetDisplay.mPosition);
-            };
-            // Check that the offset is within bounds
-            areTouching &= switch (targetDisplay.mPosition) {
-                case POSITION_LEFT, POSITION_RIGHT ->
-                        childBounds.bottom + EPSILON >= parentBounds.top
-                                && childBounds.top <= parentBounds.bottom + EPSILON;
-                case POSITION_TOP, POSITION_BOTTOM ->
-                        childBounds.right + EPSILON >= parentBounds.left
-                                && childBounds.left <= parentBounds.right + EPSILON;
-                default -> throw new IllegalStateException(
-                        "Unexpected value: " + targetDisplay.mPosition);
-            };
-
-            if (!areTouching) {
-                // Re-parent the display.
-                parent.mChildren.remove(targetDisplay);
-                RectF lastIntersectingSourceDisplayBounds =
-                        bounds.get(lastIntersectingSourceDisplay);
-                lastIntersectingSourceDisplay.mChildren.add(targetDisplay);
-
-                if (lastOffsetX != 0) {
-                    targetDisplay.mPosition = lastOffsetX > 0 ? POSITION_RIGHT : POSITION_LEFT;
-                    targetDisplay.mOffset =
-                            childBounds.top - lastIntersectingSourceDisplayBounds.top;
-                } else if (lastOffsetY != 0) {
-                    targetDisplay.mPosition = lastOffsetY > 0 ? POSITION_BOTTOM : POSITION_TOP;
-                    targetDisplay.mOffset =
-                            childBounds.left - lastIntersectingSourceDisplayBounds.left;
-                }
-            }
-        }
-    }
-
-    /**
      * Tests whether two brightness float values are within a small enough tolerance
      * of each other.
      * @param a first float to compare
@@ -605,6 +607,24 @@
         return found;
     }
 
+    /**
+     * Ensure that the offsets of all displays within the given tree are within bounds.
+     * @param display The starting node
+     */
+    private void clampOffsets(TreeNode display) {
+        if (display == null) {
+            return;
+        }
+        for (TreeNode child : display.mChildren) {
+            if (child.mPosition == POSITION_LEFT || child.mPosition == POSITION_RIGHT) {
+                child.mOffset = MathUtils.constrain(child.mOffset, -child.mHeight, display.mHeight);
+            } else if (child.mPosition == POSITION_TOP || child.mPosition == POSITION_BOTTOM) {
+                child.mOffset = MathUtils.constrain(child.mOffset, -child.mWidth, display.mWidth);
+            }
+            clampOffsets(child);
+        }
+    }
+
     public static final class TreeNode implements Parcelable {
         public static final int POSITION_LEFT = 0;
         public static final int POSITION_TOP = 1;
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index 049189f..02c7901 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -28277,7 +28277,7 @@
         mPrivateFlags |= PFLAG_FORCE_LAYOUT;
         mPrivateFlags |= PFLAG_INVALIDATED;
 
-        if (mParent != null && !mParent.isLayoutRequested()) {
+        if (mParent != null) {
             mParent.requestLayout();
         }
         if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
diff --git a/core/java/com/android/internal/os/ZygoteInit.java b/core/java/com/android/internal/os/ZygoteInit.java
index e402ddf..e60879e 100644
--- a/core/java/com/android/internal/os/ZygoteInit.java
+++ b/core/java/com/android/internal/os/ZygoteInit.java
@@ -19,6 +19,8 @@
 import static android.system.OsConstants.S_IRWXG;
 import static android.system.OsConstants.S_IRWXO;
 
+import static android.net.http.Flags.preloadHttpengineInZygote;
+
 import static com.android.internal.util.FrameworkStatsLog.BOOT_TIME_EVENT_ELAPSED_TIME__EVENT__SECONDARY_ZYGOTE_INIT_START;
 import static com.android.internal.util.FrameworkStatsLog.BOOT_TIME_EVENT_ELAPSED_TIME__EVENT__ZYGOTE_INIT_START;
 
@@ -27,6 +29,7 @@
 import android.content.pm.SharedLibraryInfo;
 import android.content.res.Resources;
 import android.os.Build;
+import android.net.http.HttpEngine;
 import android.os.Environment;
 import android.os.IInstalld;
 import android.os.Process;
@@ -144,6 +147,23 @@
         Trace.traceEnd(Trace.TRACE_TAG_DALVIK);
         preloadSharedLibraries();
         preloadTextResources();
+
+        // TODO: remove the try/catch and the flag read as soon as the flag is ramped and 25Q2
+        // starts building from source.
+        if (preloadHttpengineInZygote()) {
+            try {
+                HttpEngine.preload();
+            } catch (NoSuchMethodError e){
+                // The flag protecting this API is not an exported
+                // flag because ZygoteInit happens before the
+                // system service has initialized the flag which means
+                // that we can't query the real value of the flag
+                // from the tethering module. In order to avoid crashing
+                // in the case where we have (new zygote, old tethering).
+                // we catch the NoSuchMethodError and just log.
+                Log.d(TAG, "HttpEngine.preload() threw " + e);
+            }
+        }
         // Ask the WebViewFactory to do any initialization that must run in the zygote process,
         // for memory sharing purposes.
         WebViewFactory.prepareWebViewInZygote();
diff --git a/core/tests/coretests/src/android/hardware/display/DisplayTopologyTest.kt b/core/tests/coretests/src/android/hardware/display/DisplayTopologyTest.kt
index f584ab9..18e4fde 100644
--- a/core/tests/coretests/src/android/hardware/display/DisplayTopologyTest.kt
+++ b/core/tests/coretests/src/android/hardware/display/DisplayTopologyTest.kt
@@ -38,12 +38,7 @@
         topology.addDisplay(displayId, width, height)
 
         assertThat(topology.primaryDisplayId).isEqualTo(displayId)
-
-        val display = topology.root!!
-        assertThat(display.displayId).isEqualTo(displayId)
-        assertThat(display.width).isEqualTo(width)
-        assertThat(display.height).isEqualTo(height)
-        assertThat(display.children).isEmpty()
+        verifyDisplay(topology.root!!, displayId, width, height, noOfChildren = 0)
     }
 
     @Test
@@ -62,18 +57,9 @@
         assertThat(topology.primaryDisplayId).isEqualTo(displayId1)
 
         val display1 = topology.root!!
-        assertThat(display1.displayId).isEqualTo(displayId1)
-        assertThat(display1.width).isEqualTo(width1)
-        assertThat(display1.height).isEqualTo(height1)
-        assertThat(display1.children).hasSize(1)
-
-        val display2 = display1.children[0]
-        assertThat(display2.displayId).isEqualTo(displayId2)
-        assertThat(display2.width).isEqualTo(width2)
-        assertThat(display2.height).isEqualTo(height2)
-        assertThat(display2.children).isEmpty()
-        assertThat(display2.position).isEqualTo(POSITION_TOP)
-        assertThat(display2.offset).isEqualTo(width1 / 2 - width2 / 2)
+        verifyDisplay(display1, displayId1, width1, height1, noOfChildren = 1)
+        verifyDisplay(display1.children[0], displayId2, width2, height2, POSITION_TOP,
+            offset = width1 / 2 - width2 / 2, noOfChildren = 0)
     }
 
     @Test
@@ -97,29 +83,18 @@
         assertThat(topology.primaryDisplayId).isEqualTo(displayId1)
 
         val display1 = topology.root!!
-        assertThat(display1.displayId).isEqualTo(displayId1)
-        assertThat(display1.width).isEqualTo(width1)
-        assertThat(display1.height).isEqualTo(height1)
-        assertThat(display1.children).hasSize(1)
+        verifyDisplay(display1, displayId1, width1, height1, noOfChildren = 1)
 
         val display2 = display1.children[0]
-        assertThat(display2.displayId).isEqualTo(displayId2)
-        assertThat(display2.width).isEqualTo(width2)
-        assertThat(display2.height).isEqualTo(height2)
-        assertThat(display2.children).hasSize(1)
-        assertThat(display2.position).isEqualTo(POSITION_TOP)
-        assertThat(display2.offset).isEqualTo(width1 / 2 - width2 / 2)
+        verifyDisplay(display1.children[0], displayId2, width2, height2, POSITION_TOP,
+            offset = width1 / 2 - width2 / 2, noOfChildren = 1)
 
         var display = display2
         for (i in 3..noOfDisplays) {
             display = display.children[0]
-            assertThat(display.displayId).isEqualTo(i)
-            assertThat(display.width).isEqualTo(width1)
-            assertThat(display.height).isEqualTo(height1)
             // The last display should have no children
-            assertThat(display.children).hasSize(if (i < noOfDisplays) 1 else 0)
-            assertThat(display.position).isEqualTo(POSITION_RIGHT)
-            assertThat(display.offset).isEqualTo(0)
+            verifyDisplay(display, id = i, width1, height1, POSITION_RIGHT, offset = 0f,
+                noOfChildren = if (i < noOfDisplays) 1 else 0)
         }
     }
 
@@ -147,18 +122,11 @@
         assertThat(topology.primaryDisplayId).isEqualTo(displayId1)
 
         var display1 = topology.root!!
-        assertThat(display1.displayId).isEqualTo(displayId1)
-        assertThat(display1.width).isEqualTo(width1)
-        assertThat(display1.height).isEqualTo(height1)
-        assertThat(display1.children).hasSize(1)
+        verifyDisplay(display1, displayId1, width1, height1, noOfChildren = 1)
 
         var display2 = display1.children[0]
-        assertThat(display2.displayId).isEqualTo(displayId2)
-        assertThat(display2.width).isEqualTo(width2)
-        assertThat(display2.height).isEqualTo(height2)
-        assertThat(display2.children).hasSize(1)
-        assertThat(display2.position).isEqualTo(POSITION_TOP)
-        assertThat(display2.offset).isEqualTo(width1 / 2 - width2 / 2)
+        verifyDisplay(display2, displayId2, width2, height2, POSITION_TOP,
+            offset = width1 / 2 - width2 / 2, noOfChildren = 1)
 
         var display = display2
         for (i in 3..noOfDisplays) {
@@ -166,13 +134,9 @@
                 continue
             }
             display = display.children[0]
-            assertThat(display.displayId).isEqualTo(i)
-            assertThat(display.width).isEqualTo(width1)
-            assertThat(display.height).isEqualTo(height1)
             // The last display should have no children
-            assertThat(display.children).hasSize(if (i < noOfDisplays) 1 else 0)
-            assertThat(display.position).isEqualTo(POSITION_RIGHT)
-            assertThat(display.offset).isEqualTo(0)
+            verifyDisplay(display, id = i, width1, height1, POSITION_RIGHT, offset = 0f,
+                noOfChildren = if (i < noOfDisplays) 1 else 0)
         }
 
         topology.removeDisplay(22)
@@ -185,18 +149,11 @@
         assertThat(topology.primaryDisplayId).isEqualTo(displayId1)
 
         display1 = topology.root!!
-        assertThat(display1.displayId).isEqualTo(displayId1)
-        assertThat(display1.width).isEqualTo(width1)
-        assertThat(display1.height).isEqualTo(height1)
-        assertThat(display1.children).hasSize(1)
+        verifyDisplay(display1, displayId1, width1, height1, noOfChildren = 1)
 
         display2 = display1.children[0]
-        assertThat(display2.displayId).isEqualTo(displayId2)
-        assertThat(display2.width).isEqualTo(width2)
-        assertThat(display2.height).isEqualTo(height2)
-        assertThat(display2.children).hasSize(1)
-        assertThat(display2.position).isEqualTo(POSITION_TOP)
-        assertThat(display2.offset).isEqualTo(width1 / 2 - width2 / 2)
+        verifyDisplay(display2, displayId2, width2, height2, POSITION_TOP,
+            offset = width1 / 2 - width2 / 2, noOfChildren = 1)
 
         display = display2
         for (i in 3..noOfDisplays) {
@@ -204,13 +161,9 @@
                 continue
             }
             display = display.children[0]
-            assertThat(display.displayId).isEqualTo(i)
-            assertThat(display.width).isEqualTo(width1)
-            assertThat(display.height).isEqualTo(height1)
             // The last display should have no children
-            assertThat(display.children).hasSize(if (i < noOfDisplays) 1 else 0)
-            assertThat(display.position).isEqualTo(POSITION_RIGHT)
-            assertThat(display.offset).isEqualTo(0)
+            verifyDisplay(display, id = i, width1, height1, POSITION_RIGHT, offset = 0f,
+                noOfChildren = if (i < noOfDisplays) 1 else 0)
         }
     }
 
@@ -237,12 +190,7 @@
         topology.removeDisplay(3)
 
         assertThat(topology.primaryDisplayId).isEqualTo(displayId)
-
-        val display = topology.root!!
-        assertThat(display.displayId).isEqualTo(displayId)
-        assertThat(display.width).isEqualTo(width)
-        assertThat(display.height).isEqualTo(height)
-        assertThat(display.children).isEmpty()
+        verifyDisplay(topology.root!!, displayId, width, height, noOfChildren = 0)
     }
 
     @Test
@@ -258,11 +206,46 @@
         topology.removeDisplay(displayId2)
 
         assertThat(topology.primaryDisplayId).isEqualTo(displayId1)
-        val display = topology.root!!
-        assertThat(display.displayId).isEqualTo(displayId1)
-        assertThat(display.width).isEqualTo(width)
-        assertThat(display.height).isEqualTo(height)
-        assertThat(display.children).isEmpty()
+        verifyDisplay(topology.root!!, displayId1, width, height, noOfChildren = 0)
+    }
+
+    @Test
+    fun normalization_clampsOffsets() {
+        val display1 = DisplayTopology.TreeNode(/* displayId= */ 1, /* width= */ 200f,
+            /* height= */ 600f, /* position= */ 0, /* offset= */ 0f)
+
+        val display2 = DisplayTopology.TreeNode(/* displayId= */ 2, /* width= */ 600f,
+            /* height= */ 200f, POSITION_RIGHT, /* offset= */ 800f)
+        display1.addChild(display2)
+
+        val primaryDisplayId = 3
+        val display3 = DisplayTopology.TreeNode(primaryDisplayId, /* width= */ 600f,
+            /* height= */ 200f, POSITION_LEFT, /* offset= */ -300f)
+        display1.addChild(display3)
+
+        val display4 = DisplayTopology.TreeNode(/* displayId= */ 4, /* width= */ 200f,
+            /* height= */ 600f, POSITION_TOP, /* offset= */ 1000f)
+        display2.addChild(display4)
+
+        topology = DisplayTopology(display1, primaryDisplayId)
+        topology.normalize()
+
+        assertThat(topology.primaryDisplayId).isEqualTo(primaryDisplayId)
+
+        val actualDisplay1 = topology.root!!
+        verifyDisplay(actualDisplay1, id = 1, width = 200f, height = 600f, noOfChildren = 2)
+
+        val actualDisplay2 = actualDisplay1.children[0]
+        verifyDisplay(actualDisplay2, id = 2, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 600f, noOfChildren = 1)
+
+        val actualDisplay3 = actualDisplay1.children[1]
+        verifyDisplay(actualDisplay3, id = 3, width = 600f, height = 200f, POSITION_LEFT,
+            offset = -200f, noOfChildren = 0)
+
+        val actualDisplay4 = actualDisplay2.children[0]
+        verifyDisplay(actualDisplay4, id = 4, width = 200f, height = 600f, POSITION_TOP,
+            offset = 600f, noOfChildren = 0)
     }
 
     @Test
@@ -289,34 +272,19 @@
         assertThat(topology.primaryDisplayId).isEqualTo(primaryDisplayId)
 
         val actualDisplay1 = topology.root!!
-        assertThat(actualDisplay1.displayId).isEqualTo(1)
-        assertThat(actualDisplay1.width).isEqualTo(200f)
-        assertThat(actualDisplay1.height).isEqualTo(600f)
-        assertThat(actualDisplay1.children).hasSize(2)
+        verifyDisplay(actualDisplay1, id = 1, width = 200f, height = 600f, noOfChildren = 2)
 
         val actualDisplay2 = actualDisplay1.children[0]
-        assertThat(actualDisplay2.displayId).isEqualTo(2)
-        assertThat(actualDisplay2.width).isEqualTo(600f)
-        assertThat(actualDisplay2.height).isEqualTo(200f)
-        assertThat(actualDisplay2.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay2.offset).isEqualTo(0f)
-        assertThat(actualDisplay2.children).hasSize(1)
+        verifyDisplay(actualDisplay2, id = 2, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 1)
 
         val actualDisplay3 = actualDisplay1.children[1]
-        assertThat(actualDisplay3.displayId).isEqualTo(3)
-        assertThat(actualDisplay3.width).isEqualTo(600f)
-        assertThat(actualDisplay3.height).isEqualTo(200f)
-        assertThat(actualDisplay3.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay3.offset).isEqualTo(400f)
-        assertThat(actualDisplay3.children).isEmpty()
+        verifyDisplay(actualDisplay3, id = 3, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 400f, noOfChildren = 0)
 
         val actualDisplay4 = actualDisplay2.children[0]
-        assertThat(actualDisplay4.displayId).isEqualTo(4)
-        assertThat(actualDisplay4.width).isEqualTo(200f)
-        assertThat(actualDisplay4.height).isEqualTo(600f)
-        assertThat(actualDisplay4.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay4.offset).isEqualTo(0f)
-        assertThat(actualDisplay4.children).isEmpty()
+        verifyDisplay(actualDisplay4, id = 4, width = 200f, height = 600f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 0)
     }
 
     @Test
@@ -344,34 +312,19 @@
         assertThat(topology.primaryDisplayId).isEqualTo(primaryDisplayId)
 
         val actualDisplay1 = topology.root!!
-        assertThat(actualDisplay1.displayId).isEqualTo(1)
-        assertThat(actualDisplay1.width).isEqualTo(200f)
-        assertThat(actualDisplay1.height).isEqualTo(600f)
-        assertThat(actualDisplay1.children).hasSize(1)
+        verifyDisplay(actualDisplay1, id = 1, width = 200f, height = 600f, noOfChildren = 1)
 
         val actualDisplay2 = actualDisplay1.children[0]
-        assertThat(actualDisplay2.displayId).isEqualTo(2)
-        assertThat(actualDisplay2.width).isEqualTo(200f)
-        assertThat(actualDisplay2.height).isEqualTo(600f)
-        assertThat(actualDisplay2.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay2.offset).isEqualTo(0f)
-        assertThat(actualDisplay2.children).hasSize(2)
+        verifyDisplay(actualDisplay2, id = 2, width = 200f, height = 600f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 2)
 
         val actualDisplay3 = actualDisplay2.children[1]
-        assertThat(actualDisplay3.displayId).isEqualTo(3)
-        assertThat(actualDisplay3.width).isEqualTo(600f)
-        assertThat(actualDisplay3.height).isEqualTo(200f)
-        assertThat(actualDisplay3.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay3.offset).isEqualTo(10f)
-        assertThat(actualDisplay3.children).isEmpty()
+        verifyDisplay(actualDisplay3, id = 3, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 10f, noOfChildren = 0)
 
         val actualDisplay4 = actualDisplay2.children[0]
-        assertThat(actualDisplay4.displayId).isEqualTo(4)
-        assertThat(actualDisplay4.width).isEqualTo(200f)
-        assertThat(actualDisplay4.height).isEqualTo(600f)
-        assertThat(actualDisplay4.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay4.offset).isEqualTo(210f)
-        assertThat(actualDisplay4.children).isEmpty()
+        verifyDisplay(actualDisplay4, id = 4, width = 200f, height = 600f, POSITION_RIGHT,
+            offset = 210f, noOfChildren = 0)
     }
 
     @Test
@@ -397,26 +350,15 @@
         assertThat(topology.primaryDisplayId).isEqualTo(primaryDisplayId)
 
         val actualDisplay1 = topology.root!!
-        assertThat(actualDisplay1.displayId).isEqualTo(1)
-        assertThat(actualDisplay1.width).isEqualTo(200f)
-        assertThat(actualDisplay1.height).isEqualTo(50f)
-        assertThat(actualDisplay1.children).hasSize(1)
+        verifyDisplay(actualDisplay1, id = 1, width = 200f, height = 50f, noOfChildren = 1)
 
         val actualDisplay2 = actualDisplay1.children[0]
-        assertThat(actualDisplay2.displayId).isEqualTo(2)
-        assertThat(actualDisplay2.width).isEqualTo(600f)
-        assertThat(actualDisplay2.height).isEqualTo(200f)
-        assertThat(actualDisplay2.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay2.offset).isEqualTo(0f)
-        assertThat(actualDisplay2.children).hasSize(1)
+        verifyDisplay(actualDisplay2, id = 2, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 1)
 
         val actualDisplay3 = actualDisplay2.children[0]
-        assertThat(actualDisplay3.displayId).isEqualTo(3)
-        assertThat(actualDisplay3.width).isEqualTo(600f)
-        assertThat(actualDisplay3.height).isEqualTo(200f)
-        assertThat(actualDisplay3.position).isEqualTo(POSITION_BOTTOM)
-        assertThat(actualDisplay3.offset).isEqualTo(0f)
-        assertThat(actualDisplay3.children).isEmpty()
+        verifyDisplay(actualDisplay3, id = 3, width = 600f, height = 200f, POSITION_BOTTOM,
+            offset = 0f, noOfChildren = 0)
     }
 
     @Test
@@ -443,34 +385,19 @@
         assertThat(topology.primaryDisplayId).isEqualTo(primaryDisplayId)
 
         val actualDisplay1 = topology.root!!
-        assertThat(actualDisplay1.displayId).isEqualTo(1)
-        assertThat(actualDisplay1.width).isEqualTo(200f)
-        assertThat(actualDisplay1.height).isEqualTo(600f)
-        assertThat(actualDisplay1.children).hasSize(1)
+        verifyDisplay(actualDisplay1, id = 1, width = 200f, height = 600f, noOfChildren = 1)
 
         val actualDisplay2 = actualDisplay1.children[0]
-        assertThat(actualDisplay2.displayId).isEqualTo(2)
-        assertThat(actualDisplay2.width).isEqualTo(200f)
-        assertThat(actualDisplay2.height).isEqualTo(600f)
-        assertThat(actualDisplay2.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay2.offset).isEqualTo(0f)
-        assertThat(actualDisplay2.children).hasSize(1)
+        verifyDisplay(actualDisplay2, id = 2, width = 200f, height = 600f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 1)
 
         val actualDisplay3 = actualDisplay2.children[0]
-        assertThat(actualDisplay3.displayId).isEqualTo(3)
-        assertThat(actualDisplay3.width).isEqualTo(600f)
-        assertThat(actualDisplay3.height).isEqualTo(200f)
-        assertThat(actualDisplay3.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay3.offset).isEqualTo(400f)
-        assertThat(actualDisplay3.children).hasSize(1)
+        verifyDisplay(actualDisplay3, id = 3, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 400f, noOfChildren = 1)
 
         val actualDisplay4 = actualDisplay3.children[0]
-        assertThat(actualDisplay4.displayId).isEqualTo(4)
-        assertThat(actualDisplay4.width).isEqualTo(200f)
-        assertThat(actualDisplay4.height).isEqualTo(600f)
-        assertThat(actualDisplay4.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay4.offset).isEqualTo(-400f)
-        assertThat(actualDisplay4.children).isEmpty()
+        verifyDisplay(actualDisplay4, id = 4, width = 200f, height = 600f, POSITION_RIGHT,
+            offset = -400f, noOfChildren = 0)
     }
 
     @Test
@@ -635,7 +562,7 @@
             nodes,
             // In the case of corner adjacency, we prefer a left/right attachment.
             Pair(POSITION_RIGHT, 10f),
-            Pair(POSITION_BOTTOM, 40.5f), // TODO: fix implementation to remove this gap
+            Pair(POSITION_BOTTOM, 30f),
         )
 
         assertThat(nodes[0].children).containsExactly(nodes[1])
@@ -667,34 +594,19 @@
         assertThat(copy.primaryDisplayId).isEqualTo(primaryDisplayId)
 
         val actualDisplay1 = copy.root!!
-        assertThat(actualDisplay1.displayId).isEqualTo(1)
-        assertThat(actualDisplay1.width).isEqualTo(200f)
-        assertThat(actualDisplay1.height).isEqualTo(600f)
-        assertThat(actualDisplay1.children).hasSize(2)
+        verifyDisplay(actualDisplay1, id = 1, width = 200f, height = 600f, noOfChildren = 2)
 
         val actualDisplay2 = actualDisplay1.children[0]
-        assertThat(actualDisplay2.displayId).isEqualTo(2)
-        assertThat(actualDisplay2.width).isEqualTo(600f)
-        assertThat(actualDisplay2.height).isEqualTo(200f)
-        assertThat(actualDisplay2.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay2.offset).isEqualTo(0f)
-        assertThat(actualDisplay2.children).hasSize(1)
+        verifyDisplay(actualDisplay2, id = 2, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 1)
 
         val actualDisplay3 = actualDisplay1.children[1]
-        assertThat(actualDisplay3.displayId).isEqualTo(3)
-        assertThat(actualDisplay3.width).isEqualTo(600f)
-        assertThat(actualDisplay3.height).isEqualTo(200f)
-        assertThat(actualDisplay3.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay3.offset).isEqualTo(400f)
-        assertThat(actualDisplay3.children).isEmpty()
+        verifyDisplay(actualDisplay3, id = 3, width = 600f, height = 200f, POSITION_RIGHT,
+            offset = 400f, noOfChildren = 0)
 
         val actualDisplay4 = actualDisplay2.children[0]
-        assertThat(actualDisplay4.displayId).isEqualTo(4)
-        assertThat(actualDisplay4.width).isEqualTo(200f)
-        assertThat(actualDisplay4.height).isEqualTo(600f)
-        assertThat(actualDisplay4.position).isEqualTo(POSITION_RIGHT)
-        assertThat(actualDisplay4.offset).isEqualTo(0f)
-        assertThat(actualDisplay4.children).isEmpty()
+        verifyDisplay(actualDisplay4, id = 4, width = 200f, height = 600f, POSITION_RIGHT,
+            offset = 0f, noOfChildren = 0)
     }
 
     /**
@@ -722,9 +634,20 @@
         return nodes
     }
 
+    private fun verifyDisplay(display: DisplayTopology.TreeNode, id: Int, width: Float,
+                              height: Float, @DisplayTopology.TreeNode.Position position: Int = 0,
+                              offset: Float = 0f, noOfChildren: Int) {
+        assertThat(display.displayId).isEqualTo(id)
+        assertThat(display.width).isEqualTo(width)
+        assertThat(display.height).isEqualTo(height)
+        assertThat(display.position).isEqualTo(position)
+        assertThat(display.offset).isEqualTo(offset)
+        assertThat(display.children).hasSize(noOfChildren)
+    }
+
     private fun assertPositioning(
             nodes: List<DisplayTopology.TreeNode>, vararg positions: Pair<Int, Float>) {
-        assertThat(nodes.drop(1).map { Pair(it.position, it.offset )})
+        assertThat(nodes.drop(1).map { Pair(it.position, it.offset) })
             .containsExactly(*positions)
             .inOrder()
     }
diff --git a/core/tests/coretests/src/android/view/ViewGroupTest.java b/core/tests/coretests/src/android/view/ViewGroupTest.java
index ae3ad36..43c404e 100644
--- a/core/tests/coretests/src/android/view/ViewGroupTest.java
+++ b/core/tests/coretests/src/android/view/ViewGroupTest.java
@@ -213,6 +213,35 @@
         assertTrue(autofillableViews.containsAll(Arrays.asList(viewA, viewC)));
     }
 
+    @Test
+    public void testMeasureCache() {
+        final int spec1 = View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.AT_MOST);
+        final int spec2 = View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.AT_MOST);
+        final Context context = getInstrumentation().getContext();
+        final View child = new View(context);
+        final TestView parent = new TestView(context, 0);
+        parent.addView(child);
+
+        child.setPadding(1, 2, 3, 4);
+        parent.measure(spec1, spec1);
+        assertEquals(4, parent.getMeasuredWidth());
+        assertEquals(6, parent.getMeasuredHeight());
+
+        child.setPadding(5, 6, 7, 8);
+        parent.measure(spec2, spec2);
+        assertEquals(12, parent.getMeasuredWidth());
+        assertEquals(14, parent.getMeasuredHeight());
+
+        // This ends the state of forceLayout.
+        parent.layout(0, 0, 50, 50);
+
+        // The cached values should be cleared after the new setPadding is called. And the measured
+        // width and height should be up-to-date.
+        parent.measure(spec1, spec1);
+        assertEquals(12, parent.getMeasuredWidth());
+        assertEquals(14, parent.getMeasuredHeight());
+    }
+
     private static void getUnobscuredTouchableRegion(Region outRegion, View view) {
         outRegion.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
         final ViewParent parent = view.getParent();
@@ -240,6 +269,19 @@
         protected void onLayout(boolean changed, int l, int t, int r, int b) {
             // We don't layout this view.
         }
+
+        @Override
+        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+            int measuredWidth = 0;
+            int measuredHeight = 0;
+            final int count = getChildCount();
+            for (int i = 0; i < count; i++) {
+                final View child = getChildAt(i);
+                measuredWidth += child.getPaddingLeft() + child.getPaddingRight();
+                measuredHeight += child.getPaddingTop() + child.getPaddingBottom();
+            }
+            setMeasuredDimension(measuredWidth, measuredHeight);
+        }
     }
 
     public static class AutofillableTestView extends TestView {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
index 2128cbc..0d16880 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
@@ -26,6 +26,7 @@
 import android.graphics.Rect;
 import android.util.Pair;
 import android.view.LayoutInflater;
+import android.view.SurfaceControl;
 import android.view.View;
 import android.window.DesktopModeFlags;
 
@@ -70,6 +71,9 @@
 
     private final float mHideScmTolerance;
 
+    @NonNull
+    private final Rect mLayoutBounds = new Rect();
+
     CompatUIWindowManager(@NonNull Context context, @NonNull TaskInfo taskInfo,
                           @NonNull SyncTransactionQueue syncQueue,
                           @NonNull Consumer<CompatUIEvent> callback,
@@ -105,6 +109,7 @@
 
     @Override
     protected void removeLayout() {
+        mLayoutBounds.setEmpty();
         mLayout = null;
     }
 
@@ -171,18 +176,21 @@
     @Override
     @VisibleForTesting
     public void updateSurfacePosition() {
-        if (mLayout == null) {
+        updateLayoutBounds();
+        if (mLayoutBounds.isEmpty()) {
             return;
         }
-        // Position of the button in the container coordinate.
-        final Rect taskBounds = getTaskBounds();
-        final Rect taskStableBounds = getTaskStableBounds();
-        final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
-                ? taskStableBounds.left - taskBounds.left
-                : taskStableBounds.right - taskBounds.left - mLayout.getMeasuredWidth();
-        final int positionY = taskStableBounds.bottom - taskBounds.top
-                - mLayout.getMeasuredHeight();
-        updateSurfacePosition(positionX, positionY);
+        updateSurfacePosition(mLayoutBounds.left, mLayoutBounds.top);
+    }
+
+    @Override
+    @VisibleForTesting
+    public void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+        updateLayoutBounds();
+        if (mLayoutBounds.isEmpty()) {
+            return;
+        }
+        updateSurfaceBounds(tx, mLayoutBounds);
     }
 
     @VisibleForTesting
@@ -219,6 +227,23 @@
         return percentageAreaOfLetterboxInTask < mHideScmTolerance;
     }
 
+    private void updateLayoutBounds() {
+        if (mLayout == null) {
+            mLayoutBounds.setEmpty();
+            return;
+        }
+        // Position of the button in the container coordinate.
+        final Rect taskBounds = getTaskBounds();
+        final Rect taskStableBounds = getTaskStableBounds();
+        final int layoutWidth = mLayout.getMeasuredWidth();
+        final int layoutHeight = mLayout.getMeasuredHeight();
+        final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
+                ? taskStableBounds.left - taskBounds.left
+                : taskStableBounds.right - taskBounds.left - layoutWidth;
+        final int positionY = taskStableBounds.bottom - taskBounds.top - layoutHeight;
+        mLayoutBounds.set(positionX, positionY, positionX + layoutWidth, positionY + layoutHeight);
+    }
+
     private void updateVisibilityOfViews() {
         if (mLayout == null) {
             return;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
index d2b4f1a..82acfe5 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
@@ -43,6 +43,7 @@
 import android.view.WindowlessWindowManager;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.window.flags.Flags;
 import com.android.wm.shell.ShellTaskOrganizer;
 import com.android.wm.shell.common.DisplayLayout;
 import com.android.wm.shell.common.SyncTransactionQueue;
@@ -327,8 +328,15 @@
         if (mViewHost == null) {
             return;
         }
-        mViewHost.relayout(windowLayoutParams);
-        updateSurfacePosition();
+        if (Flags.appCompatAsyncRelayout()) {
+            mViewHost.relayout(windowLayoutParams, tx -> {
+                updateSurfacePosition(tx);
+                tx.apply();
+            });
+        } else {
+            mViewHost.relayout(windowLayoutParams);
+            updateSurfacePosition();
+        }
     }
 
     @NonNull
@@ -349,6 +357,10 @@
      */
     protected abstract void updateSurfacePosition();
 
+    protected void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+
+    }
+
     /**
      * Updates the position of the surface with respect to the given {@code positionX} and {@code
      * positionY}.
@@ -366,6 +378,15 @@
         });
     }
 
+    protected void updateSurfaceBounds(@NonNull SurfaceControl.Transaction tx,
+            @NonNull Rect bounds) {
+        if (mLeash == null) {
+            return;
+        }
+        tx.setPosition(mLeash, bounds.left, bounds.top)
+                .setWindowCrop(mLeash, bounds.width(), bounds.height());
+    }
+
     protected int getLayoutDirection() {
         return mContext.getResources().getConfiguration().getLayoutDirection();
     }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java
index 3f67172..650d2170 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java
@@ -27,6 +27,7 @@
 import android.graphics.Rect;
 import android.os.SystemClock;
 import android.view.LayoutInflater;
+import android.view.SurfaceControl;
 import android.view.View;
 import android.view.accessibility.AccessibilityManager;
 
@@ -69,6 +70,9 @@
     @NonNull
     final CompatUIHintsState mCompatUIHintsState;
 
+    @NonNull
+    private final Rect mLayoutBounds = new Rect();
+
     @Nullable
     private UserAspectRatioSettingsLayout mLayout;
 
@@ -108,6 +112,7 @@
 
     @Override
     protected void removeLayout() {
+        mLayoutBounds.setEmpty();
         mLayout = null;
     }
 
@@ -168,18 +173,21 @@
     @Override
     @VisibleForTesting
     public void updateSurfacePosition() {
-        if (mLayout == null) {
+        updateLayoutBounds();
+        if (mLayoutBounds.isEmpty()) {
             return;
         }
-        // Position of the button in the container coordinate.
-        final Rect taskBounds = getTaskBounds();
-        final Rect taskStableBounds = getTaskStableBounds();
-        final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
-                ? taskStableBounds.left - taskBounds.left
-                : taskStableBounds.right - taskBounds.left - mLayout.getMeasuredWidth();
-        final int positionY = taskStableBounds.bottom - taskBounds.top
-                - mLayout.getMeasuredHeight();
-        updateSurfacePosition(positionX, positionY);
+        updateSurfacePosition(mLayoutBounds.left, mLayoutBounds.top);
+    }
+
+    @Override
+    @VisibleForTesting
+    public void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+        updateLayoutBounds();
+        if (mLayoutBounds.isEmpty()) {
+            return;
+        }
+        updateSurfaceBounds(tx, mLayoutBounds);
     }
 
     @VisibleForTesting
@@ -202,6 +210,23 @@
                 && !isHideDelayReached(mNextButtonHideTimeMs));
     }
 
+    private void updateLayoutBounds() {
+        if (mLayout == null) {
+            mLayoutBounds.setEmpty();
+            return;
+        }
+        // Position of the button in the container coordinate.
+        final Rect taskBounds = getTaskBounds();
+        final Rect taskStableBounds = getTaskStableBounds();
+        final int layoutWidth = mLayout.getMeasuredWidth();
+        final int layoutHeight = mLayout.getMeasuredHeight();
+        final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
+                ? taskStableBounds.left - taskBounds.left
+                : taskStableBounds.right - taskBounds.left - layoutWidth;
+        final int positionY = taskStableBounds.bottom - taskBounds.top - layoutHeight;
+        mLayoutBounds.set(positionX, positionY, positionX + layoutWidth, positionY + layoutHeight);
+    }
+
     private void showUserAspectRatioButton() {
         if (mLayout == null) {
             return;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt
index 36904fb5..7764688 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt
@@ -23,6 +23,7 @@
 import android.os.IBinder
 import android.view.SurfaceControl
 import android.view.WindowManager
+import android.view.WindowManager.TRANSIT_CLOSE
 import android.view.WindowManager.TRANSIT_OPEN
 import android.window.DesktopModeFlags
 import android.window.TransitionInfo
@@ -197,8 +198,9 @@
             logW("Should have closing desktop task")
             return false
         }
-        if (isLastDesktopTask(closeChange)) {
-            // Dispatch close desktop task animation to the default transition handlers.
+        if (isWallpaperActivityClosing(info)) {
+            // If the wallpaper activity is closing then the desktop is closing, animate the closing
+            // desktop by dispatching to other transition handlers.
             return dispatchCloseLastDesktopTaskAnimation(
                 transition,
                 info,
@@ -419,10 +421,12 @@
         ) != null
     }
 
-    private fun isLastDesktopTask(change: TransitionInfo.Change): Boolean =
-        change.taskInfo?.let {
-            desktopUserRepositories.getProfile(it.userId).getExpandedTaskCount(it.displayId) == 1
-        } ?: false
+    private fun isWallpaperActivityClosing(info: TransitionInfo) =
+        info.changes.any { change ->
+            change.mode == TRANSIT_CLOSE &&
+                change.taskInfo != null &&
+                DesktopWallpaperActivity.isWallpaperTask(change.taskInfo!!)
+        }
 
     private fun findCloseDesktopTaskChange(info: TransitionInfo): TransitionInfo.Change? {
         if (info.type != WindowManager.TRANSIT_CLOSE) return null
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt
index 267bbb6..49a7e29 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt
@@ -21,6 +21,7 @@
 import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
 import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
 import android.app.WindowConfiguration.WindowingMode
+import android.content.Intent
 import android.os.Binder
 import android.os.Handler
 import android.os.IBinder
@@ -36,7 +37,9 @@
 import android.view.WindowManager.TRANSIT_OPEN
 import android.view.WindowManager.TRANSIT_TO_BACK
 import android.view.WindowManager.TransitionType
+import android.window.IWindowContainerToken
 import android.window.TransitionInfo
+import android.window.WindowContainerToken
 import android.window.WindowContainerTransaction
 import androidx.test.filters.SmallTest
 import com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE
@@ -188,7 +191,7 @@
     fun startAnimation_withoutClosingDesktopTask_returnsFalse() {
         val transition = mock<IBinder>()
         val transitionInfo =
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 changeMode = TRANSIT_OPEN,
                 task = createTask(WINDOWING_MODE_FREEFORM)
             )
@@ -213,8 +216,7 @@
     fun startAnimation_withClosingDesktopTask_callsCloseTaskHandler() {
         val wct = WindowContainerTransaction()
         val transition = mock<IBinder>()
-        val transitionInfo = createTransitionInfo(task = createTask(WINDOWING_MODE_FREEFORM))
-        whenever(desktopRepository.getExpandedTaskCount(any())).thenReturn(2)
+        val transitionInfo = createCloseTransitionInfo(task = createTask(WINDOWING_MODE_FREEFORM))
         whenever(
                 closeDesktopTaskTransitionHandler.startAnimation(any(), any(), any(), any(), any())
             )
@@ -243,8 +245,8 @@
     fun startAnimation_withClosingLastDesktopTask_dispatchesTransition() {
         val wct = WindowContainerTransaction()
         val transition = mock<IBinder>()
-        val transitionInfo = createTransitionInfo(task = createTask(WINDOWING_MODE_FREEFORM))
-        whenever(desktopRepository.getExpandedTaskCount(any())).thenReturn(1)
+        val transitionInfo = createCloseTransitionInfo(
+            task = createTask(WINDOWING_MODE_FREEFORM), withWallpaper = true)
         whenever(transitions.dispatchTransition(any(), any(), any(), any(), any(), any()))
             .thenReturn(mock())
         whenever(transitions.startTransition(WindowManager.TRANSIT_CLOSE, wct, mixedHandler))
@@ -355,7 +357,7 @@
         val otherChange = createChange(createTask(WINDOWING_MODE_FREEFORM))
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange, otherChange)
             ),
@@ -395,7 +397,7 @@
         val immersiveChange = createChange(immersiveTask)
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange, immersiveChange)
             ),
@@ -437,7 +439,7 @@
         )
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange)
             ),
@@ -471,7 +473,7 @@
         )
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange, minimizeChange)
             ),
@@ -505,7 +507,7 @@
 
         val started = mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(nonLaunchTaskChange)
             ),
@@ -535,7 +537,7 @@
 
         val started = mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(createChange(task, mode = TRANSIT_OPEN))
             ),
@@ -569,7 +571,7 @@
         val openingChange = createChange(openingTask, mode = TRANSIT_OPEN)
         val started = mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(immersiveChange, openingChange)
             ),
@@ -604,7 +606,7 @@
         )
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange)
             ),
@@ -640,7 +642,7 @@
         )
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange, minimizeChange)
             ),
@@ -670,7 +672,7 @@
         val launchTaskChange = createChange(launchingTask)
         mixedHandler.startAnimation(
             transition,
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_OPEN,
                 listOf(launchTaskChange)
             ),
@@ -727,7 +729,7 @@
         val started = mixedHandler.startAnimation(
             transition = transition,
             info =
-                createTransitionInfo(
+                createCloseTransitionInfo(
                 TRANSIT_TO_BACK,
                 listOf(minimizingTaskChange)
             ),
@@ -766,7 +768,7 @@
         mixedHandler.startAnimation(
             transition = transition,
             info =
-            createTransitionInfo(
+            createCloseTransitionInfo(
                 TRANSIT_TO_BACK,
                 listOf(minimizingTaskChange)
             ),
@@ -786,12 +788,12 @@
             )
     }
 
-    private fun createTransitionInfo(
-        type: Int = WindowManager.TRANSIT_CLOSE,
+    private fun createCloseTransitionInfo(
         changeMode: Int = WindowManager.TRANSIT_CLOSE,
-        task: RunningTaskInfo
+        task: RunningTaskInfo,
+        withWallpaper: Boolean = false,
     ): TransitionInfo =
-        TransitionInfo(type, 0 /* flags */).apply {
+        TransitionInfo(WindowManager.TRANSIT_CLOSE, 0 /* flags */).apply {
             addChange(
                 TransitionInfo.Change(mock(), closingTaskLeash).apply {
                     mode = changeMode
@@ -799,9 +801,18 @@
                     taskInfo = task
                 }
             )
+            if (withWallpaper) {
+                addChange(
+                    TransitionInfo.Change(/* container= */ mock(), /* leash= */ mock()).apply {
+                        mode = WindowManager.TRANSIT_CLOSE
+                        parent = null
+                        taskInfo = createWallpaperTask()
+                    }
+                )
+            }
         }
 
-    private fun createTransitionInfo(
+    private fun createCloseTransitionInfo(
         @TransitionType type: Int,
         changes: List<TransitionInfo.Change> = emptyList()
     ): TransitionInfo = TransitionInfo(type, /* flags= */ 0).apply {
@@ -822,4 +833,13 @@
             .setActivityType(ACTIVITY_TYPE_STANDARD)
             .setWindowingMode(windowingMode)
             .build()
+
+    private fun createWallpaperTask() =
+        RunningTaskInfo().apply {
+            token = WindowContainerToken(mock<IWindowContainerToken>())
+            baseIntent =
+                Intent().apply {
+                    component = DesktopWallpaperActivity.wallpaperActivityComponent
+                }
+        }
 }
diff --git a/media/java/android/media/AudioFormat.java b/media/java/android/media/AudioFormat.java
index 0da8371b..c72a74e 100644
--- a/media/java/android/media/AudioFormat.java
+++ b/media/java/android/media/AudioFormat.java
@@ -714,7 +714,7 @@
     /**
      * @hide
      * Return a channel mask ready to be used by native code
-     * @param mask a combination of the CHANNEL_OUT_* definitions, but not CHANNEL_OUT_DEFAULT
+     * @param javaMask a combination of the CHANNEL_OUT_* definitions, but not CHANNEL_OUT_DEFAULT
      * @return a native channel mask
      */
     public static int convertChannelOutMaskToNativeMask(int javaMask) {
@@ -724,13 +724,98 @@
     /**
      * @hide
      * Return a java output channel mask
-     * @param mask a native channel mask
+     * @param nativeMask a native channel mask
      * @return a combination of the CHANNEL_OUT_* definitions
      */
     public static int convertNativeChannelMaskToOutMask(int nativeMask) {
         return (nativeMask << 2);
     }
 
+    /**
+     * @hide
+     * Return a human-readable string from a java channel mask
+     * @param javaMask a bit field of CHANNEL_OUT_* values
+     * @return a string in the "mono", "stereo", "5.1" style, or the hex version when not a standard
+     *   mask.
+     */
+    public static String javaChannelOutMaskToString(int javaMask) {
+        // save haptics info for end of string
+        int haptics = javaMask & (CHANNEL_OUT_HAPTIC_A | CHANNEL_OUT_HAPTIC_B);
+        // continue without looking at haptic channels
+        javaMask &= ~(CHANNEL_OUT_HAPTIC_A | CHANNEL_OUT_HAPTIC_B);
+        StringBuilder result = new StringBuilder("");
+        switch (javaMask) {
+            case CHANNEL_OUT_MONO:
+                result.append("mono");
+                break;
+            case CHANNEL_OUT_STEREO:
+                result.append("stereo");
+                break;
+            case CHANNEL_OUT_QUAD:
+                result.append("quad");
+                break;
+            case CHANNEL_OUT_QUAD_SIDE:
+                result.append("quad side");
+                break;
+            case CHANNEL_OUT_SURROUND:
+                result.append("4.0");
+                break;
+            case CHANNEL_OUT_5POINT1:
+                result.append("5.1");
+                break;
+            case CHANNEL_OUT_6POINT1:
+                result.append("6.1");
+                break;
+            case CHANNEL_OUT_5POINT1_SIDE:
+                result.append("5.1 side");
+                break;
+            case CHANNEL_OUT_7POINT1:
+                result.append("7.1 (5 fronts)");
+                break;
+            case CHANNEL_OUT_7POINT1_SURROUND:
+                result.append("7.1");
+                break;
+            case CHANNEL_OUT_5POINT1POINT2:
+                result.append("5.1.2");
+                break;
+            case CHANNEL_OUT_5POINT1POINT4:
+                result.append("5.1.4");
+                break;
+            case CHANNEL_OUT_7POINT1POINT2:
+                result.append("7.1.2");
+                break;
+            case CHANNEL_OUT_7POINT1POINT4:
+                result.append("7.1.4");
+                break;
+            case CHANNEL_OUT_9POINT1POINT4:
+                result.append("9.1.4");
+                break;
+            case CHANNEL_OUT_9POINT1POINT6:
+                result.append("9.1.6");
+                break;
+            case CHANNEL_OUT_13POINT_360RA:
+                result.append("360RA 13ch");
+                break;
+            case CHANNEL_OUT_22POINT2:
+                result.append("22.2");
+                break;
+            default:
+                result.append("0x").append(Integer.toHexString(javaMask));
+                break;
+        }
+        if ((haptics & (CHANNEL_OUT_HAPTIC_A | CHANNEL_OUT_HAPTIC_B)) != 0) {
+            result.append("(+haptic ");
+            if ((haptics & CHANNEL_OUT_HAPTIC_A) == CHANNEL_OUT_HAPTIC_A) {
+                result.append("A");
+            }
+            if ((haptics & CHANNEL_OUT_HAPTIC_B) == CHANNEL_OUT_HAPTIC_B) {
+                result.append("B");
+            }
+            result.append(")");
+        }
+        return result.toString();
+    }
+
     public static final int CHANNEL_IN_DEFAULT = 1;
     // These directly match native
     public static final int CHANNEL_IN_LEFT = 0x4;
diff --git a/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl b/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl
index 63c52a1..8f03057 100644
--- a/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl
+++ b/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl
@@ -25,7 +25,6 @@
  * @hide
  */
 oneway interface IMediaRoute2ProviderServiceCallback {
-    // TODO: Change it to updateRoutes?
     void notifyProviderUpdated(in MediaRoute2ProviderInfo providerInfo);
     void notifySessionCreated(long requestId, in RoutingSessionInfo sessionInfo);
     void notifySessionsUpdated(in List<RoutingSessionInfo> sessionInfo);
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
new file mode 100644
index 0000000..7a64965
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
@@ -0,0 +1,408 @@
+/*
+ * 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.bluetooth;
+
+import static com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data.INVALID_VOLUME;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.util.ArrayMap;
+import android.util.KeyValueListParser;
+import android.util.Log;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+/**
+ * The class to manage hearing device local data from Settings.
+ *
+ * <p><b>Note:</b> Before calling any methods to get or change the local data, you must first call
+ * the {@code start()} method to load the data from Settings. Whenever the data is modified, you
+ * must call the {@code stop()} method to save the data into Settings. After calling {@code stop()},
+ * you should not call any methods to get or change the local data without again calling
+ * {@code start()}.
+ */
+public class HearingDeviceLocalDataManager {
+    private static final String TAG = "HearingDeviceDataMgr";
+    private static final boolean DEBUG = true;
+
+    /** Interface for listening hearing device local data changed */
+    public interface OnDeviceLocalDataChangeListener {
+        /**
+         * The method is called when the local data of the device with the address is changed.
+         *
+         * @param address the device anonymized address
+         * @param data    the updated data
+         */
+        void onDeviceLocalDataChange(@NonNull String address, @Nullable Data data);
+    }
+
+    static final String KEY_ADDR = "addr";
+    static final String KEY_AMBIENT = "ambient";
+    static final String KEY_GROUP_AMBIENT = "group_ambient";
+    static final String KEY_AMBIENT_CONTROL_EXPANDED = "control_expanded";
+    static final String LOCAL_AMBIENT_VOLUME_SETTINGS =
+            Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME;
+
+    private static final Object sLock = new Object();
+
+    private final Context mContext;
+    private Executor mListenerExecutor;
+    @GuardedBy("sLock")
+    private final Map<String, Data> mAddrToDataMap = new HashMap<>();
+    private OnDeviceLocalDataChangeListener mListener;
+    private SettingsObserver mSettingsObserver;
+    private boolean mIsStarted = false;
+
+    public HearingDeviceLocalDataManager(@NonNull Context context) {
+        mContext = context;
+        mSettingsObserver = new SettingsObserver(ThreadUtils.getUiThreadHandler());
+    }
+
+    /** Starts the manager. Loads the data from Settings and start observing any changes. */
+    public synchronized void start() {
+        if (mIsStarted) {
+            return;
+        }
+        mIsStarted = true;
+        getLocalDataFromSettings();
+        mSettingsObserver.register(mContext.getContentResolver());
+    }
+
+    /** Stops the manager. Flushes the data into Settings and stop observing. */
+    public synchronized void stop() {
+        if (!mIsStarted) {
+            return;
+        }
+        putAmbientVolumeSettings();
+        mSettingsObserver.unregister(mContext.getContentResolver());
+        mIsStarted = false;
+    }
+
+    /**
+     * Sets a listener which will be be notified when hearing device local data is changed.
+     *
+     * @param listener the listener to be notified
+     * @param executor the executor to run the
+     *                 {@link OnDeviceLocalDataChangeListener#onDeviceLocalDataChange(String,
+     *                 Data)} callback
+     */
+    public void setOnDeviceLocalDataChangeListener(
+            @NonNull OnDeviceLocalDataChangeListener listener, @NonNull Executor executor) {
+        mListener = listener;
+        mListenerExecutor = executor;
+    }
+
+    /**
+     * Gets the local data of the corresponding hearing device. This should be called after
+     * {@link #start()} is called().
+     *
+     * @param device the device to query the local data
+     */
+    @NonNull
+    public Data get(@NonNull BluetoothDevice device) {
+        if (!mIsStarted) {
+            Log.w(TAG, "Manager is not started. Please call start() first.");
+            return new Data();
+        }
+        synchronized (sLock) {
+            return mAddrToDataMap.getOrDefault(device.getAnonymizedAddress(), new Data());
+        }
+    }
+
+    /**
+     * Puts the local data of the corresponding hearing device.
+     *
+     * @param device the device to update the local data
+     */
+    private void put(BluetoothDevice device, Data data) {
+        if (device == null) {
+            return;
+        }
+        synchronized (sLock) {
+            final String addr = device.getAnonymizedAddress();
+            mAddrToDataMap.put(addr, data);
+            if (mListener != null && mListenerExecutor != null) {
+                mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, data));
+            }
+        }
+    }
+
+    /**
+     * Updates the ambient volume of the corresponding hearing device. This should be called after
+     * {@link #start()} is called().
+     *
+     * @param device the device to update
+     * @param value  the ambient value
+     * @return if the local data is updated
+     */
+    public boolean updateAmbient(@Nullable BluetoothDevice device, int value) {
+        if (!mIsStarted) {
+            Log.w(TAG, "Manager is not started. Please call start() first.");
+            return false;
+        }
+        if (device == null) {
+            return false;
+        }
+        synchronized (sLock) {
+            Data data = get(device);
+            if (value == data.ambient) {
+                return false;
+            }
+            put(device, new Data.Builder(data).ambient(value).build());
+            return true;
+        }
+    }
+
+    /**
+     * Updates the group ambient volume of the corresponding hearing device. This should be called
+     * after {@link #start()} is called().
+     *
+     * @param device the device to update
+     * @param value  the group ambient value
+     * @return if the local data is updated
+     */
+    public boolean updateGroupAmbient(@Nullable BluetoothDevice device, int value) {
+        if (!mIsStarted) {
+            Log.w(TAG, "Manager is not started. Please call start() first.");
+            return false;
+        }
+        if (device == null) {
+            return false;
+        }
+        synchronized (sLock) {
+            Data data = get(device);
+            if (value == data.groupAmbient) {
+                return false;
+            }
+            put(device, new Data.Builder(data).groupAmbient(value).build());
+            return true;
+        }
+    }
+
+    /**
+     * Updates the ambient control is expanded or not of the corresponding hearing device. This
+     * should be called after {@link #start()} is called().
+     *
+     * @param device   the device to update
+     * @param expanded the ambient control is expanded or not
+     * @return if the local data is updated
+     */
+    public boolean updateAmbientControlExpanded(@Nullable BluetoothDevice device,
+            boolean expanded) {
+        if (!mIsStarted) {
+            Log.w(TAG, "Manager is not started. Please call start() first.");
+            return false;
+        }
+        if (device == null) {
+            return false;
+        }
+        synchronized (sLock) {
+            Data data = get(device);
+            if (expanded == data.ambientControlExpanded) {
+                return false;
+            }
+            put(device, new Data.Builder(data).ambientControlExpanded(expanded).build());
+            return true;
+        }
+    }
+
+    void getLocalDataFromSettings() {
+        synchronized (sLock) {
+            Map<String, Data> updatedAddrToDataMap = parseFromSettings();
+            notifyIfDataChanged(mAddrToDataMap, updatedAddrToDataMap);
+            mAddrToDataMap.clear();
+            mAddrToDataMap.putAll(updatedAddrToDataMap);
+            if (DEBUG) {
+                Log.v(TAG, "getLocalDataFromSettings, " + mAddrToDataMap + ", manager: " + this);
+            }
+        }
+    }
+
+    void putAmbientVolumeSettings() {
+        synchronized (sLock) {
+            StringBuilder builder = new StringBuilder();
+            for (Map.Entry<String, Data> entry : mAddrToDataMap.entrySet()) {
+                builder.append(KEY_ADDR).append("=").append(entry.getKey());
+                builder.append(entry.getValue().toSettingsFormat()).append(";");
+            }
+            if (DEBUG) {
+                Log.v(TAG, "putAmbientVolumeSettings, " + builder + ", manager: " + this);
+            }
+            Settings.Global.putStringForUser(mContext.getContentResolver(),
+                    LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(),
+                    UserHandle.USER_SYSTEM);
+        }
+    }
+
+    @GuardedBy("sLock")
+    private Map<String, Data> parseFromSettings() {
+        String settings = Settings.Global.getStringForUser(mContext.getContentResolver(),
+                LOCAL_AMBIENT_VOLUME_SETTINGS, UserHandle.USER_SYSTEM);
+        Map<String, Data> addrToDataMap = new ArrayMap<>();
+        if (settings != null && !settings.isEmpty()) {
+            String[] localDataArray = settings.split(";");
+            for (String localData : localDataArray) {
+                KeyValueListParser parser = new KeyValueListParser(',');
+                parser.setString(localData);
+                String address = parser.getString(KEY_ADDR, "");
+                if (!address.isEmpty()) {
+                    Data data = new Data.Builder()
+                            .ambient(parser.getInt(KEY_AMBIENT, INVALID_VOLUME))
+                            .groupAmbient(parser.getInt(KEY_GROUP_AMBIENT, INVALID_VOLUME))
+                            .ambientControlExpanded(
+                                    parser.getBoolean(KEY_AMBIENT_CONTROL_EXPANDED, false))
+                            .build();
+                    addrToDataMap.put(address, data);
+                }
+            }
+        }
+        return addrToDataMap;
+    }
+
+    @GuardedBy("sLock")
+    private void notifyIfDataChanged(Map<String, Data> oldAddrToDataMap,
+            Map<String, Data> newAddrToDataMap) {
+        newAddrToDataMap.forEach((addr, data) -> {
+            Data oldData = oldAddrToDataMap.get(addr);
+            if (oldData == null || !oldData.equals(data)) {
+                if (mListener != null) {
+                    mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, data));
+                }
+            }
+        });
+    }
+
+    private final class SettingsObserver extends ContentObserver {
+        private final Uri mAmbientVolumeUri = Settings.Global.getUriFor(
+                LOCAL_AMBIENT_VOLUME_SETTINGS);
+
+        SettingsObserver(Handler handler) {
+            super(handler);
+        }
+
+        void register(ContentResolver contentResolver) {
+            contentResolver.registerContentObserver(mAmbientVolumeUri, false, this,
+                    UserHandle.USER_SYSTEM);
+        }
+
+        void unregister(ContentResolver contentResolver) {
+            contentResolver.unregisterContentObserver(this);
+        }
+
+        @Override
+        public void onChange(boolean selfChange, @Nullable Uri uri) {
+            if (mAmbientVolumeUri.equals(uri)) {
+                Log.v(TAG, "Local data on change, manager: " + HearingDeviceLocalDataManager.this);
+                getLocalDataFromSettings();
+            }
+        }
+    }
+
+    public record Data(int ambient, int groupAmbient, boolean ambientControlExpanded) {
+
+        public static int INVALID_VOLUME = Integer.MIN_VALUE;
+
+        private Data() {
+            this(INVALID_VOLUME, INVALID_VOLUME, false);
+        }
+
+        /**
+         * Return {@code true} if one of {@link #ambient} or {@link #groupAmbient} is assigned to
+         * a valid value.
+         */
+        public boolean hasAmbientData() {
+            return ambient != INVALID_VOLUME || groupAmbient != INVALID_VOLUME;
+        }
+
+        /**
+         * @return the composed string which is used to store the local data in
+         * {@link Settings.Global#HEARING_DEVICE_LOCAL_AMBIENT_VOLUME}
+         */
+        @NonNull
+        public String toSettingsFormat() {
+            String string = "";
+            if (ambient != INVALID_VOLUME) {
+                string += ("," + KEY_AMBIENT + "=" + ambient);
+            }
+            if (groupAmbient != INVALID_VOLUME) {
+                string += ("," + KEY_GROUP_AMBIENT + "=" + groupAmbient);
+            }
+            string += ("," + KEY_AMBIENT_CONTROL_EXPANDED + "=" + ambientControlExpanded);
+            return string;
+        }
+
+        /** Builder for a Data object */
+        public static final class Builder {
+            private int mAmbient;
+            private int mGroupAmbient;
+            private boolean mAmbientControlExpanded;
+
+            public Builder() {
+                this.mAmbient = INVALID_VOLUME;
+                this.mGroupAmbient = INVALID_VOLUME;
+                this.mAmbientControlExpanded = false;
+            }
+
+            public Builder(@NonNull Data other) {
+                this.mAmbient = other.ambient;
+                this.mGroupAmbient = other.groupAmbient;
+                this.mAmbientControlExpanded = other.ambientControlExpanded;
+            }
+
+            /** Sets the ambient volume */
+            @NonNull
+            public Builder ambient(int ambient) {
+                this.mAmbient = ambient;
+                return this;
+            }
+
+            /** Sets the group ambient volume */
+            @NonNull
+            public Builder groupAmbient(int groupAmbient) {
+                this.mGroupAmbient = groupAmbient;
+                return this;
+            }
+
+            /** Sets the ambient control expanded */
+            @NonNull
+            public Builder ambientControlExpanded(boolean ambientControlExpanded) {
+                this.mAmbientControlExpanded = ambientControlExpanded;
+                return this;
+            }
+
+            /** Build the Data object */
+            @NonNull
+            public Data build() {
+                return new Data(mAmbient, mGroupAmbient, mAmbientControlExpanded);
+            }
+        }
+    }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/VolumeControlProfile.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/VolumeControlProfile.java
index ab7a3db..d85b92f 100644
--- a/packages/SettingsLib/src/com/android/settingslib/bluetooth/VolumeControlProfile.java
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/VolumeControlProfile.java
@@ -21,6 +21,7 @@
 
 import android.annotation.CallbackExecutor;
 import android.annotation.IntRange;
+import android.bluetooth.AudioInputControl;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothClass;
 import android.bluetooth.BluetoothDevice;
@@ -34,6 +35,7 @@
 import androidx.annotation.RequiresApi;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Executor;
 
@@ -168,6 +170,7 @@
         }
         mService.setVolumeOffset(device, volumeOffset);
     }
+
     /**
      * Provides information about the possibility to set volume offset on the remote device. If the
      * remote device supports Volume Offset Control Service, it is automatically connected.
@@ -210,6 +213,22 @@
         mService.setDeviceVolume(device, volume, isGroupOp);
     }
 
+    /**
+     * Returns a list of {@link AudioInputControl} objects associated with a Bluetooth device.
+     *
+     * @param device The remote Bluetooth device.
+     * @return A list of {@link AudioInputControl} objects, or an empty list if no AICS instances
+     *     are found or if an error occurs.
+     * @hide
+     */
+    public @NonNull List<AudioInputControl> getAudioInputControlServices(
+            @NonNull BluetoothDevice device) {
+        if (mService == null) {
+            return Collections.emptyList();
+        }
+        return mService.getAudioInputControlServices(device);
+    }
+
     @Override
     public boolean accessProfileEnabled() {
         return false;
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt b/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt
index 23be7ba..496c3e6 100644
--- a/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt
@@ -74,10 +74,6 @@
         mutableModesFlow.value = mutableModesFlow.value.filter { it.id != id }
     }
 
-    fun replaceMode(modeId: String, mode: ZenMode) {
-        mutableModesFlow.value = (mutableModesFlow.value.filter { it.id != modeId }) + mode
-    }
-
     fun clearModes() {
         mutableModesFlow.value = listOf()
     }
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java b/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java
index 6842d0a..abc1638 100644
--- a/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java
@@ -41,32 +41,24 @@
     private String mId;
     private AutomaticZenRule mRule;
     private ZenModeConfig.ZenRule mConfigZenRule;
+    private boolean mIsManualDnd;
 
     public static final ZenMode EXAMPLE = new TestModeBuilder().build();
 
-    public static final ZenMode MANUAL_DND_ACTIVE = manualDnd(Uri.EMPTY,
+    public static final ZenMode MANUAL_DND_ACTIVE = manualDnd(
             INTERRUPTION_FILTER_PRIORITY, true);
 
-    public static final ZenMode MANUAL_DND_INACTIVE = manualDnd(Uri.EMPTY,
+    public static final ZenMode MANUAL_DND_INACTIVE = manualDnd(
             INTERRUPTION_FILTER_PRIORITY, false);
 
     @NonNull
     public static ZenMode manualDnd(@NotificationManager.InterruptionFilter int filter,
             boolean isActive) {
-        return manualDnd(Uri.EMPTY, filter, isActive);
-    }
-
-    private static ZenMode manualDnd(Uri conditionId,
-            @NotificationManager.InterruptionFilter int filter, boolean isActive) {
-        return ZenMode.manualDndMode(
-                new AutomaticZenRule.Builder("Do Not Disturb", conditionId)
-                        .setInterruptionFilter(filter)
-                        .setType(AutomaticZenRule.TYPE_OTHER)
-                        .setManualInvocationAllowed(true)
-                        .setPackage(SystemZenRules.PACKAGE_ANDROID)
-                        .setZenPolicy(new ZenPolicy.Builder().disallowAllSounds().build())
-                        .build(),
-                isActive);
+        return new TestModeBuilder()
+                .makeManualDnd()
+                .setInterruptionFilter(filter)
+                .setActive(isActive)
+                .build();
     }
 
     public TestModeBuilder() {
@@ -91,6 +83,10 @@
         mConfigZenRule.enabled = previous.getRule().isEnabled();
         mConfigZenRule.pkg = previous.getRule().getPackageName();
         setActive(previous.isActive());
+
+        if (previous.isManualDnd()) {
+            makeManualDnd();
+        }
     }
 
     public TestModeBuilder setId(String id) {
@@ -222,7 +218,25 @@
         return this;
     }
 
+    public TestModeBuilder makeManualDnd() {
+        mIsManualDnd = true;
+        // Set the "fixed" properties of a DND mode. Other things, such as policy/filter may be set
+        // separately or copied from a preexisting DND, so they are not overwritten here.
+        setId(ZenMode.MANUAL_DND_MODE_ID);
+        setName("Do Not Disturb");
+        setType(AutomaticZenRule.TYPE_OTHER);
+        setManualInvocationAllowed(true);
+        setPackage(SystemZenRules.PACKAGE_ANDROID);
+        setConditionId(Uri.EMPTY);
+        return this;
+    }
+
     public ZenMode build() {
-        return new ZenMode(mId, mRule, mConfigZenRule);
+        if (mIsManualDnd) {
+            return ZenMode.manualDndMode(mRule, mConfigZenRule.condition != null
+                    && mConfigZenRule.condition.state == Condition.STATE_TRUE);
+        } else {
+            return new ZenMode(mId, mRule, mConfigZenRule);
+        }
     }
 }
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
new file mode 100644
index 0000000..b659c02
--- /dev/null
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.bluetooth;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.UserHandle;
+import android.provider.Settings;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadows.ShadowSettings;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Tests for {@link HearingDeviceLocalDataManager}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {HearingDeviceLocalDataManagerTest.ShadowGlobal.class})
+public class HearingDeviceLocalDataManagerTest {
+
+    private static final String TEST_ADDRESS = "XX:XX:XX:XX:11:22";
+    private static final int TEST_AMBIENT = 10;
+    private static final int TEST_GROUP_AMBIENT = 20;
+    private static final boolean TEST_AMBIENT_CONTROL_EXPANDED = true;
+    private static final int TEST_UPDATED_AMBIENT = 30;
+    private static final int TEST_UPDATED_GROUP_AMBIENT = 40;
+    private static final boolean TEST_UPDATED_AMBIENT_CONTROL_EXPANDED = false;
+
+    @Rule
+    public MockitoRule mMockitoRule = MockitoJUnit.rule();
+    @Mock
+    private BluetoothDevice mDevice;
+    @Mock
+    private HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener mListener;
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private HearingDeviceLocalDataManager mLocalDataManager;
+
+    @Before
+    public void setUp() {
+        prepareTestDataInSettings();
+        mLocalDataManager = new HearingDeviceLocalDataManager(mContext);
+        mLocalDataManager.start();
+        mLocalDataManager.setOnDeviceLocalDataChangeListener(mListener,
+                mContext.getMainExecutor());
+
+        when(mDevice.getAnonymizedAddress()).thenReturn(TEST_ADDRESS);
+    }
+
+    @Test
+    public void stop_verifyDataIsSaved() {
+        mLocalDataManager.updateAmbient(mDevice, TEST_UPDATED_AMBIENT);
+        mLocalDataManager.stop();
+
+        String settings = Settings.Global.getStringForUser(mContext.getContentResolver(),
+                Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME, UserHandle.USER_SYSTEM);
+        String expectedSettings = generateSettingsString(TEST_ADDRESS, TEST_UPDATED_AMBIENT,
+                TEST_GROUP_AMBIENT, TEST_AMBIENT_CONTROL_EXPANDED);
+        assertThat(settings).isEqualTo(expectedSettings);
+    }
+
+    @Test
+    public void get_correctDataFromSettings() {
+        HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(mDevice);
+
+        assertThat(data.ambient()).isEqualTo(TEST_AMBIENT);
+        assertThat(data.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+        assertThat(data.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+    }
+
+    @Test
+    public void updateAmbient_correctValue_listenerCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.ambient()).isEqualTo(TEST_AMBIENT);
+
+        mLocalDataManager.updateAmbient(mDevice, TEST_UPDATED_AMBIENT);
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.ambient()).isEqualTo(TEST_UPDATED_AMBIENT);
+        verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+    }
+
+    @Test
+    public void updateAmbient_sameValue_listenerNotCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.ambient()).isEqualTo(TEST_AMBIENT);
+
+        mLocalDataManager.updateAmbient(mDevice, TEST_AMBIENT);
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.ambient()).isEqualTo(TEST_AMBIENT);
+        verify(mListener, never()).onDeviceLocalDataChange(any(), any());
+    }
+
+    @Test
+    public void updateGroupAmbient_correctValue_listenerCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+
+        mLocalDataManager.updateGroupAmbient(mDevice, TEST_UPDATED_GROUP_AMBIENT);
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.groupAmbient()).isEqualTo(TEST_UPDATED_GROUP_AMBIENT);
+        verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+    }
+
+    @Test
+    public void updateGroupAmbient_sameValue_listenerNotCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+
+        mLocalDataManager.updateGroupAmbient(mDevice, TEST_GROUP_AMBIENT);
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+        verify(mListener, never()).onDeviceLocalDataChange(any(), any());
+    }
+
+    @Test
+    public void updateAmbientControlExpanded_correctValue_listenerCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+
+        mLocalDataManager.updateAmbientControlExpanded(mDevice,
+                TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.ambientControlExpanded()).isEqualTo(
+                TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+        verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+    }
+
+    @Test
+    public void updateAmbientControlExpanded_sameValue_listenerNotCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+
+        mLocalDataManager.updateAmbientControlExpanded(mDevice, TEST_AMBIENT_CONTROL_EXPANDED);
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+        verify(mListener, never()).onDeviceLocalDataChange(any(), any());
+    }
+
+    @Test
+    public void getLocalDataFromSettings_dataChanged_correctValue_listenerCalled() {
+        HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+        assertThat(oldData.ambient()).isEqualTo(TEST_AMBIENT);
+        assertThat(oldData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+        assertThat(oldData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+
+        prepareUpdatedDataInSettings();
+        mLocalDataManager.getLocalDataFromSettings();
+
+        HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+        assertThat(newData.ambient()).isEqualTo(TEST_UPDATED_AMBIENT);
+        assertThat(newData.groupAmbient()).isEqualTo(TEST_UPDATED_GROUP_AMBIENT);
+        assertThat(newData.ambientControlExpanded()).isEqualTo(
+                TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+        verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+    }
+
+    private void prepareTestDataInSettings() {
+        String data = generateSettingsString(TEST_ADDRESS, TEST_AMBIENT, TEST_GROUP_AMBIENT,
+                TEST_AMBIENT_CONTROL_EXPANDED);
+        Settings.Global.putStringForUser(mContext.getContentResolver(),
+                Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME, data,
+                UserHandle.USER_SYSTEM);
+    }
+
+    private void prepareUpdatedDataInSettings() {
+        String data = generateSettingsString(TEST_ADDRESS, TEST_UPDATED_AMBIENT,
+                TEST_UPDATED_GROUP_AMBIENT, TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+        Settings.Global.putStringForUser(mContext.getContentResolver(),
+                Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME, data,
+                UserHandle.USER_SYSTEM);
+    }
+
+    private String generateSettingsString(String addr, int ambient, int groupAmbient,
+            boolean ambientControlExpanded) {
+        return "addr=" + addr + ",ambient=" + ambient + ",group_ambient=" + groupAmbient
+                + ",control_expanded=" + ambientControlExpanded + ";";
+    }
+
+    @Implements(value = Settings.Global.class)
+    public static class ShadowGlobal extends ShadowSettings.ShadowGlobal {
+        private static final Map<ContentResolver, Map<String, String>> sDataMap = new HashMap<>();
+
+        @Implementation
+        protected static boolean putStringForUser(
+                ContentResolver cr, String name, String value, int userHandle) {
+            get(cr).put(name, value);
+            return true;
+        }
+
+        @Implementation
+        protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
+            return get(cr).get(name);
+        }
+
+        private static Map<String, String> get(ContentResolver cr) {
+            return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
+        }
+    }
+}
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/VolumeControlProfileTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/VolumeControlProfileTest.java
index 9c518de..bd67394 100644
--- a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/VolumeControlProfileTest.java
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/VolumeControlProfileTest.java
@@ -24,6 +24,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.bluetooth.AudioInputControl;
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothManager;
 import android.bluetooth.BluetoothProfile;
@@ -45,6 +46,7 @@
 import org.robolectric.annotation.Config;
 import org.robolectric.shadow.api.Shadow;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.Executor;
@@ -248,4 +250,16 @@
         verify(mService).isVolumeOffsetAvailable(mBluetoothDevice);
         assertThat(available).isFalse();
     }
+
+    @Test
+    public void getAudioInputControlServices_verifyIsCalledAndReturnNonNullList() {
+        mServiceListener.onServiceConnected(BluetoothProfile.VOLUME_CONTROL, mService);
+        when(mService.getAudioInputControlServices(mBluetoothDevice)).thenReturn(new ArrayList<>());
+
+        final List<AudioInputControl> controls = mProfile.getAudioInputControlServices(
+                mBluetoothDevice);
+
+        verify(mService).getAudioInputControlServices(mBluetoothDevice);
+        assertThat(controls).isNotNull();
+    }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt
index fcf4662..50ac2619 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt
@@ -405,7 +405,8 @@
         testScope.runTest {
             val lockScreenState by collectLastValue(underTest.lockScreenState)
 
-            zenModeRepository.addMode(TestModeBuilder.MANUAL_DND_INACTIVE)
+            val manualDnd = TestModeBuilder.MANUAL_DND_INACTIVE
+            zenModeRepository.addMode(manualDnd)
             runCurrent()
 
             assertThat(lockScreenState)
@@ -419,8 +420,7 @@
                     )
                 )
 
-            zenModeRepository.removeMode(TestModeBuilder.MANUAL_DND_INACTIVE.id)
-            zenModeRepository.addMode(TestModeBuilder.MANUAL_DND_ACTIVE)
+            zenModeRepository.activateMode(manualDnd)
             runCurrent()
 
             assertThat(lockScreenState)
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
index 74d4178..4e33a59 100644
--- 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
@@ -378,8 +378,7 @@
 
             assertThat(dndMode!!.isActive).isFalse()
 
-            zenModeRepository.removeMode(TestModeBuilder.MANUAL_DND_INACTIVE.id)
-            zenModeRepository.addMode(TestModeBuilder.MANUAL_DND_ACTIVE)
+            zenModeRepository.activateMode(TestModeBuilder.MANUAL_DND_INACTIVE.id)
             runCurrent()
 
             assertThat(dndMode!!.isActive).isTrue()
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
index 1e6e52a..d8184db 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
@@ -83,7 +83,7 @@
 
             assertThat(ringerViewModel).isInstanceOf(RingerViewModelState.Available::class.java)
             assertThat((ringerViewModel as RingerViewModelState.Available).uiModel.drawerState)
-                .isEqualTo(RingerDrawerState.Closed(normalRingerMode))
+                .isEqualTo(RingerDrawerState.Closed(normalRingerMode, normalRingerMode))
         }
 
     @Test
@@ -91,8 +91,9 @@
         testScope.runTest {
             val ringerViewModel by collectLastValue(underTest.ringerViewModel)
             val vibrateRingerMode = RingerMode(RINGER_MODE_VIBRATE)
+            val normalRingerMode = RingerMode(RINGER_MODE_NORMAL)
 
-            setUpRingerModeAndOpenDrawer(RingerMode(RINGER_MODE_NORMAL))
+            setUpRingerModeAndOpenDrawer(normalRingerMode)
             // Select vibrate ringer mode.
             underTest.onRingerButtonClicked(vibrateRingerMode)
             controller.getState()
@@ -103,7 +104,8 @@
             var uiModel = (ringerViewModel as RingerViewModelState.Available).uiModel
             assertThat(uiModel.availableButtons[uiModel.currentButtonIndex]?.ringerMode)
                 .isEqualTo(vibrateRingerMode)
-            assertThat(uiModel.drawerState).isEqualTo(RingerDrawerState.Closed(vibrateRingerMode))
+            assertThat(uiModel.drawerState)
+                .isEqualTo(RingerDrawerState.Closed(vibrateRingerMode, normalRingerMode))
 
             val silentRingerMode = RingerMode(RINGER_MODE_SILENT)
             // Open drawer
@@ -120,7 +122,8 @@
             uiModel = (ringerViewModel as RingerViewModelState.Available).uiModel
             assertThat(uiModel.availableButtons[uiModel.currentButtonIndex]?.ringerMode)
                 .isEqualTo(silentRingerMode)
-            assertThat(uiModel.drawerState).isEqualTo(RingerDrawerState.Closed(silentRingerMode))
+            assertThat(uiModel.drawerState)
+                .isEqualTo(RingerDrawerState.Closed(silentRingerMode, vibrateRingerMode))
             assertThat(controller.hasScheduledTouchFeedback).isFalse()
             assertThat(vibratorHelper.totalVibrations).isEqualTo(2)
         }
diff --git a/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml b/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
index 877637e..1607121 100644
--- a/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
+++ b/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
@@ -17,10 +17,10 @@
 <MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
     <Transition
-        android:id="@+id/transition"
+        android:id="@+id/close_to_open_transition"
         app:constraintSetEnd="@+id/volume_dialog_ringer_drawer_open"
         app:constraintSetStart="@+id/volume_dialog_ringer_drawer_close"
-        app:transitionEasing="path(0.05f, 0.7f, 0.1f, 1f)"
+        app:transitionEasing="cubic(0.05, 0.7, 0.1, 1.0)"
         app:duration="400">
     </Transition>
 
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
index 1963ba2..82ac056 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
@@ -16,6 +16,8 @@
 
 package com.android.systemui.volume.dialog.ringer.ui.binder
 
+import android.animation.ArgbEvaluator
+import android.graphics.drawable.GradientDrawable
 import android.view.LayoutInflater
 import android.view.View
 import android.widget.ImageButton
@@ -23,6 +25,10 @@
 import androidx.compose.ui.util.fastForEachIndexed
 import androidx.constraintlayout.motion.widget.MotionLayout
 import androidx.constraintlayout.widget.ConstraintSet
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.FloatValueHolder
+import androidx.dynamicanimation.animation.SpringAnimation
+import androidx.dynamicanimation.animation.SpringForce
 import com.android.internal.R as internalR
 import com.android.settingslib.Utils
 import com.android.systemui.lifecycle.WindowLifecycleState
@@ -31,24 +37,44 @@
 import com.android.systemui.res.R
 import com.android.systemui.util.children
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
+import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonUiModel
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonViewModel
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerDrawerState
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModel
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModelState
 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.VolumeDialogRingerDrawerViewModel
+import com.android.systemui.volume.dialog.ui.utils.suspendAnimate
 import javax.inject.Inject
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+private const val CLOSE_DRAWER_DELAY = 300L
 
 @VolumeDialogScope
 class VolumeDialogRingerViewBinder
 @Inject
 constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Factory) {
+    private val roundnessSpringForce =
+        SpringForce(0F).apply {
+            stiffness = 800F
+            dampingRatio = 0.6F
+        }
+    private val colorSpringForce =
+        SpringForce(0F).apply {
+            stiffness = 3800F
+            dampingRatio = 1F
+        }
+    private val rgbEvaluator = ArgbEvaluator()
 
     fun bind(view: View) {
         with(view) {
             val volumeDialogBackgroundView = requireViewById<View>(R.id.volume_dialog_background)
             val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
+            val unselectedButtonUiModel = RingerButtonUiModel.getUnselectedButton(context)
+            val selectedButtonUiModel = RingerButtonUiModel.getSelectedButton(context)
+
             repeatWhenAttached {
                 viewModel(
                     traceName = "VolumeDialogRingerViewBinder",
@@ -61,26 +87,53 @@
                                 is RingerViewModelState.Available -> {
                                     val uiModel = ringerState.uiModel
 
-                                    bindDrawerButtons(viewModel, uiModel)
-
                                     // Set up view background and visibility
                                     drawerContainer.visibility = View.VISIBLE
                                     when (uiModel.drawerState) {
                                         is RingerDrawerState.Initial -> {
+                                            drawerContainer.animateAndBindDrawerButtons(
+                                                viewModel,
+                                                uiModel,
+                                                selectedButtonUiModel,
+                                                unselectedButtonUiModel,
+                                            )
                                             drawerContainer.closeDrawer(uiModel.currentButtonIndex)
                                             volumeDialogBackgroundView.setBackgroundResource(
                                                 R.drawable.volume_dialog_background
                                             )
                                         }
                                         is RingerDrawerState.Closed -> {
-                                            drawerContainer.closeDrawer(uiModel.currentButtonIndex)
-                                            volumeDialogBackgroundView.setBackgroundResource(
-                                                R.drawable.volume_dialog_background
-                                            )
+                                            if (
+                                                uiModel.selectedButton.ringerMode ==
+                                                    uiModel.drawerState.currentMode
+                                            ) {
+                                                drawerContainer.animateAndBindDrawerButtons(
+                                                    viewModel,
+                                                    uiModel,
+                                                    selectedButtonUiModel,
+                                                    unselectedButtonUiModel,
+                                                ) {
+                                                    drawerContainer.closeDrawer(
+                                                        uiModel.currentButtonIndex
+                                                    )
+                                                    volumeDialogBackgroundView
+                                                        .setBackgroundResource(
+                                                            R.drawable.volume_dialog_background
+                                                        )
+                                                }
+                                            }
                                         }
                                         is RingerDrawerState.Open -> {
+                                            drawerContainer.animateAndBindDrawerButtons(
+                                                viewModel,
+                                                uiModel,
+                                                selectedButtonUiModel,
+                                                unselectedButtonUiModel,
+                                            )
                                             // Open drawer
-                                            drawerContainer.transitionToEnd()
+                                            drawerContainer.transitionToState(
+                                                R.id.volume_dialog_ringer_drawer_open
+                                            )
                                             if (
                                                 uiModel.currentButtonIndex !=
                                                     uiModel.availableButtons.size - 1
@@ -106,45 +159,93 @@
         }
     }
 
-    private fun View.bindDrawerButtons(
+    private suspend fun MotionLayout.animateAndBindDrawerButtons(
         viewModel: VolumeDialogRingerDrawerViewModel,
         uiModel: RingerViewModel,
+        selectedButtonUiModel: RingerButtonUiModel,
+        unselectedButtonUiModel: RingerButtonUiModel,
+        onAnimationEnd: Runnable? = null,
     ) {
-        val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
-        val count = uiModel.availableButtons.size
-        drawerContainer.ensureChildCount(R.layout.volume_ringer_button, count)
+        ensureChildCount(R.layout.volume_ringer_button, uiModel.availableButtons.size)
+        if (
+            uiModel.drawerState is RingerDrawerState.Closed &&
+                uiModel.drawerState.currentMode != uiModel.drawerState.previousMode
+        ) {
+            val count = uiModel.availableButtons.size
+            val selectedButton =
+                getChildAt(count - uiModel.currentButtonIndex - 1)
+                    .requireViewById<ImageButton>(R.id.volume_drawer_button)
+            val previousIndex =
+                uiModel.availableButtons.indexOfFirst {
+                    it?.ringerMode == uiModel.drawerState.previousMode
+                }
+            val unselectedButton =
+                getChildAt(count - previousIndex - 1)
+                    .requireViewById<ImageButton>(R.id.volume_drawer_button)
 
+            // On roundness animation end.
+            val roundnessAnimationEndListener =
+                DynamicAnimation.OnAnimationEndListener { _, _, _, _ ->
+                    postDelayed(
+                        { bindButtons(viewModel, uiModel, onAnimationEnd, isAnimated = true) },
+                        CLOSE_DRAWER_DELAY,
+                    )
+                }
+
+            // We only need to execute on roundness animation end once.
+            selectedButton.animateTo(selectedButtonUiModel, roundnessAnimationEndListener)
+            unselectedButton.animateTo(unselectedButtonUiModel)
+        } else {
+            bindButtons(viewModel, uiModel, onAnimationEnd)
+        }
+    }
+
+    private fun MotionLayout.bindButtons(
+        viewModel: VolumeDialogRingerDrawerViewModel,
+        uiModel: RingerViewModel,
+        onAnimationEnd: Runnable? = null,
+        isAnimated: Boolean = false,
+    ) {
+        val count = uiModel.availableButtons.size
         uiModel.availableButtons.fastForEachIndexed { index, ringerButton ->
             ringerButton?.let {
-                val view = drawerContainer.getChildAt(count - index - 1)
-                // TODO (b/369995871): object animator for button switch ( active <-> inactive )
+                val view = getChildAt(count - index - 1)
                 if (index == uiModel.currentButtonIndex) {
-                    view.bindDrawerButton(uiModel.selectedButton, viewModel, isSelected = true)
+                    view.bindDrawerButton(
+                        uiModel.selectedButton,
+                        viewModel,
+                        isSelected = true,
+                        isAnimated = isAnimated,
+                    )
                 } else {
-                    view.bindDrawerButton(it, viewModel)
+                    view.bindDrawerButton(it, viewModel, isAnimated)
                 }
             }
         }
+        onAnimationEnd?.run()
     }
 
     private fun View.bindDrawerButton(
         buttonViewModel: RingerButtonViewModel,
         viewModel: VolumeDialogRingerDrawerViewModel,
         isSelected: Boolean = false,
+        isAnimated: Boolean = false,
     ) {
         with(requireViewById<ImageButton>(R.id.volume_drawer_button)) {
             setImageResource(buttonViewModel.imageResId)
             contentDescription = context.getString(buttonViewModel.contentDescriptionResId)
-            if (isSelected) {
+            if (isSelected && !isAnimated) {
                 setBackgroundResource(R.drawable.volume_drawer_selection_bg)
                 setColorFilter(
                     Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary)
                 )
-            } else {
+                background = background.mutate()
+            } else if (!isAnimated) {
                 setBackgroundResource(R.drawable.volume_ringer_item_bg)
                 setColorFilter(
                     Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface)
                 )
+                background = background.mutate()
             }
             setOnClickListener {
                 viewModel.onRingerButtonClicked(buttonViewModel.ringerMode, isSelected)
@@ -171,9 +272,10 @@
     }
 
     private fun MotionLayout.closeDrawer(selectedIndex: Int) {
+        setTransition(R.id.close_to_open_transition)
         cloneConstraintSet(R.id.volume_dialog_ringer_drawer_close)
             .adjustClosedConstraintsForDrawer(selectedIndex, this)
-        transitionToStart()
+        transitionToState(R.id.volume_dialog_ringer_drawer_close)
     }
 
     private fun ConstraintSet.adjustOpenConstraintsForDrawer(motionLayout: MotionLayout) {
@@ -263,4 +365,47 @@
         connect(button.id, ConstraintSet.START, motionLayout.id, ConstraintSet.START)
         connect(button.id, ConstraintSet.END, motionLayout.id, ConstraintSet.END)
     }
+
+    private suspend fun ImageButton.animateTo(
+        ringerButtonUiModel: RingerButtonUiModel,
+        roundnessAnimationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
+    ) {
+        val roundnessAnimation =
+            SpringAnimation(FloatValueHolder(0F)).setSpring(roundnessSpringForce)
+        val colorAnimation = SpringAnimation(FloatValueHolder(0F)).setSpring(colorSpringForce)
+        val radius = (background as GradientDrawable).cornerRadius
+        val cornerRadiusDiff =
+            ringerButtonUiModel.cornerRadius - (background as GradientDrawable).cornerRadius
+        val roundnessAnimationUpdateListener =
+            DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
+                (background as GradientDrawable).cornerRadius = radius + value * cornerRadiusDiff
+                background.invalidateSelf()
+            }
+        val colorAnimationUpdateListener =
+            DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
+                val currentIconColor =
+                    rgbEvaluator.evaluate(
+                        value.coerceIn(0F, 1F),
+                        imageTintList?.colors?.first(),
+                        ringerButtonUiModel.tintColor,
+                    ) as Int
+                val currentBgColor =
+                    rgbEvaluator.evaluate(
+                        value.coerceIn(0F, 1F),
+                        (background as GradientDrawable).color?.colors?.get(0),
+                        ringerButtonUiModel.backgroundColor,
+                    ) as Int
+
+                (background as GradientDrawable).setColor(currentBgColor)
+                background.invalidateSelf()
+                setColorFilter(currentIconColor)
+            }
+        coroutineScope {
+            launch { colorAnimation.suspendAnimate(colorAnimationUpdateListener) }
+            roundnessAnimation.suspendAnimate(
+                roundnessAnimationUpdateListener,
+                roundnessAnimationEndListener,
+            )
+        }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt
new file mode 100644
index 0000000..3c46567
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.ringer.ui.viewmodel
+
+import android.content.Context
+import com.android.internal.R as internalR
+import com.android.settingslib.Utils
+import com.android.systemui.res.R
+
+/** Models the UI state of ringer button */
+data class RingerButtonUiModel(
+    /** Icon color. */
+    val tintColor: Int,
+    val backgroundColor: Int,
+    val cornerRadius: Int,
+) {
+    companion object {
+        fun getUnselectedButton(context: Context): RingerButtonUiModel {
+            return RingerButtonUiModel(
+                tintColor =
+                    Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface),
+                backgroundColor =
+                    Utils.getColorAttrDefaultColor(
+                        context,
+                        internalR.attr.materialColorSurfaceContainerHighest,
+                    ),
+                cornerRadius =
+                    context.resources.getDimensionPixelSize(
+                        R.dimen.volume_dialog_background_square_corner_radius
+                    ),
+            )
+        }
+
+        fun getSelectedButton(context: Context): RingerButtonUiModel {
+            return RingerButtonUiModel(
+                tintColor =
+                    Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary),
+                backgroundColor =
+                    Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorPrimary),
+                cornerRadius =
+                    context.resources.getDimensionPixelSize(
+                        R.dimen.volume_dialog_ringer_selected_button_background_radius
+                    ),
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
index f321837..afb3f68 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
@@ -25,7 +25,8 @@
     data class Open(val mode: RingerMode) : RingerDrawerState
 
     /** When clicked to close drawer */
-    data class Closed(val mode: RingerMode) : RingerDrawerState
+    data class Closed(val currentMode: RingerMode, val previousMode: RingerMode) :
+        RingerDrawerState
 
     /** Initial state when volume dialog is shown with a closed drawer. */
     interface Initial : RingerDrawerState {
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
index 624dcc7..45338e4 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
@@ -98,7 +98,10 @@
                     RingerDrawerState.Open(ringerMode)
                 }
                 is RingerDrawerState.Open -> {
-                    RingerDrawerState.Closed(ringerMode)
+                    RingerDrawerState.Closed(
+                        ringerMode,
+                        (drawerState.value as RingerDrawerState.Open).mode,
+                    )
                 }
                 is RingerDrawerState.Closed -> {
                     RingerDrawerState.Open(ringerMode)
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
index c7f5801..10cf615 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
@@ -20,6 +20,8 @@
 import android.animation.AnimatorListenerAdapter
 import android.animation.ValueAnimator
 import android.view.ViewPropertyAnimator
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.SpringAnimation
 import kotlin.coroutines.resume
 import kotlinx.coroutines.CancellableContinuation
 import kotlinx.coroutines.suspendCancellableCoroutine
@@ -80,6 +82,27 @@
     }
 }
 
+/**
+ * Starts spring animation and suspends until it's finished. Cancels the animation if the running
+ * coroutine is cancelled.
+ */
+suspend fun SpringAnimation.suspendAnimate(
+    animationUpdateListener: DynamicAnimation.OnAnimationUpdateListener? = null,
+    animationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
+) = suspendCancellableCoroutine { continuation ->
+    animationUpdateListener?.let(::addUpdateListener)
+    addEndListener { animation, canceled, value, velocity ->
+        continuation.resumeIfCan(Unit)
+        animationEndListener?.onAnimationEnd(animation, canceled, value, velocity)
+    }
+    animateToFinalPosition(1F)
+    continuation.invokeOnCancellation {
+        animationUpdateListener?.let(::removeUpdateListener)
+        animationEndListener?.let(::removeEndListener)
+        cancel()
+    }
+}
+
 private fun <T> CancellableContinuation<T>.resumeIfCan(value: T) {
     if (!isCancelled && !isCompleted) {
         resume(value)
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt
index 2aa6e7b..ae94544 100644
--- a/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt
@@ -24,7 +24,6 @@
 import android.widget.FrameLayout
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.settingslib.notification.modes.TestModeBuilder.MANUAL_DND_ACTIVE
 import com.android.settingslib.notification.modes.TestModeBuilder.MANUAL_DND_INACTIVE
 import com.android.systemui.Flags as AConfigFlags
 import com.android.systemui.SysuiTestCase
@@ -107,6 +106,7 @@
     private lateinit var repository: FakeKeyguardRepository
     private val clockBuffers = ClockMessageBuffers(LogcatOnlyMessageBuffer(LogLevel.DEBUG))
     private lateinit var underTest: ClockEventController
+    private lateinit var dndModeId: String
 
     @Mock private lateinit var broadcastDispatcher: BroadcastDispatcher
     @Mock private lateinit var batteryController: BatteryController
@@ -156,6 +156,7 @@
         whenever(largeClockController.theme).thenReturn(ThemeConfig(true, null))
         whenever(userTracker.userId).thenReturn(1)
 
+        dndModeId = MANUAL_DND_INACTIVE.id
         zenModeRepository.addMode(MANUAL_DND_INACTIVE)
 
         repository = FakeKeyguardRepository()
@@ -528,7 +529,7 @@
         testScope.runTest {
             underTest.listenForDnd(testScope.backgroundScope)
 
-            zenModeRepository.replaceMode(MANUAL_DND_INACTIVE.id, MANUAL_DND_ACTIVE)
+            zenModeRepository.activateMode(dndModeId)
             runCurrent()
 
             verify(events)
@@ -536,7 +537,7 @@
                     eq(ZenData(ZenMode.IMPORTANT_INTERRUPTIONS, R.string::dnd_is_on.name))
                 )
 
-            zenModeRepository.replaceMode(MANUAL_DND_ACTIVE.id, MANUAL_DND_INACTIVE)
+            zenModeRepository.deactivateMode(dndModeId)
             runCurrent()
 
             verify(events).onZenDataChanged(eq(ZenData(ZenMode.OFF, R.string::dnd_is_off.name)))
diff --git a/services/core/java/com/android/server/audio/SpatializerHelper.java b/services/core/java/com/android/server/audio/SpatializerHelper.java
index afa90d5..608edbb 100644
--- a/services/core/java/com/android/server/audio/SpatializerHelper.java
+++ b/services/core/java/com/android/server/audio/SpatializerHelper.java
@@ -129,6 +129,8 @@
     /** current level as reported by native Spatializer in callback */
     private int mSpatLevel = Spatializer.SPATIALIZER_IMMERSIVE_LEVEL_NONE;
     private int mCapableSpatLevel = Spatializer.SPATIALIZER_IMMERSIVE_LEVEL_NONE;
+    /** cached version of Spatializer.getSpatializedChannelMasks */
+    private List<Integer> mSpatializedChannelMasks = Collections.emptyList();
 
     private boolean mTransauralSupported = false;
     private boolean mBinauralSupported = false;
@@ -1030,6 +1032,17 @@
                 return;
             }
             try {
+                final int[] nativeMasks = mSpat.getSpatializedChannelMasks();
+                for (int i = 0; i < nativeMasks.length; i++) {
+                    nativeMasks[i] = AudioFormat.convertNativeChannelMaskToOutMask(nativeMasks[i]);
+                }
+                mSpatializedChannelMasks = Arrays.stream(nativeMasks).boxed().toList();
+
+            } catch (Exception e) { // just catch Exception in case nativeMasks is null
+                Log.e(TAG, "Error calling getSpatializedChannelMasks", e);
+                mSpatializedChannelMasks = Collections.emptyList();
+            }
+            try {
                 //TODO: register heatracking callback only when sensors are registered
                 if (mIsHeadTrackingSupported) {
                     mActualHeadTrackingMode =
@@ -1103,20 +1116,7 @@
     }
 
     synchronized @NonNull List<Integer> getSpatializedChannelMasks() {
-        if (!checkSpatializer("getSpatializedChannelMasks")) {
-            return Collections.emptyList();
-        }
-        try {
-            final int[] nativeMasks = new int[0]; // FIXME mSpat query goes here
-            for (int i = 0; i < nativeMasks.length; i++) {
-                nativeMasks[i] = AudioFormat.convertNativeChannelMaskToOutMask(nativeMasks[i]);
-            }
-            final List<Integer> masks = Arrays.stream(nativeMasks).boxed().toList();
-            return masks;
-        } catch (Exception e) { // just catch Exception in case nativeMasks is null
-            Log.e(TAG, "Error calling getSpatializedChannelMasks", e);
-            return Collections.emptyList();
-        }
+        return mSpatializedChannelMasks;
     }
 
     //------------------------------------------------------
@@ -1622,6 +1622,14 @@
         pw.println("\tmState:" + mState);
         pw.println("\tmSpatLevel:" + mSpatLevel);
         pw.println("\tmCapableSpatLevel:" + mCapableSpatLevel);
+        List<Integer> speakerMasks = getSpatializedChannelMasks();
+        StringBuilder masks = speakerMasks.isEmpty()
+                ? new StringBuilder("none") : new StringBuilder("");
+        for (Integer mask : speakerMasks) {
+            masks.append(AudioFormat.javaChannelOutMaskToString(mask)).append(" ");
+        }
+        pw.println("\tspatialized speaker masks: " + masks);
+
         pw.println("\tmIsHeadTrackingSupported:" + mIsHeadTrackingSupported);
         StringBuilder modesString = new StringBuilder();
         for (int mode : mSupportedHeadTrackingModes) {
diff --git a/services/core/java/com/android/server/display/DisplayTopologyCoordinator.java b/services/core/java/com/android/server/display/DisplayTopologyCoordinator.java
index 55b292a..5b78726 100644
--- a/services/core/java/com/android/server/display/DisplayTopologyCoordinator.java
+++ b/services/core/java/com/android/server/display/DisplayTopologyCoordinator.java
@@ -100,13 +100,14 @@
      */
     DisplayTopology getTopology() {
         synchronized (mSyncRoot) {
-            return mTopology;
+            return mTopology.copy();
         }
     }
 
     void setTopology(DisplayTopology topology) {
         synchronized (mSyncRoot) {
             mTopology = topology;
+            mTopology.normalize();
             sendTopologyUpdateLocked();
         }
     }
diff --git a/services/core/java/com/android/server/media/MediaSession2Record.java b/services/core/java/com/android/server/media/MediaSession2Record.java
index c8a8799..e7b79ab 100644
--- a/services/core/java/com/android/server/media/MediaSession2Record.java
+++ b/services/core/java/com/android/server/media/MediaSession2Record.java
@@ -16,7 +16,6 @@
 
 package com.android.server.media;
 
-import android.app.ForegroundServiceDelegationOptions;
 import android.app.Notification;
 import android.media.MediaController2;
 import android.media.Session2CommandGroup;
@@ -98,11 +97,8 @@
     }
 
     @Override
-    public ForegroundServiceDelegationOptions getForegroundServiceDelegationOptions() {
-        // For an app to be eligible for FGS delegation, it needs a media session liked to a media
-        // notification. Currently, notifications cannot be linked to MediaSession2 so it is not
-        // supported.
-        return null;
+    public boolean hasLinkedNotificationSupport() {
+        return false;
     }
 
     @Override
diff --git a/services/core/java/com/android/server/media/MediaSessionRecord.java b/services/core/java/com/android/server/media/MediaSessionRecord.java
index 668ee2a..5f7c86f 100644
--- a/services/core/java/com/android/server/media/MediaSessionRecord.java
+++ b/services/core/java/com/android/server/media/MediaSessionRecord.java
@@ -29,7 +29,6 @@
 import android.annotation.RequiresPermission;
 import android.app.ActivityManager;
 import android.app.ActivityManagerInternal;
-import android.app.ForegroundServiceDelegationOptions;
 import android.app.Notification;
 import android.app.PendingIntent;
 import android.app.compat.CompatChanges;
@@ -184,8 +183,6 @@
     private final UriGrantsManagerInternal mUgmInternal;
     private final Context mContext;
 
-    private final ForegroundServiceDelegationOptions mForegroundServiceDelegationOptions;
-
     private final Object mLock = new Object();
     // This field is partially guarded by mLock. Writes and non-atomic iterations (for example:
     // index-based-iterations) must be guarded by mLock. But it is safe to acquire an iterator
@@ -306,32 +303,10 @@
         mPolicies = policies;
         mUgmInternal = LocalServices.getService(UriGrantsManagerInternal.class);
 
-        mForegroundServiceDelegationOptions = createForegroundServiceDelegationOptions();
-
         // May throw RemoteException if the session app is killed.
         mSessionCb.mCb.asBinder().linkToDeath(this, 0);
     }
 
-    private ForegroundServiceDelegationOptions createForegroundServiceDelegationOptions() {
-        return new ForegroundServiceDelegationOptions.Builder()
-                .setClientPid(mOwnerPid)
-                .setClientUid(getUid())
-                .setClientPackageName(getPackageName())
-                .setClientAppThread(null)
-                .setSticky(false)
-                .setClientInstanceName(
-                        "MediaSessionFgsDelegate_"
-                                + getUid()
-                                + "_"
-                                + mOwnerPid
-                                + "_"
-                                + getPackageName())
-                .setForegroundServiceTypes(0)
-                .setDelegationService(
-                        ForegroundServiceDelegationOptions.DELEGATION_SERVICE_MEDIA_PLAYBACK)
-                .build();
-    }
-
     /**
      * Get the session binder for the {@link MediaSession}.
      *
@@ -389,6 +364,11 @@
         return mUserId;
     }
 
+    @Override
+    public boolean hasLinkedNotificationSupport() {
+        return true;
+    }
+
     /**
      * Check if this session has system priorty and should receive media buttons
      * before any other sessions.
@@ -752,11 +732,6 @@
         return mPackageName + "/" + mTag + "/" + getUniqueId() + " (userId=" + mUserId + ")";
     }
 
-    @Override
-    public ForegroundServiceDelegationOptions getForegroundServiceDelegationOptions() {
-        return mForegroundServiceDelegationOptions;
-    }
-
     private void postAdjustLocalVolume(final int stream, final int direction, final int flags,
             final String callingOpPackageName, final int callingPid, final int callingUid,
             final boolean asSystemService, final boolean useSuggested,
diff --git a/services/core/java/com/android/server/media/MediaSessionRecordImpl.java b/services/core/java/com/android/server/media/MediaSessionRecordImpl.java
index 6c3b123..3966608 100644
--- a/services/core/java/com/android/server/media/MediaSessionRecordImpl.java
+++ b/services/core/java/com/android/server/media/MediaSessionRecordImpl.java
@@ -16,10 +16,8 @@
 
 package com.android.server.media;
 
-import android.app.ForegroundServiceDelegationOptions;
 import android.app.Notification;
 import android.media.AudioManager;
-import android.media.session.PlaybackState;
 import android.os.ResultReceiver;
 import android.view.KeyEvent;
 
@@ -63,13 +61,14 @@
     public abstract int getUserId();
 
     /**
-     * Get the {@link ForegroundServiceDelegationOptions} needed for notifying activity manager
-     * service with changes in the {@link PlaybackState} for this session.
+     * Returns whether this session supports linked notifications.
      *
-     * @return the {@link ForegroundServiceDelegationOptions} needed for notifying the activity
-     *     manager service with changes in the {@link PlaybackState} for this session.
+     * <p>A notification is linked to a media session if it contains
+     * {@link android.app.Notification#EXTRA_MEDIA_SESSION}.
+     *
+     * @return {@code true} if this session supports linked notifications, {@code false} otherwise.
      */
-    public abstract ForegroundServiceDelegationOptions getForegroundServiceDelegationOptions();
+    public abstract boolean hasLinkedNotificationSupport();
 
     /**
      * Check if this session has system priority and should receive media buttons before any other
diff --git a/services/core/java/com/android/server/media/MediaSessionService.java b/services/core/java/com/android/server/media/MediaSessionService.java
index 2b29fbd..e091fc6 100644
--- a/services/core/java/com/android/server/media/MediaSessionService.java
+++ b/services/core/java/com/android/server/media/MediaSessionService.java
@@ -30,7 +30,6 @@
 import android.annotation.Nullable;
 import android.app.ActivityManager;
 import android.app.ActivityManagerInternal;
-import android.app.ForegroundServiceDelegationOptions;
 import android.app.KeyguardManager;
 import android.app.Notification;
 import android.app.NotificationManager;
@@ -185,9 +184,9 @@
 
     /**
      * Maps uid with all user engaged session records associated to it. It's used for calling
-     * ActivityManagerInternal startFGS and stopFGS. This collection doesn't contain
-     * MediaSession2Record(s). When the media session is paused, There exists a timeout before
-     * calling stopFGS unlike usage logging which considers it disengaged immediately.
+     * ActivityManagerInternal internal api to set fgs active/inactive. This collection doesn't
+     * contain MediaSession2Record(s). When the media session is paused, There exists a timeout
+     * before setting FGS inactive unlike usage logging which considers it disengaged immediately.
      */
     @GuardedBy("mLock")
     private final Map<Integer, Set<MediaSessionRecordImpl>> mUserEngagedSessionsForFgs =
@@ -195,7 +194,7 @@
 
     /* Maps uid with all media notifications associated to it */
     @GuardedBy("mLock")
-    private final Map<Integer, Set<Notification>> mMediaNotifications = new HashMap<>();
+    private final Map<Integer, Set<StatusBarNotification>> mMediaNotifications = new HashMap<>();
 
     /**
      * Holds all {@link MediaSessionRecordImpl} which we've reported as being {@link
@@ -700,10 +699,10 @@
             MediaSessionRecordImpl mediaSessionRecord, boolean isUserEngaged) {
         if (isUserEngaged) {
             addUserEngagedSession(mediaSessionRecord);
-            startFgsIfSessionIsLinkedToNotification(mediaSessionRecord);
+            setFgsActiveIfSessionIsLinkedToNotification(mediaSessionRecord);
         } else {
             removeUserEngagedSession(mediaSessionRecord);
-            stopFgsIfNoSessionIsLinkedToNotification(mediaSessionRecord);
+            setFgsInactiveIfNoSessionIsLinkedToNotification(mediaSessionRecord);
         }
     }
 
@@ -737,17 +736,20 @@
         }
     }
 
-    private void startFgsIfSessionIsLinkedToNotification(
+    private void setFgsActiveIfSessionIsLinkedToNotification(
             MediaSessionRecordImpl mediaSessionRecord) {
-        Log.d(TAG, "startFgsIfSessionIsLinkedToNotification: record=" + mediaSessionRecord);
+        Log.d(TAG, "setFgsIfSessionIsLinkedToNotification: record=" + mediaSessionRecord);
         if (!Flags.enableNotifyingActivityManagerWithMediaSessionStatusChange()) {
             return;
         }
+        if (!mediaSessionRecord.hasLinkedNotificationSupport()) {
+            return;
+        }
         synchronized (mLock) {
             int uid = mediaSessionRecord.getUid();
-            for (Notification mediaNotification : mMediaNotifications.getOrDefault(uid, Set.of())) {
-                if (mediaSessionRecord.isLinkedToNotification(mediaNotification)) {
-                    startFgsDelegateLocked(mediaSessionRecord);
+            for (StatusBarNotification sbn : mMediaNotifications.getOrDefault(uid, Set.of())) {
+                if (mediaSessionRecord.isLinkedToNotification(sbn.getNotification())) {
+                    setFgsActiveLocked(mediaSessionRecord, sbn);
                     return;
                 }
             }
@@ -755,81 +757,92 @@
     }
 
     @GuardedBy("mLock")
-    private void startFgsDelegateLocked(MediaSessionRecordImpl mediaSessionRecord) {
-        ForegroundServiceDelegationOptions foregroundServiceDelegationOptions =
-                mediaSessionRecord.getForegroundServiceDelegationOptions();
-        if (foregroundServiceDelegationOptions == null) {
-            return; // This record doesn't support FGS. Typically a MediaSession2 record.
-        }
+    private void setFgsActiveLocked(MediaSessionRecordImpl mediaSessionRecord,
+            StatusBarNotification sbn) {
         if (!mFgsAllowedMediaSessionRecords.add(mediaSessionRecord)) {
-            return; // This record is already FGS-started.
+            return; // This record already is FGS-activated.
         }
         final long token = Binder.clearCallingIdentity();
         try {
+            final String packageName = sbn.getPackageName();
+            final int uid = sbn.getUid();
+            final int notificationId = sbn.getId();
             Log.i(
                     TAG,
                     TextUtils.formatSimple(
-                            "startFgsDelegate: pkg=%s uid=%d",
-                            foregroundServiceDelegationOptions.mClientPackageName,
-                            foregroundServiceDelegationOptions.mClientUid));
-            mActivityManagerInternal.startForegroundServiceDelegate(
-                    foregroundServiceDelegationOptions, /* connection= */ null);
+                            "setFgsActiveLocked: pkg=%s uid=%d notification=%d",
+                            packageName, uid, notificationId));
+            mActivityManagerInternal.notifyActiveMediaForegroundService(packageName,
+                    sbn.getUser().getIdentifier(), notificationId);
         } finally {
             Binder.restoreCallingIdentity(token);
         }
     }
 
-    private void stopFgsIfNoSessionIsLinkedToNotification(
+    @Nullable
+    private StatusBarNotification getLinkedNotification(
+            int uid, MediaSessionRecordImpl record) {
+        synchronized (mLock) {
+            for (StatusBarNotification sbn :
+                    mMediaNotifications.getOrDefault(uid, Set.of())) {
+                if (record.isLinkedToNotification(sbn.getNotification())) {
+                    return sbn;
+                }
+            }
+        }
+        return null;
+    }
+
+    private void setFgsInactiveIfNoSessionIsLinkedToNotification(
             MediaSessionRecordImpl mediaSessionRecord) {
-        Log.d(TAG, "stopFgsIfNoSessionIsLinkedToNotification: record=" + mediaSessionRecord);
+        Log.d(TAG, "setFgsIfNoSessionIsLinkedToNotification: record=" + mediaSessionRecord);
         if (!Flags.enableNotifyingActivityManagerWithMediaSessionStatusChange()) {
             return;
         }
+        if (!mediaSessionRecord.hasLinkedNotificationSupport()) {
+            return;
+        }
         synchronized (mLock) {
-            int uid = mediaSessionRecord.getUid();
-            ForegroundServiceDelegationOptions foregroundServiceDelegationOptions =
-                    mediaSessionRecord.getForegroundServiceDelegationOptions();
-            if (foregroundServiceDelegationOptions == null) {
-                return;
-            }
-
+            final int uid = mediaSessionRecord.getUid();
             for (MediaSessionRecordImpl record :
                     mUserEngagedSessionsForFgs.getOrDefault(uid, Set.of())) {
-                for (Notification mediaNotification :
+                for (StatusBarNotification sbn :
                         mMediaNotifications.getOrDefault(uid, Set.of())) {
-                    if (record.isLinkedToNotification(mediaNotification)) {
+                    if (record.isLinkedToNotification(sbn.getNotification())) {
                         // A user engaged session linked with a media notification is found.
                         // We shouldn't call stop FGS in this case.
                         return;
                     }
                 }
             }
-
-            stopFgsDelegateLocked(mediaSessionRecord);
+            final StatusBarNotification linkedNotification =
+                    getLinkedNotification(uid, mediaSessionRecord);
+            if (linkedNotification != null) {
+                setFgsInactiveLocked(mediaSessionRecord, linkedNotification);
+            }
         }
     }
 
     @GuardedBy("mLock")
-    private void stopFgsDelegateLocked(MediaSessionRecordImpl mediaSessionRecord) {
-        ForegroundServiceDelegationOptions foregroundServiceDelegationOptions =
-                mediaSessionRecord.getForegroundServiceDelegationOptions();
-        if (foregroundServiceDelegationOptions == null) {
-            return; // This record doesn't support FGS. Typically a MediaSession2 record.
-        }
+    private void setFgsInactiveLocked(MediaSessionRecordImpl mediaSessionRecord,
+            StatusBarNotification sbn) {
         if (!mFgsAllowedMediaSessionRecords.remove(mediaSessionRecord)) {
-            return; // This record is not FGS-started. No need to stop it.
+            return; // This record is not FGS-active. No need to set inactive.
         }
 
         final long token = Binder.clearCallingIdentity();
         try {
+            final String packageName = sbn.getPackageName();
+            final int userId = sbn.getUser().getIdentifier();
+            final int uid = sbn.getUid();
+            final int notificationId = sbn.getId();
             Log.i(
                     TAG,
                     TextUtils.formatSimple(
-                            "stopFgsDelegate: pkg=%s uid=%d",
-                            foregroundServiceDelegationOptions.mClientPackageName,
-                            foregroundServiceDelegationOptions.mClientUid));
-            mActivityManagerInternal.stopForegroundServiceDelegate(
-                    foregroundServiceDelegationOptions);
+                            "setFgsInactiveLocked: pkg=%s uid=%d notification=%d",
+                            packageName, uid, notificationId));
+            mActivityManagerInternal.notifyInactiveMediaForegroundService(packageName,
+                    userId, notificationId);
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -3259,18 +3272,18 @@
         @Override
         public void onNotificationPosted(StatusBarNotification sbn) {
             super.onNotificationPosted(sbn);
-            Notification postedNotification = sbn.getNotification();
             int uid = sbn.getUid();
+            final Notification postedNotification = sbn.getNotification();
             if (!postedNotification.isMediaNotification()) {
                 return;
             }
             synchronized (mLock) {
                 mMediaNotifications.putIfAbsent(uid, new HashSet<>());
-                mMediaNotifications.get(uid).add(postedNotification);
+                mMediaNotifications.get(uid).add(sbn);
                 for (MediaSessionRecordImpl mediaSessionRecord :
                         mUserEngagedSessionsForFgs.getOrDefault(uid, Set.of())) {
                     if (mediaSessionRecord.isLinkedToNotification(postedNotification)) {
-                        startFgsDelegateLocked(mediaSessionRecord);
+                        setFgsActiveLocked(mediaSessionRecord, sbn);
                         return;
                     }
                 }
@@ -3286,9 +3299,9 @@
                 return;
             }
             synchronized (mLock) {
-                Set<Notification> uidMediaNotifications = mMediaNotifications.get(uid);
+                Set<StatusBarNotification> uidMediaNotifications = mMediaNotifications.get(uid);
                 if (uidMediaNotifications != null) {
-                    uidMediaNotifications.remove(removedNotification);
+                    uidMediaNotifications.remove(sbn);
                     if (uidMediaNotifications.isEmpty()) {
                         mMediaNotifications.remove(uid);
                     }
@@ -3300,8 +3313,7 @@
                 if (notificationRecord == null) {
                     return;
                 }
-
-                stopFgsIfNoSessionIsLinkedToNotification(notificationRecord);
+                setFgsInactiveIfNoSessionIsLinkedToNotification(notificationRecord);
             }
         }
 
diff --git a/services/core/java/com/android/server/notification/GroupHelper.java b/services/core/java/com/android/server/notification/GroupHelper.java
index f914551..4b41696 100644
--- a/services/core/java/com/android/server/notification/GroupHelper.java
+++ b/services/core/java/com/android/server/notification/GroupHelper.java
@@ -59,6 +59,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Objects;
 import java.util.Set;
 import java.util.function.Predicate;
@@ -243,7 +244,7 @@
                 if (!sbn.isAppGroup()) {
                     sbnToBeAutogrouped = maybeGroupWithSections(record, autogroupSummaryExists);
                 } else {
-                    maybeUngroupWithSections(record);
+                    maybeUngroupOnAppGrouped(record);
                 }
             } else {
                 final StatusBarNotification sbn = record.getSbn();
@@ -553,11 +554,13 @@
     }
 
     /**
-     * A non-app grouped notification has been added or updated
+     * A non-app-grouped notification has been added or updated
      * Evaluate if:
      * (a) an existing autogroup summary needs updated attributes
      * (b) a new autogroup summary needs to be added with correct attributes
      * (c) other non-app grouped children need to be moved to the autogroup
+     * (d) the notification has been updated from a groupable to a non-groupable section and needs
+     *  to trigger a cleanup
      *
      * This method implements autogrouping with sections support.
      *
@@ -567,11 +570,11 @@
             boolean autogroupSummaryExists) {
         final StatusBarNotification sbn = record.getSbn();
         boolean sbnToBeAutogrouped = false;
-
         final NotificationSectioner sectioner = getSection(record);
         if (sectioner == null) {
+            maybeUngroupOnNonGroupableUpdate(record);
             if (DEBUG) {
-                Log.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
+                Slog.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
             }
             return false;
         }
@@ -584,7 +587,6 @@
         if (record.getGroupKey().equals(fullAggregateGroupKey.toString())) {
             return false;
         }
-
         synchronized (mAggregatedNotifications) {
             ArrayMap<String, NotificationAttributes> ungrouped =
                 mUngroupedAbuseNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
@@ -601,11 +603,11 @@
             if (ungrouped.size() >= mAutoGroupAtCount || autogroupSummaryExists) {
                 if (DEBUG) {
                     if (ungrouped.size() >= mAutoGroupAtCount) {
-                        Log.i(TAG,
+                        Slog.i(TAG,
                             "Found >=" + mAutoGroupAtCount
                                 + " ungrouped notifications => force grouping");
                     } else {
-                        Log.i(TAG, "Found aggregate summary => force grouping");
+                        Slog.i(TAG, "Found aggregate summary => force grouping");
                     }
                 }
 
@@ -642,7 +644,24 @@
     }
 
     /**
-     * A notification was added that's app grouped.
+     * A notification was added that was previously part of a valid section and needs to trigger
+     * GH state cleanup.
+     */
+    private void maybeUngroupOnNonGroupableUpdate(NotificationRecord record) {
+        maybeUngroupWithSections(record, getPreviousValidSectionKey(record));
+    }
+
+    /**
+     * A notification was added that is app-grouped.
+     */
+    private void maybeUngroupOnAppGrouped(NotificationRecord record) {
+        maybeUngroupWithSections(record, getSectionGroupKeyWithFallback(record));
+    }
+
+    /**
+     * Called when a notification is posted and is either app-grouped or was previously part of
+     * a valid section and needs to trigger GH state cleanup.
+     *
      * Evaluate whether:
      * (a) an existing autogroup summary needs updated attributes
      * (b) if we need to remove our autogroup overlay for this notification
@@ -652,13 +671,20 @@
      *
      * And updates the internal state of un-app-grouped notifications and their flags.
      */
-    private void maybeUngroupWithSections(NotificationRecord record) {
+    private void maybeUngroupWithSections(NotificationRecord record,
+            @Nullable FullyQualifiedGroupKey fullAggregateGroupKey) {
+        if (fullAggregateGroupKey == null) {
+            if (DEBUG) {
+                Slog.i(TAG,
+                        "Skipping maybeUngroupWithSections for " + record
+                            + " no valid section found.");
+            }
+            return;
+        }
+
         final StatusBarNotification sbn = record.getSbn();
         final String pkgName = sbn.getPackageName();
         final int userId = record.getUserId();
-        final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(userId,
-                pkgName, getSection(record));
-
         synchronized (mAggregatedNotifications) {
             // if this notification still exists and has an autogroup overlay, but is now
             // grouped by the app, clear the overlay
@@ -675,21 +701,22 @@
                 mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
 
                 if (DEBUG) {
-                    Log.i(TAG, "maybeUngroup removeAutoGroup: " + record);
+                    Slog.i(TAG, "maybeUngroup removeAutoGroup: " + record);
                 }
 
                 mCallback.removeAutoGroup(sbn.getKey());
 
                 if (aggregatedNotificationsAttrs.isEmpty()) {
                     if (DEBUG) {
-                        Log.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
+                        Slog.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
                     }
                     mCallback.removeAutoGroupSummary(userId, pkgName,
                             fullAggregateGroupKey.toString());
                     mAggregatedNotifications.remove(fullAggregateGroupKey);
                 } else {
                     if (DEBUG) {
-                        Log.i(TAG, "Aggregate group not empty, updating: " + fullAggregateGroupKey);
+                        Slog.i(TAG,
+                                "Aggregate group not empty, updating: " + fullAggregateGroupKey);
                     }
                     updateAggregateAppGroup(fullAggregateGroupKey, sbn.getKey(), true, 0);
                 }
@@ -860,8 +887,15 @@
         final StatusBarNotification sbn = record.getSbn();
         final String pkgName = sbn.getPackageName();
         final int userId = record.getUserId();
-        final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(userId,
-                pkgName, getSection(record));
+
+        final FullyQualifiedGroupKey fullAggregateGroupKey = getSectionGroupKeyWithFallback(record);
+        if (fullAggregateGroupKey == null) {
+            if (DEBUG) {
+                Slog.i(TAG,
+                        "Skipping autogroup cleanup for " + record + " no valid section found.");
+            }
+            return;
+        }
 
         synchronized (mAggregatedNotifications) {
             ArrayMap<String, NotificationAttributes> ungrouped =
@@ -879,14 +913,15 @@
 
                 if (aggregatedNotificationsAttrs.isEmpty()) {
                     if (DEBUG) {
-                        Log.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
+                        Slog.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
                     }
                     mCallback.removeAutoGroupSummary(userId, pkgName,
                             fullAggregateGroupKey.toString());
                     mAggregatedNotifications.remove(fullAggregateGroupKey);
                 } else {
                     if (DEBUG) {
-                        Log.i(TAG, "Aggregate group not empty, updating: " + fullAggregateGroupKey);
+                        Slog.i(TAG,
+                                "Aggregate group not empty, updating: " + fullAggregateGroupKey);
                     }
                     updateAggregateAppGroup(fullAggregateGroupKey, sbn.getKey(), true, 0);
                 }
@@ -901,6 +936,52 @@
     }
 
     /**
+     * Get the section key for a notification. If the section is invalid, ie. notification is not
+     * auto-groupable, then return the previous valid section, if any.
+     * @param record the notification
+     * @return a section group key, null if not found
+     */
+    @Nullable
+    private FullyQualifiedGroupKey getSectionGroupKeyWithFallback(final NotificationRecord record) {
+        final NotificationSectioner sectioner = getSection(record);
+        if (sectioner != null) {
+            return new FullyQualifiedGroupKey(record.getUserId(), record.getSbn().getPackageName(),
+                sectioner);
+        } else {
+            return getPreviousValidSectionKey(record);
+        }
+    }
+
+    /**
+     * Get the previous valid section key of a notification that may have been updated to an invalid
+     * section. This is needed in case a notification is updated as an ungroupable (invalid section)
+     *  => auto-groups need to be updated/GH state cleanup.
+     * @param record the notification
+     * @return a section group key or null if not found
+     */
+    @Nullable
+    private FullyQualifiedGroupKey getPreviousValidSectionKey(final NotificationRecord record) {
+        synchronized (mAggregatedNotifications) {
+            final String recordKey = record.getKey();
+            // Search in ungrouped
+            for (Entry<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
+                        ungroupedSection : mUngroupedAbuseNotifications.entrySet()) {
+                if (ungroupedSection.getValue().containsKey(recordKey)) {
+                    return ungroupedSection.getKey();
+                }
+            }
+            // Search in aggregated
+            for (Entry<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
+                    aggregatedSection : mAggregatedNotifications.entrySet()) {
+                if (aggregatedSection.getValue().containsKey(recordKey)) {
+                    return aggregatedSection.getKey();
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
      * Called when a child notification is removed, after some delay, so that this helper can
      * trigger a forced grouping if the group has become sparse/singleton
      * or only the summary is left.
diff --git a/services/core/java/com/android/server/wm/DisplayContent.java b/services/core/java/com/android/server/wm/DisplayContent.java
index bccf6b20..e190963 100644
--- a/services/core/java/com/android/server/wm/DisplayContent.java
+++ b/services/core/java/com/android/server/wm/DisplayContent.java
@@ -791,6 +791,12 @@
     private WindowState mLastWakeLockHoldingWindow;
 
     /**
+     * Whether display is allowed to ignore all activity size restrictions.
+     * @see #isDisplayIgnoreActivitySizeRestrictions
+     */
+    private final boolean mIgnoreActivitySizeRestrictions;
+
+    /**
      * The helper of policy controller.
      *
      * @see DisplayWindowPolicyControllerHelper
@@ -1220,6 +1226,8 @@
 
         setWindowingMode(WINDOWING_MODE_FULLSCREEN);
         mWmService.mDisplayWindowSettings.applySettingsToDisplayLocked(this);
+        mIgnoreActivitySizeRestrictions =
+                mWmService.mDisplayWindowSettings.isIgnoreActivitySizeRestrictionsLocked(this);
 
         // Sets the initial touch mode state.
         mInTouchMode = mWmService.mContext.getResources().getBoolean(
@@ -5810,7 +5818,7 @@
      * {@link VirtualDisplayConfig.Builder#setIgnoreActivitySizeRestrictions}.</p>
      */
     boolean isDisplayIgnoreActivitySizeRestrictions() {
-        return mWmService.mDisplayWindowSettings.isIgnoreActivitySizeRestrictionsLocked(this);
+        return mIgnoreActivitySizeRestrictions;
     }
 
     /**
diff --git a/services/core/java/com/android/server/wm/ImeInsetsSourceProvider.java b/services/core/java/com/android/server/wm/ImeInsetsSourceProvider.java
index 59a9e85..4230cd8 100644
--- a/services/core/java/com/android/server/wm/ImeInsetsSourceProvider.java
+++ b/services/core/java/com/android/server/wm/ImeInsetsSourceProvider.java
@@ -286,7 +286,12 @@
                 if (isImeInputTarget(caller)) {
                     reportImeInputTargetStateToControlTarget(caller, controlTarget, statsToken);
                 } else {
-                    // TODO(b/353463205) add ImeTracker?
+                    ProtoLog.w(WM_DEBUG_IME,
+                            "Tried to update client visibility for non-IME input target %s "
+                                    + "(current target: %s)",
+                            caller, mDisplayContent.getImeInputTarget());
+                    ImeTracker.forLogging().onFailed(statsToken,
+                            ImeTracker.PHASE_SERVER_UPDATE_CLIENT_VISIBILITY);
                 }
             }
             return false;
diff --git a/services/devicepolicy/java/com/android/server/devicepolicy/DevicePolicyManagerService.java b/services/devicepolicy/java/com/android/server/devicepolicy/DevicePolicyManagerService.java
index a48fa5e..ac1219c 100644
--- a/services/devicepolicy/java/com/android/server/devicepolicy/DevicePolicyManagerService.java
+++ b/services/devicepolicy/java/com/android/server/devicepolicy/DevicePolicyManagerService.java
@@ -9085,7 +9085,9 @@
         }
         CallerIdentity caller = getCallerIdentity(who);
 
-        Objects.requireNonNull(who, "ComponentName is null");
+        if (!Flags.setAutoTimeEnabledCoexistence()) {
+            Objects.requireNonNull(who, "ComponentName is null");
+        }
         Preconditions.checkCallAuthorization(isProfileOwnerOnUser0(caller)
                 || isProfileOwnerOfOrganizationOwnedDevice(caller) || isDefaultDeviceOwner(caller));
 
@@ -9165,7 +9167,9 @@
 
         CallerIdentity caller = getCallerIdentity(who);
 
-        Objects.requireNonNull(who, "ComponentName is null");
+        if (!Flags.setAutoTimeZoneEnabledCoexistence()) {
+            Objects.requireNonNull(who, "ComponentName is null");
+        }
         Preconditions.checkCallAuthorization(isProfileOwnerOnUser0(caller)
                 || isProfileOwnerOfOrganizationOwnedDevice(caller) || isDefaultDeviceOwner(
                 caller));
diff --git a/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyCoordinatorTest.kt b/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyCoordinatorTest.kt
index e4b461f..5d42713 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyCoordinatorTest.kt
+++ b/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyCoordinatorTest.kt
@@ -20,6 +20,7 @@
 import android.util.DisplayMetrics
 import android.view.Display
 import android.view.DisplayInfo
+import com.google.common.truth.Truth.assertThat
 import org.junit.Before
 import org.junit.Test
 import org.mockito.ArgumentMatchers.anyFloat
@@ -87,4 +88,21 @@
         verify(mockTopology, never()).addDisplay(anyInt(), anyFloat(), anyFloat())
         verify(mockTopologyChangedCallback, never()).invoke(any())
     }
+
+    @Test
+    fun getTopology_copy() {
+        assertThat(coordinator.topology).isEqualTo(mockTopologyCopy)
+    }
+
+    @Test
+    fun setTopology_normalize() {
+        val topology = mock<DisplayTopology>()
+        val topologyCopy = mock<DisplayTopology>()
+        whenever(topology.copy()).thenReturn(topologyCopy)
+
+        coordinator.topology = topology
+
+        verify(topology).normalize()
+        verify(mockTopologyChangedCallback).invoke(topologyCopy)
+    }
 }
\ No newline at end of file
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
index dd278fc..6cb2429 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
@@ -2338,6 +2338,177 @@
     }
 
     @Test
+    @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS})
+    public void testUpdateToUngroupableSection_cleanupUngrouped() {
+        final String pkg = "package";
+        // Post notification w/o group in a valid section
+        NotificationRecord notification = spy(getNotificationRecord(pkg, 0, "", mUser,
+                "", false, IMPORTANCE_LOW));
+        Notification n = mock(Notification.class);
+        StatusBarNotification sbn = spy(getSbn(pkg, 0, "0", UserHandle.SYSTEM));
+        when(notification.getNotification()).thenReturn(n);
+        when(notification.getSbn()).thenReturn(sbn);
+        when(sbn.getNotification()).thenReturn(n);
+        when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+        assertThat(GroupHelper.getSection(notification)).isNotNull();
+        mGroupHelper.onNotificationPosted(notification, false);
+
+        // Update notification to invalid section
+        when(n.isStyle(Notification.CallStyle.class)).thenReturn(true);
+        assertThat(GroupHelper.getSection(notification)).isNull();
+        boolean needsAutogrouping = mGroupHelper.onNotificationPosted(notification, false);
+        assertThat(needsAutogrouping).isFalse();
+
+        // Check that GH internal state (ungrouped list) was cleaned-up
+        // Post AUTOGROUP_AT_COUNT-1 notifications => should not autogroup
+        for (int i = 0; i < AUTOGROUP_AT_COUNT - 1; i++) {
+            int id = 42 + i;
+            notification = getNotificationRecord(pkg, id, "" + id, mUser,
+                null, false, IMPORTANCE_LOW);
+            mGroupHelper.onNotificationPosted(notification, false);
+        }
+
+        verify(mCallback, never()).addAutoGroupSummary(anyInt(), anyString(), anyString(),
+                anyString(), anyInt(), any());
+        verify(mCallback, never()).addAutoGroup(anyString(), anyString(), anyBoolean());
+    }
+
+    @Test
+    @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS,
+            android.app.Flags.FLAG_CHECK_AUTOGROUP_BEFORE_POST})
+    public void testUpdateToUngroupableSection_afterAutogroup_isUngrouped() {
+        final String pkg = "package";
+        final List<NotificationRecord> notificationList = new ArrayList<>();
+        // Post notification w/o group in a valid section
+        for (int i = 0; i < AUTOGROUP_AT_COUNT; i++) {
+            NotificationRecord notification = spy(getNotificationRecord(pkg, i, "" + i, mUser,
+                    "", false, IMPORTANCE_LOW));
+            Notification n = mock(Notification.class);
+            StatusBarNotification sbn = spy(getSbn(pkg, i, "" + i, UserHandle.SYSTEM));
+            when(notification.getNotification()).thenReturn(n);
+            when(notification.getSbn()).thenReturn(sbn);
+            when(sbn.getNotification()).thenReturn(n);
+            when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+            assertThat(GroupHelper.getSection(notification)).isNotNull();
+            mGroupHelper.onNotificationPosted(notification, false);
+            notificationList.add(notification);
+        }
+
+        final String expectedGroupKey = GroupHelper.getFullAggregateGroupKey(pkg,
+                AGGREGATE_GROUP_KEY + "SilentSection", UserHandle.SYSTEM.getIdentifier());
+        verify(mCallback, times(1)).addAutoGroupSummary(anyInt(), eq(pkg), anyString(),
+                eq(expectedGroupKey), anyInt(), any());
+        verify(mCallback, times(AUTOGROUP_AT_COUNT - 1)).addAutoGroup(anyString(),
+                eq(expectedGroupKey), eq(true));
+
+        // Update a notification to invalid section
+        Mockito.reset(mCallback);
+        final NotificationRecord notifToInvalidate = notificationList.get(0);
+        when(notifToInvalidate.getNotification().isStyle(Notification.CallStyle.class)).thenReturn(
+                true);
+        assertThat(GroupHelper.getSection(notifToInvalidate)).isNull();
+        boolean needsAutogrouping = mGroupHelper.onNotificationPosted(notifToInvalidate, true);
+        assertThat(needsAutogrouping).isFalse();
+
+        // Check that the updated notification was removed from the autogroup
+        verify(mCallback, times(1)).removeAutoGroup(eq(notifToInvalidate.getKey()));
+        verify(mCallback, never()).removeAutoGroupSummary(anyInt(), anyString(), anyString());
+        verify(mCallback, times(1)).updateAutogroupSummary(anyInt(), anyString(),
+                eq(expectedGroupKey), any());
+    }
+
+    @Test
+    @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS,
+            android.app.Flags.FLAG_CHECK_AUTOGROUP_BEFORE_POST})
+    public void testUpdateToUngroupableSection_onRemoved_isUngrouped() {
+        final String pkg = "package";
+        final List<NotificationRecord> notificationList = new ArrayList<>();
+        // Post notification w/o group in a valid section
+        for (int i = 0; i < AUTOGROUP_AT_COUNT; i++) {
+            NotificationRecord notification = spy(getNotificationRecord(pkg, i, "" + i, mUser,
+                    "", false, IMPORTANCE_LOW));
+            Notification n = mock(Notification.class);
+            StatusBarNotification sbn = spy(getSbn(pkg, i, "" + i, UserHandle.SYSTEM));
+            when(notification.getNotification()).thenReturn(n);
+            when(notification.getSbn()).thenReturn(sbn);
+            when(sbn.getNotification()).thenReturn(n);
+            when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+            assertThat(GroupHelper.getSection(notification)).isNotNull();
+            mGroupHelper.onNotificationPosted(notification, false);
+            notificationList.add(notification);
+        }
+
+        final String expectedGroupKey = GroupHelper.getFullAggregateGroupKey(pkg,
+                AGGREGATE_GROUP_KEY + "SilentSection", UserHandle.SYSTEM.getIdentifier());
+        verify(mCallback, times(1)).addAutoGroupSummary(anyInt(), eq(pkg), anyString(),
+                eq(expectedGroupKey), anyInt(), any());
+        verify(mCallback, times(AUTOGROUP_AT_COUNT - 1)).addAutoGroup(anyString(),
+                eq(expectedGroupKey), eq(true));
+
+        // Update a notification to invalid section and removed it
+        Mockito.reset(mCallback);
+        final NotificationRecord notifToInvalidate = notificationList.get(0);
+        when(notifToInvalidate.getNotification().isStyle(Notification.CallStyle.class)).thenReturn(
+                true);
+        assertThat(GroupHelper.getSection(notifToInvalidate)).isNull();
+        notificationList.remove(notifToInvalidate);
+        mGroupHelper.onNotificationRemoved(notifToInvalidate, notificationList);
+
+        // Check that the autogroup was updated
+        verify(mCallback, never()).removeAutoGroup(anyString());
+        verify(mCallback, never()).removeAutoGroupSummary(anyInt(), anyString(), anyString());
+        verify(mCallback, times(1)).updateAutogroupSummary(anyInt(), anyString(),
+                eq(expectedGroupKey), any());
+    }
+
+    @Test
+    @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS})
+    public void testUpdateToUngroupableSection_afterForceGrouping_isUngrouped() {
+        final String pkg = "package";
+        final String groupName = "testGroup";
+        final List<NotificationRecord> notificationList = new ArrayList<>();
+        final ArrayMap<String, NotificationRecord> summaryByGroup = new ArrayMap<>();
+        // Post valid section summary notifications without children => force group
+        for (int i = 0; i < AUTOGROUP_AT_COUNT; i++) {
+            NotificationRecord notification = spy(getNotificationRecord(mPkg, i, "" + i, mUser,
+                    groupName, true, IMPORTANCE_LOW));
+            Notification n = mock(Notification.class);
+            StatusBarNotification sbn = spy(getSbn(pkg, i, "" + i, UserHandle.SYSTEM, groupName));
+            when(notification.getNotification()).thenReturn(n);
+            when(notification.getSbn()).thenReturn(sbn);
+            when(n.getGroup()).thenReturn(groupName);
+            when(sbn.getNotification()).thenReturn(n);
+            when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+            assertThat(GroupHelper.getSection(notification)).isNotNull();
+            notificationList.add(notification);
+            mGroupHelper.onNotificationPostedWithDelay(notification, notificationList,
+                    summaryByGroup);
+        }
+
+        final String expectedGroupKey = GroupHelper.getFullAggregateGroupKey(pkg,
+                AGGREGATE_GROUP_KEY + "SilentSection", UserHandle.SYSTEM.getIdentifier());
+        verify(mCallback, times(1)).addAutoGroupSummary(anyInt(), eq(pkg), anyString(),
+                eq(expectedGroupKey), anyInt(), any());
+        verify(mCallback, times(AUTOGROUP_AT_COUNT)).addAutoGroup(anyString(),
+                eq(expectedGroupKey), eq(true));
+
+        // Update a notification to invalid section
+        Mockito.reset(mCallback);
+        final NotificationRecord notifToInvalidate = notificationList.get(0);
+        when(notifToInvalidate.getNotification().isStyle(Notification.CallStyle.class)).thenReturn(
+                true);
+        assertThat(GroupHelper.getSection(notifToInvalidate)).isNull();
+        boolean needsAutogrouping = mGroupHelper.onNotificationPosted(notifToInvalidate, true);
+        assertThat(needsAutogrouping).isFalse();
+
+        // Check that GH internal state (ungrouped list) was cleaned-up
+        verify(mCallback, times(1)).removeAutoGroup(eq(notifToInvalidate.getKey()));
+        verify(mCallback, never()).removeAutoGroupSummary(anyInt(), anyString(), anyString());
+        verify(mCallback, times(1)).updateAutogroupSummary(anyInt(), anyString(),
+                eq(expectedGroupKey), any());
+    }
+
+    @Test
     @EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
     public void testMoveAggregateGroups_updateChannel() {
         final String pkg = "package";