Merge "Fix bad parcelable for deviceId" into main
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/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/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/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/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/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";