[Ambient Volume] UI of volume sliders in Settings

Collapse/expand the controls when clicking on the hearder with arrow.

Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control
Bug: 357878944
Test: atest AmbientVolumePreferenceTest
Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest
Test: atest BluetoothDetailsHearingDeviceControllerTest

Change-Id: I845a4397601e563ed027d7d2a0a13651e95de708
diff --git a/res/layout/preference_ambient_volume.xml b/res/layout/preference_ambient_volume.xml
new file mode 100644
index 0000000..a8595c6
--- /dev/null
+++ b/res/layout/preference_ambient_volume.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2024 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="center_vertical"
+    android:minHeight="?android:attr/listPreferredItemHeightSmall"
+    android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+    android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+    android:clickable="false"
+    android:orientation="horizontal">
+
+    <include
+        layout="@layout/settingslib_icon_frame"
+        android:layout_width="48dp"
+        android:layout_height="48dp"/>
+
+    <TextView
+        android:id="@android:id/title"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:singleLine="true"
+        android:textAppearance="?android:attr/textAppearanceListItem"
+        android:textColor="?android:attr/textColorPrimary"
+        android:ellipsize="marquee"
+        android:fadingEdge="horizontal"/>
+    <ImageView
+        android:id="@+id/expand_icon"
+        android:layout_width="48dp"
+        android:layout_height="48dp"
+        android:padding="10dp"
+        android:contentDescription="@null"
+        android:tint="@androidprv:color/materialColorOnPrimaryContainer"
+        android:src="@drawable/ic_keyboard_arrow_down"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f7afbd6..c7cdd6e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -164,6 +164,12 @@
     <string name="bluetooth_hearing_aids_presets_empty_list_message">There are no presets programmed by your audiologist</string>
     <!-- Message when selecting hearing aids presets failed. [CHAR LIMIT=NONE] -->
     <string name="bluetooth_hearing_aids_presets_error">Couldn\u2019t update preset</string>
+    <!-- Connected devices settings. Title for ambient volume control which controls the remote device's microphone input volume. [CHAR LIMIT=60] -->
+    <string name="bluetooth_ambient_volume_control">Surroundings</string>
+    <!-- Connected devices settings. Content description for the icon to expand the unified ambient volume control to left and right separated controls. [CHAR LIMIT=NONE] -->
+    <string name="bluetooth_ambient_volume_control_expand">Expand to left and right separated controls</string>
+    <!-- Connected devices settings. Content description for the icon to collapse the left and right separated ambient volume controls to unified control. [CHAR LIMIT=NONE] -->
+    <string name="bluetooth_ambient_volume_control_collapse">Collapse to unified control</string>
     <!-- Connected devices settings. Title of the preference to show the entrance of the audio output page. It can change different types of audio are played on phone or other bluetooth devices. [CHAR LIMIT=35] -->
     <string name="bluetooth_audio_routing_title">Audio output</string>
     <!-- Title for bluetooth audio routing page footer. [CHAR LIMIT=30] -->
diff --git a/src/com/android/settings/bluetooth/AmbientVolumePreference.java b/src/com/android/settings/bluetooth/AmbientVolumePreference.java
new file mode 100644
index 0000000..0122203
--- /dev/null
+++ b/src/com/android/settings/bluetooth/AmbientVolumePreference.java
@@ -0,0 +1,182 @@
+/*
+ * 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.settings.bluetooth;
+
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+
+import android.content.Context;
+import android.util.ArrayMap;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceViewHolder;
+
+import com.android.settings.R;
+import com.android.settings.widget.SeekBarPreference;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A preference group of ambient volume controls.
+ *
+ * <p> It consists of a header with an expand icon and volume sliders for unified control and
+ * separated control for devices in the same set. Toggle the expand icon will make the UI switch
+ * between unified and separated control.
+ */
+public class AmbientVolumePreference extends PreferenceGroup {
+
+    /** Interface definition for a callback to be invoked when the icon is clicked. */
+    public interface OnIconClickListener {
+        /** Called when the expand icon is clicked. */
+        void onExpandIconClick();
+    };
+
+    static final float ROTATION_COLLAPSED = 0f;
+    static final float ROTATION_EXPANDED = 180f;
+    static final int SIDE_UNIFIED = 999;
+    static final List<Integer> VALID_SIDES = List.of(SIDE_UNIFIED, SIDE_LEFT, SIDE_RIGHT);
+
+    @Nullable
+    private OnIconClickListener mListener;
+    @Nullable
+    private View mExpandIcon;
+    private boolean mExpandable = true;
+    private boolean mExpanded = false;
+    private Map<Integer, SeekBarPreference> mSideToSliderMap = new ArrayMap<>();
+
+    public AmbientVolumePreference(@NonNull Context context) {
+        super(context, null);
+        setLayoutResource(R.layout.preference_ambient_volume);
+        setIcon(com.android.settingslib.R.drawable.ic_ambient_volume);
+        setTitle(R.string.bluetooth_ambient_volume_control);
+        setSelectable(false);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull PreferenceViewHolder holder) {
+        super.onBindViewHolder(holder);
+        holder.setDividerAllowedAbove(false);
+        holder.setDividerAllowedBelow(false);
+
+        mExpandIcon = holder.itemView.requireViewById(R.id.expand_icon);
+        mExpandIcon.setOnClickListener(v -> {
+            setExpanded(!mExpanded);
+            if (mListener != null) {
+                mListener.onExpandIconClick();
+            }
+        });
+        updateExpandIcon();
+    }
+
+    void setExpandable(boolean expandable) {
+        mExpandable = expandable;
+        if (!mExpandable) {
+            setExpanded(false);
+        }
+        updateExpandIcon();
+    }
+
+    boolean isExpandable() {
+        return mExpandable;
+    }
+
+    void setExpanded(boolean expanded) {
+        if (!mExpandable && expanded) {
+            return;
+        }
+        mExpanded = expanded;
+        updateExpandIcon();
+        updateLayout();
+    }
+
+    boolean isExpanded() {
+        return mExpanded;
+    }
+
+    void setOnIconClickListener(@Nullable OnIconClickListener listener) {
+        mListener = listener;
+    }
+
+    void setSliders(Map<Integer, SeekBarPreference> sideToSliderMap) {
+        mSideToSliderMap = sideToSliderMap;
+        for (SeekBarPreference preference : sideToSliderMap.values()) {
+            if (findPreference(preference.getKey()) == null) {
+                addPreference(preference);
+            }
+        }
+        updateLayout();
+    }
+
+    void setSliderEnabled(int side, boolean enabled) {
+        SeekBarPreference slider = mSideToSliderMap.get(side);
+        if (slider != null && slider.isEnabled() != enabled) {
+            slider.setEnabled(enabled);
+            updateLayout();
+        }
+    }
+
+    void setSliderValue(int side, int value) {
+        SeekBarPreference slider = mSideToSliderMap.get(side);
+        if (slider != null && slider.getProgress() != value) {
+            slider.setProgress(value);
+        }
+    }
+
+    void setSliderRange(int side, int min, int max) {
+        SeekBarPreference slider = mSideToSliderMap.get(side);
+        if (slider != null) {
+            slider.setMin(min);
+            slider.setMax(max);
+        }
+    }
+
+    void updateLayout() {
+        mSideToSliderMap.forEach((side, slider) -> {
+            if (side == SIDE_UNIFIED) {
+                slider.setVisible(!mExpanded);
+            } else {
+                slider.setVisible(mExpanded);
+            }
+            if (!slider.isEnabled()) {
+                slider.setProgress(slider.getMin());
+            }
+        });
+    }
+
+    private void updateExpandIcon() {
+        if (mExpandIcon == null) {
+            return;
+        }
+        mExpandIcon.setVisibility(mExpandable ? VISIBLE : GONE);
+        mExpandIcon.setRotation(mExpanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED);
+        if (mExpandable) {
+            final int stringRes = mExpanded
+                    ? R.string.bluetooth_ambient_volume_control_collapse
+                    : R.string.bluetooth_ambient_volume_control_expand;
+            mExpandIcon.setContentDescription(getContext().getString(stringRes));
+        } else {
+            mExpandIcon.setContentDescription(null);
+        }
+    }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
new file mode 100644
index 0000000..0629e6e
--- /dev/null
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
@@ -0,0 +1,198 @@
+/*
+ * 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.settings.bluetooth;
+
+import static com.android.settings.bluetooth.AmbientVolumePreference.SIDE_UNIFIED;
+import static com.android.settings.bluetooth.AmbientVolumePreference.VALID_SIDES;
+import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.KEY_HEARING_DEVICE_GROUP;
+import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.ORDER_AMBIENT_VOLUME;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_INVALID;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.util.ArraySet;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.widget.SeekBarPreference;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.utils.ThreadUtils;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+
+import java.util.Set;
+
+/** A {@link BluetoothDetailsController} that manages ambient volume control preferences. */
+public class BluetoothDetailsAmbientVolumePreferenceController extends
+        BluetoothDetailsController implements Preference.OnPreferenceChangeListener {
+
+    private static final boolean DEBUG = true;
+    private static final String TAG = "AmbientPrefController";
+
+    static final String KEY_AMBIENT_VOLUME = "ambient_volume";
+    static final String KEY_AMBIENT_VOLUME_SLIDER = "ambient_volume_slider";
+    private static final int ORDER_AMBIENT_VOLUME_CONTROL_UNIFIED = 0;
+    private static final int ORDER_AMBIENT_VOLUME_CONTROL_SEPARATED = 1;
+
+    private final Set<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
+    private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
+    private final BiMap<Integer, SeekBarPreference> mSideToSliderMap = HashBiMap.create();
+
+    @Nullable
+    private PreferenceCategory mDeviceControls;
+    @Nullable
+    private AmbientVolumePreference mPreference;
+
+    public BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context,
+            @NonNull PreferenceFragmentCompat fragment,
+            @NonNull CachedBluetoothDevice device,
+            @NonNull Lifecycle lifecycle) {
+        super(context, fragment, device, lifecycle);
+    }
+
+    @Override
+    protected void init(PreferenceScreen screen) {
+        mDeviceControls = screen.findPreference(KEY_HEARING_DEVICE_GROUP);
+        if (mDeviceControls == null) {
+            return;
+        }
+        loadDevices();
+    }
+
+    @Override
+    protected void refresh() {
+        if (!isAvailable()) {
+            return;
+        }
+        // TODO: load data from remote
+        refreshControlUi();
+    }
+
+    @Override
+    public boolean isAvailable() {
+        boolean isDeviceSupportVcp = mCachedDevice.getProfiles().stream().anyMatch(
+                profile -> profile instanceof VolumeControlProfile);
+        return isDeviceSupportVcp;
+    }
+
+    @Nullable
+    @Override
+    public String getPreferenceKey() {
+        return KEY_AMBIENT_VOLUME;
+    }
+
+    @Override
+    public boolean onPreferenceChange(@NonNull Preference preference, @Nullable Object newValue) {
+        if (preference instanceof SeekBarPreference && newValue instanceof final Integer value) {
+            final int side = mSideToSliderMap.inverse().getOrDefault(preference, SIDE_INVALID);
+            if (DEBUG) {
+                Log.d(TAG, "onPreferenceChange: side=" + side + ", value=" + value);
+            }
+            if (side == SIDE_UNIFIED) {
+                // TODO: set the value on the devices
+            } else {
+                // TODO: set the value on the side device
+            }
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public void onDeviceAttributesChanged() {
+        mCachedDevices.forEach(device -> {
+            device.unregisterCallback(this);
+        });
+        mContext.getMainExecutor().execute(() -> {
+            loadDevices();
+            if (!mCachedDevices.isEmpty()) {
+                refresh();
+            }
+            ThreadUtils.postOnBackgroundThread(() ->
+                    mCachedDevices.forEach(device -> {
+                        device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+                    })
+            );
+        });
+    }
+
+    private void loadDevices() {
+        mSideToDeviceMap.clear();
+        mCachedDevices.clear();
+        if (VALID_SIDES.contains(mCachedDevice.getDeviceSide())) {
+            mSideToDeviceMap.put(mCachedDevice.getDeviceSide(), mCachedDevice.getDevice());
+            mCachedDevices.add(mCachedDevice);
+        }
+        for (CachedBluetoothDevice memberDevice : mCachedDevice.getMemberDevice()) {
+            if (VALID_SIDES.contains(memberDevice.getDeviceSide())) {
+                mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice());
+                mCachedDevices.add(memberDevice);
+            }
+        }
+        createAmbientVolumePreference();
+        createSliderPreferences();
+        if (mPreference != null) {
+            mPreference.setExpandable(mSideToDeviceMap.size() > 1);
+            mPreference.setSliders((mSideToSliderMap));
+        }
+    }
+
+    private void createAmbientVolumePreference() {
+        if (mPreference != null || mDeviceControls == null) {
+            return;
+        }
+        mPreference = new AmbientVolumePreference(mDeviceControls.getContext());
+        mPreference.setKey(KEY_AMBIENT_VOLUME);
+        mPreference.setOrder(ORDER_AMBIENT_VOLUME);
+        if (mDeviceControls.findPreference(mPreference.getKey()) == null) {
+            mDeviceControls.addPreference(mPreference);
+        }
+    }
+
+    private void createSliderPreferences() {
+        mSideToDeviceMap.forEach((s, d) ->
+                createSliderPreference(s, ORDER_AMBIENT_VOLUME_CONTROL_SEPARATED + s));
+        createSliderPreference(SIDE_UNIFIED, ORDER_AMBIENT_VOLUME_CONTROL_UNIFIED);
+    }
+
+    private void createSliderPreference(int side, int order) {
+        if (mSideToSliderMap.containsKey(side) || mDeviceControls == null) {
+            return;
+        }
+        SeekBarPreference preference = new SeekBarPreference(mDeviceControls.getContext());
+        preference.setKey(KEY_AMBIENT_VOLUME_SLIDER + "_" + side);
+        preference.setOrder(order);
+        preference.setOnPreferenceChangeListener(this);
+        mSideToSliderMap.put(side, preference);
+    }
+
+    /** Refreshes the control UI visibility and enabled state. */
+    private void refreshControlUi() {
+        if (mPreference != null) {
+            mPreference.updateLayout();
+        }
+    }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
index 3703b71..7236d8c 100644
--- a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
@@ -42,6 +42,7 @@
 
     public static final int ORDER_HEARING_DEVICE_SETTINGS = 1;
     public static final int ORDER_HEARING_AIDS_PRESETS = 2;
+    public static final int ORDER_AMBIENT_VOLUME = 4;
     static final String KEY_HEARING_DEVICE_GROUP = "hearing_device_group";
 
     private final List<BluetoothDetailsController> mControllers = new ArrayList<>();
@@ -107,6 +108,10 @@
             mControllers.add(new BluetoothDetailsHearingAidsPresetsController(mContext, mFragment,
                     mManager, mCachedDevice, mLifecycle));
         }
+        if (com.android.settingslib.flags.Flags.hearingDevicesAmbientVolumeControl()) {
+            mControllers.add(new BluetoothDetailsAmbientVolumePreferenceController(mContext,
+                    mFragment, mCachedDevice, mLifecycle));
+        }
     }
 
     @NonNull
diff --git a/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java
new file mode 100644
index 0000000..75f3c9a
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.settings.bluetooth;
+
+import static com.android.settings.bluetooth.AmbientVolumePreference.ROTATION_COLLAPSED;
+import static com.android.settings.bluetooth.AmbientVolumePreference.ROTATION_EXPANDED;
+import static com.android.settings.bluetooth.AmbientVolumePreference.SIDE_UNIFIED;
+import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME;
+import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME_SLIDER;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.util.ArrayMap;
+import android.view.View;
+import android.widget.ImageView;
+
+import androidx.preference.PreferenceManager;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.PreferenceViewHolder;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settings.widget.SeekBarPreference;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Map;
+
+/** Tests for {@link AmbientVolumePreference}. */
+@RunWith(RobolectricTestRunner.class)
+public class AmbientVolumePreferenceTest {
+
+    private static final String KEY_UNIFIED_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_UNIFIED;
+    private static final String KEY_LEFT_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_LEFT;
+    private static final String KEY_RIGHT_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_RIGHT;
+
+    @Rule
+    public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+    @Spy
+    private Context mContext = ApplicationProvider.getApplicationContext();
+    @Mock
+    private AmbientVolumePreference.OnIconClickListener mListener;
+    @Mock
+    private View mItemView;
+
+    private AmbientVolumePreference mPreference;
+    private ImageView mExpandIcon;
+    private final Map<Integer, SeekBarPreference> mSideToSlidersMap = new ArrayMap<>();
+
+    @Before
+    public void setUp() {
+        PreferenceManager preferenceManager = new PreferenceManager(mContext);
+        PreferenceScreen preferenceScreen = preferenceManager.createPreferenceScreen(mContext);
+        mPreference = new AmbientVolumePreference(mContext);
+        mPreference.setKey(KEY_AMBIENT_VOLUME);
+        mPreference.setOnIconClickListener(mListener);
+        mPreference.setExpandable(true);
+        preferenceScreen.addPreference(mPreference);
+
+        prepareSliders();
+        mPreference.setSliders(mSideToSlidersMap);
+
+        mExpandIcon = new ImageView(mContext);
+        when(mItemView.requireViewById(R.id.expand_icon)).thenReturn(mExpandIcon);
+
+        PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests(
+                mItemView);
+        mPreference.onBindViewHolder(preferenceViewHolder);
+    }
+
+    @Test
+    public void setExpandable_expandable_expandIconVisible() {
+        mPreference.setExpandable(true);
+
+        assertThat(mExpandIcon.getVisibility()).isEqualTo(View.VISIBLE);
+    }
+
+    @Test
+    public void setExpandable_notExpandable_expandIconGone() {
+        mPreference.setExpandable(false);
+
+        assertThat(mExpandIcon.getVisibility()).isEqualTo(View.GONE);
+    }
+
+    @Test
+    public void setExpanded_expanded_assertControlUiCorrect() {
+        mPreference.setExpanded(true);
+
+        assertControlUiCorrect();
+    }
+
+    @Test
+    public void setExpanded_notExpanded_assertControlUiCorrect() {
+        mPreference.setExpanded(false);
+
+        assertControlUiCorrect();
+    }
+
+    private void assertControlUiCorrect() {
+        final boolean expanded = mPreference.isExpanded();
+        assertThat(mSideToSlidersMap.get(SIDE_UNIFIED).isVisible()).isEqualTo(!expanded);
+        assertThat(mSideToSlidersMap.get(SIDE_LEFT).isVisible()).isEqualTo(expanded);
+        assertThat(mSideToSlidersMap.get(SIDE_RIGHT).isVisible()).isEqualTo(expanded);
+        final float rotation = expanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED;
+        assertThat(mExpandIcon.getRotation()).isEqualTo(rotation);
+    }
+
+    private void prepareSliders() {
+        prepareSlider(SIDE_UNIFIED);
+        prepareSlider(SIDE_LEFT);
+        prepareSlider(SIDE_RIGHT);
+    }
+
+    private void prepareSlider(int side) {
+        SeekBarPreference slider = new SeekBarPreference(mContext);
+        if (side == SIDE_LEFT) {
+            slider.setKey(KEY_LEFT_SLIDER);
+        } else if (side == SIDE_RIGHT) {
+            slider.setKey(KEY_RIGHT_SLIDER);
+        } else {
+            slider.setKey(KEY_UNIFIED_SLIDER);
+        }
+        mSideToSlidersMap.put(side, slider);
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
new file mode 100644
index 0000000..89209d1
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.settings.bluetooth;
+
+import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME;
+import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME_SLIDER;
+import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.KEY_HEARING_DEVICE_GROUP;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothDevice;
+import android.os.Looper;
+
+import androidx.preference.PreferenceCategory;
+
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settings.widget.SeekBarPreference;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+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 java.util.Set;
+
+/** Tests for {@link BluetoothDetailsAmbientVolumePreferenceController}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {
+        ShadowThreadUtils.class
+})
+public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends
+        BluetoothDetailsControllerTestBase {
+
+    @Rule
+    public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+    private static final String LEFT_CONTROL_KEY = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_LEFT;
+    private static final String RIGHT_CONTROL_KEY = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_RIGHT;
+    private static final String TEST_ADDRESS = "00:00:00:00:11";
+    private static final String TEST_MEMBER_ADDRESS = "00:00:00:00:22";
+
+    @Mock
+    private CachedBluetoothDevice mCachedMemberDevice;
+    @Mock
+    private BluetoothDevice mDevice;
+    @Mock
+    private BluetoothDevice mMemberDevice;
+
+    private BluetoothDetailsAmbientVolumePreferenceController mController;
+
+    @Before
+    public void setUp() {
+        super.setUp();
+
+        PreferenceCategory deviceControls = new PreferenceCategory(mContext);
+        deviceControls.setKey(KEY_HEARING_DEVICE_GROUP);
+        mScreen.addPreference(deviceControls);
+        mController = new BluetoothDetailsAmbientVolumePreferenceController(mContext, mFragment,
+                mCachedDevice, mLifecycle);
+    }
+
+    @Test
+    public void init_deviceWithoutMember_controlNotExpandable() {
+        prepareDevice(/* hasMember= */ false);
+
+        mController.init(mScreen);
+
+        AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME);
+        assertThat(preference).isNotNull();
+        assertThat(preference.isExpandable()).isFalse();
+    }
+
+    @Test
+    public void init_deviceWithMember_controlExpandable() {
+        prepareDevice(/* hasMember= */ true);
+
+        mController.init(mScreen);
+
+        AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME);
+        assertThat(preference).isNotNull();
+        assertThat(preference.isExpandable()).isTrue();
+    }
+
+    @Test
+    public void onDeviceAttributesChanged_newDevice_newPreference() {
+        prepareDevice(/* hasMember= */ false);
+
+        mController.init(mScreen);
+
+        // check the right control is null before onDeviceAttributesChanged()
+        SeekBarPreference leftControl = mScreen.findPreference(LEFT_CONTROL_KEY);
+        SeekBarPreference rightControl = mScreen.findPreference(RIGHT_CONTROL_KEY);
+        assertThat(leftControl).isNotNull();
+        assertThat(rightControl).isNull();
+
+        prepareDevice(/* hasMember= */ true);
+        mController.onDeviceAttributesChanged();
+        shadowOf(Looper.getMainLooper()).idle();
+
+        // check the right control is created after onDeviceAttributesChanged()
+        SeekBarPreference updatedLeftControl = mScreen.findPreference(LEFT_CONTROL_KEY);
+        SeekBarPreference updatedRightControl = mScreen.findPreference(RIGHT_CONTROL_KEY);
+        assertThat(updatedLeftControl).isEqualTo(leftControl);
+        assertThat(updatedRightControl).isNotNull();
+    }
+
+    private void prepareDevice(boolean hasMember) {
+        when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT);
+        when(mCachedDevice.getDevice()).thenReturn(mDevice);
+        when(mDevice.getAddress()).thenReturn(TEST_ADDRESS);
+        if (hasMember) {
+            when(mCachedDevice.getMemberDevice()).thenReturn(Set.of(mCachedMemberDevice));
+            when(mCachedMemberDevice.getDeviceSide()).thenReturn(SIDE_RIGHT);
+            when(mCachedMemberDevice.getDevice()).thenReturn(mMemberDevice);
+            when(mMemberDevice.getAddress()).thenReturn(TEST_MEMBER_ADDRESS);
+        }
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java
index 2a50f89..4e3c742 100644
--- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java
@@ -53,12 +53,12 @@
     @Mock
     private LocalBluetoothProfileManager mProfileManager;
     @Mock
-    private BluetoothDetailsHearingDeviceController mHearingDeviceController;
-    @Mock
     private BluetoothDetailsHearingAidsPresetsController mPresetsController;
     @Mock
     private BluetoothDetailsHearingDeviceSettingsController mHearingDeviceSettingsController;
 
+    private BluetoothDetailsHearingDeviceController mHearingDeviceController;
+
     @Override
     public void setUp() {
         super.setUp();
@@ -126,4 +126,24 @@
         assertThat(mHearingDeviceController.getSubControllers().stream().anyMatch(
                 c -> c instanceof BluetoothDetailsHearingAidsPresetsController)).isFalse();
     }
+
+    @Test
+    @RequiresFlagsEnabled(
+            com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL)
+    public void initSubControllers_flagEnabled_ambientVolumeControllerExist() {
+        mHearingDeviceController.initSubControllers(false);
+
+        assertThat(mHearingDeviceController.getSubControllers().stream().anyMatch(
+                c -> c instanceof BluetoothDetailsAmbientVolumePreferenceController)).isTrue();
+    }
+
+    @Test
+    @RequiresFlagsDisabled(
+            com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL)
+    public void initSubControllers_flagDisabled_ambientVolumeControllerNotExist() {
+        mHearingDeviceController.initSubControllers(false);
+
+        assertThat(mHearingDeviceController.getSubControllers().stream().anyMatch(
+                c -> c instanceof BluetoothDetailsAmbientVolumePreferenceController)).isFalse();
+    }
 }