diff --git a/res/layout/preference_ambient_volume.xml b/res/layout/preference_ambient_volume.xml
new file mode 100644
index 00000000000..a8595c64dd4
--- /dev/null
+++ b/res/layout/preference_ambient_volume.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 89f6d8fb44d..5a408d2450f 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -164,6 +164,22 @@
There are no presets programmed by your audiologist
Couldn\u2019t update preset
+
+ Surroundings
+
+ Expand to left and right separated controls
+
+ Collapse to unified control
+
+ Left
+
+ Right
+
+ Mute surroundings
+
+ Unmute surroundings
+
+ Couldn\u2019t update surroundings
Audio output
diff --git a/src/com/android/settings/bluetooth/AmbientVolumePreference.java b/src/com/android/settings/bluetooth/AmbientVolumePreference.java
new file mode 100644
index 00000000000..e916c046df6
--- /dev/null
+++ b/src/com/android/settings/bluetooth/AmbientVolumePreference.java
@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.bluetooth;
+
+import static android.view.View.GONE;
+import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO;
+import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES;
+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 android.widget.ImageView;
+
+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 com.google.common.primitives.Ints;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A preference group of ambient volume controls.
+ *
+ *
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();
+
+ /** Called when the ambient volume icon is clicked. */
+ void onAmbientVolumeIconClick();
+ };
+
+ static final float ROTATION_COLLAPSED = 0f;
+ static final float ROTATION_EXPANDED = 180f;
+ static final int AMBIENT_VOLUME_LEVEL_MIN = 0;
+ static final int AMBIENT_VOLUME_LEVEL_MAX = 24;
+ static final int AMBIENT_VOLUME_LEVEL_DEFAULT = 24;
+ static final int SIDE_UNIFIED = 999;
+ static final List VALID_SIDES = List.of(SIDE_UNIFIED, SIDE_LEFT, SIDE_RIGHT);
+
+ @Nullable
+ private OnIconClickListener mListener;
+ @Nullable
+ private View mExpandIcon;
+ @Nullable
+ private ImageView mVolumeIcon;
+ private boolean mExpandable = true;
+ private boolean mExpanded = false;
+ private boolean mMutable = false;
+ private boolean mMuted = false;
+ private Map mSideToSliderMap = new ArrayMap<>();
+
+ /**
+ * Ambient volume level for hearing device ambient control icon
+ *
+ * This icon visually represents the current ambient gain setting.
+ * It displays separate levels for the left and right sides, each with 5 levels ranging from 0
+ * to 4.
+ *
+ * To represent the combined left/right levels with a single value, the following calculation
+ * is used:
+ * finalLevel = (leftLevel * 5) + rightLevel
+ * For example:
+ *
+ * - If left level is 2 and right level is 3, the final level will be 13 (2 * 5 + 3)
+ * - If both left and right levels are 0, the final level will be 0
+ * - If both left and right levels are 4, the final level will be 24
+ *
+ */
+ private int mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT;
+
+ 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);
+
+ mVolumeIcon = holder.itemView.requireViewById(com.android.internal.R.id.icon);
+ mVolumeIcon.getDrawable().mutate().setTint(getContext().getColor(
+ com.android.internal.R.color.materialColorOnPrimaryContainer));
+ final View iconView = holder.itemView.requireViewById(R.id.icon_frame);
+ iconView.setOnClickListener(v -> {
+ if (!mMutable) {
+ return;
+ }
+ setMuted(!mMuted);
+ if (mListener != null) {
+ mListener.onAmbientVolumeIconClick();
+ }
+ });
+ updateVolumeIcon();
+
+ 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 setMutable(boolean mutable) {
+ mMutable = mutable;
+ if (!mMutable) {
+ mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT;
+ setMuted(false);
+ }
+ updateVolumeIcon();
+ }
+
+ boolean isMutable() {
+ return mMutable;
+ }
+
+ void setMuted(boolean muted) {
+ if (!mMutable && muted) {
+ return;
+ }
+ mMuted = muted;
+ if (mMutable && mMuted) {
+ for (SeekBarPreference slider : mSideToSliderMap.values()) {
+ slider.setProgress(slider.getMin());
+ }
+ }
+ updateVolumeIcon();
+ }
+
+ boolean isMuted() {
+ return mMuted;
+ }
+
+ void setOnIconClickListener(@Nullable OnIconClickListener listener) {
+ mListener = listener;
+ }
+
+ void setSliders(Map 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);
+ updateVolumeLevel();
+ }
+ }
+
+ 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());
+ }
+ });
+ updateVolumeLevel();
+ }
+
+ private void updateVolumeLevel() {
+ int leftLevel, rightLevel;
+ if (mExpanded) {
+ leftLevel = getVolumeLevel(SIDE_LEFT);
+ rightLevel = getVolumeLevel(SIDE_RIGHT);
+ } else {
+ final int unifiedLevel = getVolumeLevel(SIDE_UNIFIED);
+ leftLevel = unifiedLevel;
+ rightLevel = unifiedLevel;
+ }
+ mVolumeLevel = Ints.constrainToRange(leftLevel * 5 + rightLevel,
+ AMBIENT_VOLUME_LEVEL_MIN, AMBIENT_VOLUME_LEVEL_MAX);
+ updateVolumeIcon();
+ }
+
+ private int getVolumeLevel(int side) {
+ SeekBarPreference slider = mSideToSliderMap.get(side);
+ if (slider == null || !slider.isEnabled()) {
+ return 0;
+ }
+ final double min = slider.getMin();
+ final double max = slider.getMax();
+ final double levelGap = (max - min) / 4.0;
+ final int value = slider.getProgress();
+ return (int) Math.ceil((value - min) / levelGap);
+ }
+
+ 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);
+ }
+ }
+
+ private void updateVolumeIcon() {
+ if (mVolumeIcon == null) {
+ return;
+ }
+ mVolumeIcon.setImageLevel(mMuted ? 0 : mVolumeLevel);
+ if (mMutable) {
+ final int stringRes = mMuted
+ ? R.string.bluetooth_ambient_volume_unmute
+ : R.string.bluetooth_ambient_volume_mute;
+ mVolumeIcon.setContentDescription(getContext().getString(stringRes));
+ mVolumeIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ } else {
+ mVolumeIcon.setContentDescription(null);
+ mVolumeIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ }
+ }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
new file mode 100644
index 00000000000..f237ffe50c3
--- /dev/null
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
@@ -0,0 +1,614 @@
+/*
+ * 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.bluetooth.AudioInputControl.MUTE_NOT_MUTED;
+import static android.bluetooth.AudioInputControl.MUTE_MUTED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+
+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 static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+import static com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data.INVALID_VOLUME;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.util.ArraySet;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.widget.SeekBarPreference;
+import com.android.settingslib.bluetooth.AmbientVolumeController;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager;
+import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.core.lifecycle.events.OnStart;
+import com.android.settingslib.core.lifecycle.events.OnStop;
+import com.android.settingslib.utils.ThreadUtils;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+
+import java.util.Map;
+import java.util.Set;
+
+/** A {@link BluetoothDetailsController} that manages ambient volume control preferences. */
+public class BluetoothDetailsAmbientVolumePreferenceController extends
+ BluetoothDetailsController implements Preference.OnPreferenceChangeListener,
+ HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, OnStart, OnStop,
+ AmbientVolumeController.AmbientVolumeControlCallback, BluetoothCallback {
+
+ 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 LocalBluetoothManager mBluetoothManager;
+ private final Set mCachedDevices = new ArraySet<>();
+ private final BiMap mSideToDeviceMap = HashBiMap.create();
+ private final BiMap mSideToSliderMap = HashBiMap.create();
+ private final HearingDeviceLocalDataManager mLocalDataManager;
+ private final AmbientVolumeController mVolumeController;
+
+ @Nullable
+ private PreferenceCategory mDeviceControls;
+ @Nullable
+ private AmbientVolumePreference mPreference;
+ @Nullable
+ private Toast mToast;
+
+ public BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context,
+ @NonNull LocalBluetoothManager manager,
+ @NonNull PreferenceFragmentCompat fragment,
+ @NonNull CachedBluetoothDevice device,
+ @NonNull Lifecycle lifecycle) {
+ super(context, fragment, device, lifecycle);
+ mBluetoothManager = manager;
+ mLocalDataManager = new HearingDeviceLocalDataManager(context);
+ mLocalDataManager.setOnDeviceLocalDataChangeListener(this,
+ ThreadUtils.getBackgroundExecutor());
+ mVolumeController = new AmbientVolumeController(manager.getProfileManager(), this);
+ }
+
+ @VisibleForTesting
+ BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context,
+ @NonNull LocalBluetoothManager manager,
+ @NonNull PreferenceFragmentCompat fragment,
+ @NonNull CachedBluetoothDevice device,
+ @NonNull Lifecycle lifecycle,
+ @NonNull HearingDeviceLocalDataManager localSettings,
+ @NonNull AmbientVolumeController volumeController) {
+ super(context, fragment, device, lifecycle);
+ mBluetoothManager = manager;
+ mLocalDataManager = localSettings;
+ mVolumeController = volumeController;
+ }
+
+ @Override
+ protected void init(PreferenceScreen screen) {
+ mDeviceControls = screen.findPreference(KEY_HEARING_DEVICE_GROUP);
+ if (mDeviceControls == null) {
+ return;
+ }
+ loadDevices();
+ }
+
+ @Override
+ public void onStart() {
+ ThreadUtils.postOnBackgroundThread(() -> {
+ mBluetoothManager.getEventManager().registerCallback(this);
+ mLocalDataManager.start();
+ mCachedDevices.forEach(device -> {
+ device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+ mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
+ device.getDevice());
+ });
+ });
+ }
+
+ @Override
+ public void onResume() {
+ refresh();
+ }
+
+ @Override
+ public void onPause() {
+ }
+
+ @Override
+ public void onStop() {
+ ThreadUtils.postOnBackgroundThread(() -> {
+ mBluetoothManager.getEventManager().unregisterCallback(this);
+ mLocalDataManager.stop();
+ mCachedDevices.forEach(device -> {
+ device.unregisterCallback(this);
+ mVolumeController.unregisterCallback(device.getDevice());
+ });
+ });
+ }
+
+ @Override
+ protected void refresh() {
+ if (!isAvailable()) {
+ return;
+ }
+ boolean shouldShowAmbientControl = isAmbientControlAvailable();
+ if (shouldShowAmbientControl) {
+ if (mPreference != null) {
+ mPreference.setVisible(true);
+ }
+ loadRemoteDataToUi();
+ } else {
+ if (mPreference != null) {
+ mPreference.setVisible(false);
+ }
+ }
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return mCachedDevice.getProfiles().stream().anyMatch(
+ profile -> profile instanceof VolumeControlProfile);
+ }
+
+ @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);
+ }
+ setVolumeIfValid(side, value);
+
+ Runnable setAmbientRunnable = () -> {
+ if (side == SIDE_UNIFIED) {
+ mSideToDeviceMap.forEach((s, d) -> mVolumeController.setAmbient(d, value));
+ } else {
+ final BluetoothDevice device = mSideToDeviceMap.get(side);
+ mVolumeController.setAmbient(device, value);
+ }
+ };
+
+ if (isControlMuted()) {
+ // User drag on the volume slider when muted. Unmute the devices first.
+ if (mPreference != null) {
+ mPreference.setMuted(false);
+ }
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ mVolumeController.setMuted(device, false);
+ }
+ // Restore the value before muted
+ loadLocalDataToUi();
+ // Delay set ambient on remote device since the immediately sequential command
+ // might get failed sometimes
+ mContext.getMainThreadHandler().postDelayed(setAmbientRunnable, 1000L);
+ } else {
+ setAmbientRunnable.run();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onProfileConnectionStateChanged(@NonNull CachedBluetoothDevice cachedDevice,
+ int state, int bluetoothProfile) {
+ if (bluetoothProfile == BluetoothProfile.VOLUME_CONTROL
+ && state == BluetoothProfile.STATE_CONNECTED
+ && mCachedDevices.contains(cachedDevice)) {
+ // After VCP connected, AICS may not ready yet and still return invalid value, delay
+ // a while to wait AICS ready as a workaround
+ mContext.getMainThreadHandler().postDelayed(this::refresh, 1000L);
+ }
+ }
+
+ @Override
+ public void onDeviceAttributesChanged() {
+ mCachedDevices.forEach(device -> {
+ device.unregisterCallback(this);
+ mVolumeController.unregisterCallback(device.getDevice());
+ });
+ mContext.getMainExecutor().execute(() -> {
+ loadDevices();
+ if (!mCachedDevices.isEmpty()) {
+ refresh();
+ }
+ ThreadUtils.postOnBackgroundThread(() ->
+ mCachedDevices.forEach(device -> {
+ device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+ mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
+ device.getDevice());
+ })
+ );
+ });
+ }
+
+ @Override
+ public void onDeviceLocalDataChange(@NonNull String address, @Nullable Data data) {
+ if (data == null) {
+ // The local data is removed because the device is unpaired, do nothing
+ return;
+ }
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ if (device.getAnonymizedAddress().equals(address)) {
+ mContext.getMainExecutor().execute(() -> loadLocalDataToUi(device));
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onVolumeControlServiceConnected() {
+ mCachedDevices.forEach(
+ device -> mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
+ device.getDevice()));
+ }
+
+ @Override
+ public void onAmbientChanged(@NonNull BluetoothDevice device, int gainSettings) {
+ if (DEBUG) {
+ Log.d(TAG, "onAmbientChanged, value:" + gainSettings + ", device:" + device);
+ }
+ Data data = mLocalDataManager.get(device);
+ boolean isInitiatedFromUi = (isControlExpanded() && data.ambient() == gainSettings)
+ || (!isControlExpanded() && data.groupAmbient() == gainSettings);
+ if (isInitiatedFromUi) {
+ // The change is initiated from UI, no need to update UI
+ return;
+ }
+
+ // We have to check if we need to expand the controls by getting all remote
+ // device's ambient value, delay for a while to wait all remote devices update
+ // to the latest value to avoid unnecessary expand action.
+ mContext.getMainThreadHandler().postDelayed(this::refresh, 1200L);
+ }
+
+ @Override
+ public void onMuteChanged(@NonNull BluetoothDevice device, int mute) {
+ if (DEBUG) {
+ Log.d(TAG, "onMuteChanged, mute:" + mute + ", device:" + device);
+ }
+ boolean isInitiatedFromUi = (isControlMuted() && mute == MUTE_MUTED)
+ || (!isControlMuted() && mute == MUTE_NOT_MUTED);
+ if (isInitiatedFromUi) {
+ // The change is initiated from UI, no need to update UI
+ return;
+ }
+
+ // We have to check if we need to mute the devices by getting all remote
+ // device's mute state, delay for a while to wait all remote devices update
+ // to the latest value.
+ mContext.getMainThreadHandler().postDelayed(this::refresh, 1200L);
+ }
+
+ @Override
+ public void onCommandFailed(@NonNull BluetoothDevice device) {
+ Log.w(TAG, "onCommandFailed, device:" + device);
+ mContext.getMainExecutor().execute(() -> {
+ showErrorToast();
+ refresh();
+ });
+ }
+
+ private void loadDevices() {
+ mSideToDeviceMap.clear();
+ mCachedDevices.clear();
+ if (VALID_SIDES.contains(mCachedDevice.getDeviceSide())
+ && mCachedDevice.getBondState() == BOND_BONDED) {
+ mSideToDeviceMap.put(mCachedDevice.getDeviceSide(), mCachedDevice.getDevice());
+ mCachedDevices.add(mCachedDevice);
+ }
+ for (CachedBluetoothDevice memberDevice : mCachedDevice.getMemberDevice()) {
+ if (VALID_SIDES.contains(memberDevice.getDeviceSide())
+ && memberDevice.getBondState() == BOND_BONDED) {
+ 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);
+ mPreference.setOnIconClickListener(
+ new AmbientVolumePreference.OnIconClickListener() {
+ @Override
+ public void onExpandIconClick() {
+ mSideToDeviceMap.forEach((s, d) -> {
+ if (!isControlMuted()) {
+ // Apply previous collapsed/expanded volume to remote device
+ Data data = mLocalDataManager.get(d);
+ int volume = isControlExpanded()
+ ? data.ambient() : data.groupAmbient();
+ mVolumeController.setAmbient(d, volume);
+ }
+ // Update new value to local data
+ mLocalDataManager.updateAmbientControlExpanded(d, isControlExpanded());
+ });
+ }
+
+ @Override
+ public void onAmbientVolumeIconClick() {
+ if (!isControlMuted()) {
+ loadLocalDataToUi();
+ }
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ mVolumeController.setMuted(device, isControlMuted());
+ }
+ }
+ });
+ 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);
+ if (side == SIDE_LEFT) {
+ preference.setTitle(mContext.getString(R.string.bluetooth_ambient_volume_control_left));
+ } else if (side == SIDE_RIGHT) {
+ preference.setTitle(
+ mContext.getString(R.string.bluetooth_ambient_volume_control_right));
+ }
+ mSideToSliderMap.put(side, preference);
+ }
+
+ /** Refreshes the control UI visibility and enabled state. */
+ private void refreshControlUi() {
+ if (mPreference != null) {
+ boolean isAnySliderEnabled = false;
+ for (Map.Entry entry : mSideToDeviceMap.entrySet()) {
+ final int side = entry.getKey();
+ final BluetoothDevice device = entry.getValue();
+ final boolean enabled = isDeviceConnectedToVcp(device)
+ && mVolumeController.isAmbientControlAvailable(device);
+ isAnySliderEnabled |= enabled;
+ mPreference.setSliderEnabled(side, enabled);
+ }
+ mPreference.setSliderEnabled(SIDE_UNIFIED, isAnySliderEnabled);
+ mPreference.updateLayout();
+ }
+ }
+
+ /** Sets the volume to the corresponding control slider. */
+ private void setVolumeIfValid(int side, int volume) {
+ if (volume == INVALID_VOLUME) {
+ return;
+ }
+ if (mPreference != null) {
+ mPreference.setSliderValue(side, volume);
+ }
+ // Update new value to local data
+ if (side == SIDE_UNIFIED) {
+ mSideToDeviceMap.forEach((s, d) -> mLocalDataManager.updateGroupAmbient(d, volume));
+ } else {
+ mLocalDataManager.updateAmbient(mSideToDeviceMap.get(side), volume);
+ }
+ }
+
+ private void loadLocalDataToUi() {
+ mSideToDeviceMap.forEach((s, d) -> loadLocalDataToUi(d));
+ }
+
+ private void loadLocalDataToUi(BluetoothDevice device) {
+ final Data data = mLocalDataManager.get(device);
+ if (DEBUG) {
+ Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device);
+ }
+ final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID);
+ if (isDeviceConnectedToVcp(device) && !isControlMuted()) {
+ setVolumeIfValid(side, data.ambient());
+ setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient());
+ }
+ setControlExpanded(data.ambientControlExpanded());
+ refreshControlUi();
+ }
+
+ private void loadRemoteDataToUi() {
+ BluetoothDevice leftDevice = mSideToDeviceMap.get(SIDE_LEFT);
+ AmbientVolumeController.RemoteAmbientState leftState =
+ mVolumeController.refreshAmbientState(leftDevice);
+ BluetoothDevice rightDevice = mSideToDeviceMap.get(SIDE_RIGHT);
+ AmbientVolumeController.RemoteAmbientState rightState =
+ mVolumeController.refreshAmbientState(rightDevice);
+ if (DEBUG) {
+ Log.d(TAG, "loadRemoteDataToUi, left=" + leftState + ", right=" + rightState);
+ }
+
+ if (mPreference != null) {
+ mSideToDeviceMap.forEach((side, device) -> {
+ int ambientMax = mVolumeController.getAmbientMax(device);
+ int ambientMin = mVolumeController.getAmbientMin(device);
+ if (ambientMin != ambientMax) {
+ mPreference.setSliderRange(side, ambientMin, ambientMax);
+ mPreference.setSliderRange(SIDE_UNIFIED, ambientMin, ambientMax);
+ }
+ });
+ }
+
+ // Update ambient volume
+ final int leftAmbient = leftState != null ? leftState.gainSetting() : INVALID_VOLUME;
+ final int rightAmbient = rightState != null ? rightState.gainSetting() : INVALID_VOLUME;
+ if (isControlExpanded()) {
+ setVolumeIfValid(SIDE_LEFT, leftAmbient);
+ setVolumeIfValid(SIDE_RIGHT, rightAmbient);
+ } else {
+ if (leftAmbient != rightAmbient && leftAmbient != INVALID_VOLUME
+ && rightAmbient != INVALID_VOLUME) {
+ setVolumeIfValid(SIDE_LEFT, leftAmbient);
+ setVolumeIfValid(SIDE_RIGHT, rightAmbient);
+ setControlExpanded(true);
+ } else {
+ int unifiedAmbient = leftAmbient != INVALID_VOLUME ? leftAmbient : rightAmbient;
+ setVolumeIfValid(SIDE_UNIFIED, unifiedAmbient);
+ }
+ }
+ // Initialize local data between side and group value
+ initLocalDataIfNeeded();
+
+ // Update mute state
+ boolean mutable = true;
+ boolean muted = true;
+ if (isDeviceConnectedToVcp(leftDevice) && leftState != null) {
+ mutable &= leftState.isMutable();
+ muted &= leftState.isMuted();
+ }
+ if (isDeviceConnectedToVcp(rightDevice) && rightState != null) {
+ mutable &= rightState.isMutable();
+ muted &= rightState.isMuted();
+ }
+ if (mPreference != null) {
+ mPreference.setMutable(mutable);
+ mPreference.setMuted(muted);
+ }
+
+ // Ensure remote device mute state is synced
+ syncMuteStateIfNeeded(leftDevice, leftState, muted);
+ syncMuteStateIfNeeded(rightDevice, rightState, muted);
+
+ refreshControlUi();
+ }
+
+ /** Check if any device in the group has valid ambient control points */
+ private boolean isAmbientControlAvailable() {
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ // Found ambient local data for this device, show the ambient control
+ if (mLocalDataManager.get(device).hasAmbientData()) {
+ return true;
+ }
+ // Found remote ambient control points on this device, show the ambient control
+ if (mVolumeController.isAmbientControlAvailable(device)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isControlExpanded() {
+ return mPreference != null && mPreference.isExpanded();
+ }
+
+ private void setControlExpanded(boolean expanded) {
+ if (mPreference != null && mPreference.isExpanded() != expanded) {
+ mPreference.setExpanded(expanded);
+ }
+ mSideToDeviceMap.forEach((s, d) -> {
+ // Update new value to local data
+ mLocalDataManager.updateAmbientControlExpanded(d, expanded);
+ });
+ }
+
+ private boolean isControlMuted() {
+ return mPreference != null && mPreference.isMuted();
+ }
+
+ private void initLocalDataIfNeeded() {
+ int smallerVolumeAmongGroup = Integer.MAX_VALUE;
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ Data data = mLocalDataManager.get(device);
+ if (data.ambient() != INVALID_VOLUME) {
+ smallerVolumeAmongGroup = Math.min(data.ambient(), smallerVolumeAmongGroup);
+ } else if (data.groupAmbient() != INVALID_VOLUME) {
+ // Initialize side ambient from group ambient value
+ mLocalDataManager.updateAmbient(device, data.groupAmbient());
+ }
+ }
+ if (smallerVolumeAmongGroup != Integer.MAX_VALUE) {
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ Data data = mLocalDataManager.get(device);
+ if (data.groupAmbient() == INVALID_VOLUME) {
+ // Initialize group ambient from smaller side ambient value
+ mLocalDataManager.updateGroupAmbient(device, smallerVolumeAmongGroup);
+ }
+ }
+ }
+ }
+
+ private void syncMuteStateIfNeeded(@Nullable BluetoothDevice device,
+ @Nullable AmbientVolumeController.RemoteAmbientState state, boolean muted) {
+ if (isDeviceConnectedToVcp(device) && state != null && state.isMutable()) {
+ if (state.isMuted() != muted) {
+ mVolumeController.setMuted(device, muted);
+ }
+ }
+ }
+
+ private boolean isDeviceConnectedToVcp(@Nullable BluetoothDevice device) {
+ return device != null && device.isConnected()
+ && mBluetoothManager.getProfileManager().getVolumeControlProfile()
+ .getConnectionStatus(device) == BluetoothProfile.STATE_CONNECTED;
+ }
+
+ private void showErrorToast() {
+ if (mToast != null) {
+ mToast.cancel();
+ }
+ mToast = Toast.makeText(mContext, R.string.bluetooth_ambient_volume_error,
+ Toast.LENGTH_SHORT);
+ mToast.show();
+ }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
index 3703b7180af..8af08792180 100644
--- a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
@@ -42,6 +42,7 @@ public class BluetoothDetailsHearingDeviceController extends BluetoothDetailsCon
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 mControllers = new ArrayList<>();
@@ -107,6 +108,10 @@ public class BluetoothDetailsHearingDeviceController extends BluetoothDetailsCon
mControllers.add(new BluetoothDetailsHearingAidsPresetsController(mContext, mFragment,
mManager, mCachedDevice, mLifecycle));
}
+ if (com.android.settingslib.flags.Flags.hearingDevicesAmbientVolumeControl()) {
+ mControllers.add(new BluetoothDetailsAmbientVolumePreferenceController(mContext,
+ mManager, 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 00000000000..ec406c45503
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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 int TEST_LEFT_VOLUME_LEVEL = 1;
+ private static final int TEST_RIGHT_VOLUME_LEVEL = 2;
+ private static final int TEST_UNIFIED_VOLUME_LEVEL = 3;
+ 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 ImageView mVolumeIcon;
+ private final Map 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);
+ mPreference.setMutable(true);
+ preferenceScreen.addPreference(mPreference);
+
+ prepareSliders();
+ mPreference.setSliders(mSideToSlidersMap);
+
+ mExpandIcon = new ImageView(mContext);
+ mVolumeIcon = new ImageView(mContext);
+ mVolumeIcon.setImageResource(com.android.settingslib.R.drawable.ic_ambient_volume);
+ mVolumeIcon.setImageLevel(0);
+ when(mItemView.requireViewById(R.id.expand_icon)).thenReturn(mExpandIcon);
+ when(mItemView.requireViewById(com.android.internal.R.id.icon)).thenReturn(mVolumeIcon);
+ when(mItemView.requireViewById(R.id.icon_frame)).thenReturn(mVolumeIcon);
+
+ 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();
+ }
+
+ @Test
+ public void setMutable_mutable_clickOnMuteIconChangeMuteState() {
+ mPreference.setMutable(true);
+ mPreference.setMuted(false);
+
+ mVolumeIcon.callOnClick();
+
+ assertThat(mPreference.isMuted()).isTrue();
+ }
+
+ @Test
+ public void setMutable_notMutable_clickOnMuteIconWontChangeMuteState() {
+ mPreference.setMutable(false);
+ mPreference.setMuted(false);
+
+ mVolumeIcon.callOnClick();
+
+ assertThat(mPreference.isMuted()).isFalse();
+ }
+
+ @Test
+ public void updateLayout_mute_volumeIconIsCorrect() {
+ mPreference.setMuted(true);
+ mPreference.updateLayout();
+
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(0);
+ }
+
+ @Test
+ public void updateLayout_unmuteAndExpanded_volumeIconIsCorrect() {
+ mPreference.setMuted(false);
+ mPreference.setExpanded(true);
+ mPreference.updateLayout();
+
+ int expectedLevel = calculateVolumeLevel(TEST_LEFT_VOLUME_LEVEL, TEST_RIGHT_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ @Test
+ public void updateLayout_unmuteAndNotExpanded_volumeIconIsCorrect() {
+ mPreference.setMuted(false);
+ mPreference.setExpanded(false);
+ mPreference.updateLayout();
+
+ int expectedLevel = calculateVolumeLevel(TEST_UNIFIED_VOLUME_LEVEL,
+ TEST_UNIFIED_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ @Test
+ public void setSliderEnabled_expandedAndLeftIsDisabled_volumeIconIcCorrect() {
+ mPreference.setExpanded(true);
+ mPreference.setSliderEnabled(SIDE_LEFT, false);
+
+ int expectedLevel = calculateVolumeLevel(0, TEST_RIGHT_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ @Test
+ public void setSliderValue_expandedAndLeftValueChanged_volumeIconIcCorrect() {
+ mPreference.setExpanded(true);
+ mPreference.setSliderValue(SIDE_LEFT, 4);
+
+ int expectedLevel = calculateVolumeLevel(4, TEST_RIGHT_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ private int calculateVolumeLevel(int left, int right) {
+ return left * 5 + right;
+ }
+
+ 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);
+ slider.setMin(0);
+ slider.setMax(4);
+ if (side == SIDE_LEFT) {
+ slider.setKey(KEY_LEFT_SLIDER);
+ slider.setProgress(TEST_LEFT_VOLUME_LEVEL);
+ } else if (side == SIDE_RIGHT) {
+ slider.setKey(KEY_RIGHT_SLIDER);
+ slider.setProgress(TEST_RIGHT_VOLUME_LEVEL);
+ } else {
+ slider.setKey(KEY_UNIFIED_SLIDER);
+ slider.setProgress(TEST_UNIFIED_VOLUME_LEVEL);
+ }
+ 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 00000000000..975d3b491aa
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
@@ -0,0 +1,434 @@
+/*
+ * 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.bluetooth.AudioInputControl.MUTE_DISABLED;
+import static android.bluetooth.AudioInputControl.MUTE_NOT_MUTED;
+import static android.bluetooth.AudioInputControl.MUTE_MUTED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+
+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.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.ContentResolver;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+
+import androidx.preference.PreferenceCategory;
+
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settings.widget.SeekBarPreference;
+import com.android.settingslib.bluetooth.AmbientVolumeController;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+
+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.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+
+/** Tests for {@link BluetoothDetailsAmbientVolumePreferenceController}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {
+ BluetoothDetailsAmbientVolumePreferenceControllerTest.ShadowGlobal.class,
+ 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;
+ @Mock
+ private HearingDeviceLocalDataManager mLocalDataManager;
+ @Mock
+ private LocalBluetoothManager mBluetoothManager;
+ @Mock
+ private BluetoothEventManager mEventManager;
+ @Mock
+ private LocalBluetoothProfileManager mProfileManager;
+ @Mock
+ private VolumeControlProfile mVolumeControlProfile;
+ @Mock
+ private AmbientVolumeController mVolumeController;
+ @Mock
+ private Handler mTestHandler;
+
+ private BluetoothDetailsAmbientVolumePreferenceController mController;
+
+ @Before
+ public void setUp() {
+ super.setUp();
+
+ mContext = spy(mContext);
+ PreferenceCategory deviceControls = new PreferenceCategory(mContext);
+ deviceControls.setKey(KEY_HEARING_DEVICE_GROUP);
+ mScreen.addPreference(deviceControls);
+ mController = spy(
+ new BluetoothDetailsAmbientVolumePreferenceController(mContext, mBluetoothManager,
+ mFragment, mCachedDevice, mLifecycle, mLocalDataManager,
+ mVolumeController));
+
+ when(mBluetoothManager.getEventManager()).thenReturn(mEventManager);
+ when(mBluetoothManager.getProfileManager()).thenReturn(mProfileManager);
+ when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile);
+ when(mVolumeControlProfile.getConnectionStatus(mDevice)).thenReturn(
+ BluetoothProfile.STATE_CONNECTED);
+ when(mVolumeControlProfile.getConnectionStatus(mMemberDevice)).thenReturn(
+ BluetoothProfile.STATE_CONNECTED);
+ when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
+ when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(
+ new HearingDeviceLocalDataManager.Data.Builder().build());
+
+ when(mContext.getMainThreadHandler()).thenReturn(mTestHandler);
+ when(mTestHandler.postDelayed(any(Runnable.class), anyLong())).thenAnswer(
+ invocationOnMock -> {
+ invocationOnMock.getArgument(0, Runnable.class).run();
+ return null;
+ });
+ }
+
+ @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 onDeviceLocalDataChange_noMemberAndExpanded_uiCorrectAndDataUpdated() {
+ prepareDevice(/* hasMember= */ false);
+ mController.init(mScreen);
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(0).groupAmbient(0).ambientControlExpanded(true).build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+
+ mController.onDeviceLocalDataChange(TEST_ADDRESS, data);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME);
+ assertThat(preference).isNotNull();
+ assertThat(preference.isExpanded()).isFalse();
+ verifyDeviceDataUpdated(mDevice);
+ }
+
+ @Test
+ public void onDeviceLocalDataChange_noMemberAndCollapsed_uiCorrectAndDataUpdated() {
+ prepareDevice(/* hasMember= */ false);
+ mController.init(mScreen);
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(0).groupAmbient(0).ambientControlExpanded(false).build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+
+ mController.onDeviceLocalDataChange(TEST_ADDRESS, data);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME);
+ assertThat(preference).isNotNull();
+ assertThat(preference.isExpanded()).isFalse();
+ verifyDeviceDataUpdated(mDevice);
+ }
+
+ @Test
+ public void onDeviceLocalDataChange_hasMemberAndExpanded_uiCorrectAndDataUpdated() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(0).groupAmbient(0).ambientControlExpanded(true).build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+
+ mController.onDeviceLocalDataChange(TEST_ADDRESS, data);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME);
+ assertThat(preference).isNotNull();
+ assertThat(preference.isExpanded()).isTrue();
+ verifyDeviceDataUpdated(mDevice);
+ }
+
+ @Test
+ public void onDeviceLocalDataChange_hasMemberAndCollapsed_uiCorrectAndDataUpdated() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(0).groupAmbient(0).ambientControlExpanded(false).build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+
+ mController.onDeviceLocalDataChange(TEST_ADDRESS, data);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME);
+ assertThat(preference).isNotNull();
+ assertThat(preference.isExpanded()).isFalse();
+ verifyDeviceDataUpdated(mDevice);
+ }
+
+ @Test
+ public void onStart_localDataManagerStartAndCallbackRegistered() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+
+ mController.onStart();
+
+ verify(mLocalDataManager, atLeastOnce()).start();
+ verify(mVolumeController).registerCallback(any(Executor.class), eq(mDevice));
+ verify(mVolumeController).registerCallback(any(Executor.class), eq(mMemberDevice));
+ verify(mCachedDevice).registerCallback(any(Executor.class),
+ any(CachedBluetoothDevice.Callback.class));
+ verify(mCachedMemberDevice).registerCallback(any(Executor.class),
+ any(CachedBluetoothDevice.Callback.class));
+ }
+
+ @Test
+ public void onStop_localDataManagerStopAndCallbackUnregistered() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+
+ mController.onStop();
+
+ verify(mLocalDataManager).stop();
+ verify(mVolumeController).unregisterCallback(mDevice);
+ verify(mVolumeController).unregisterCallback(mMemberDevice);
+ verify(mCachedDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
+ verify(mCachedMemberDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
+ }
+
+ @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();
+ }
+
+ @Test
+ public void onAmbientChanged_refreshWhenNotInitiateFromUi() {
+ prepareDevice(/* hasMember= */ false);
+ mController.init(mScreen);
+ final int testAmbient = 10;
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(testAmbient)
+ .groupAmbient(testAmbient)
+ .ambientControlExpanded(false)
+ .build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+ getPreference().setExpanded(true);
+
+ mController.onAmbientChanged(mDevice, testAmbient);
+ verify(mController, never()).refresh();
+
+ final int updatedTestAmbient = 20;
+ mController.onAmbientChanged(mDevice, updatedTestAmbient);
+ verify(mController).refresh();
+ }
+
+ @Test
+ public void onMuteChanged_refreshWhenNotInitiateFromUi() {
+ prepareDevice(/* hasMember= */ false);
+ mController.init(mScreen);
+ final int testMute = MUTE_NOT_MUTED;
+ AmbientVolumeController.RemoteAmbientState state =
+ new AmbientVolumeController.RemoteAmbientState(testMute, 0);
+ when(mVolumeController.refreshAmbientState(mDevice)).thenReturn(state);
+ getPreference().setMuted(false);
+
+ mController.onMuteChanged(mDevice, testMute);
+ verify(mController, never()).refresh();
+
+ final int updatedTestMute = MUTE_MUTED;
+ mController.onMuteChanged(mDevice, updatedTestMute);
+ verify(mController).refresh();
+ }
+
+ @Test
+ public void refresh_leftAndRightDifferentGainSetting_expandControl() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+ prepareRemoteData(mDevice, 10, MUTE_NOT_MUTED);
+ prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
+ getPreference().setExpanded(false);
+
+ mController.refresh();
+
+ assertThat(getPreference().isExpanded()).isTrue();
+ }
+
+ @Test
+ public void refresh_oneSideNotMutable_controlNotMutableAndNotMuted() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+ prepareRemoteData(mDevice, 10, MUTE_DISABLED);
+ prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
+ getPreference().setMutable(true);
+ getPreference().setMuted(true);
+
+ mController.refresh();
+
+ assertThat(getPreference().isMutable()).isFalse();
+ assertThat(getPreference().isMuted()).isFalse();
+ }
+
+ @Test
+ public void refresh_oneSideNotMuted_controlNotMutedAndSyncToRemote() {
+ prepareDevice(/* hasMember= */ true);
+ mController.init(mScreen);
+ prepareRemoteData(mDevice, 10, MUTE_MUTED);
+ prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
+ getPreference().setMutable(true);
+ getPreference().setMuted(true);
+
+ mController.refresh();
+
+ assertThat(getPreference().isMutable()).isTrue();
+ assertThat(getPreference().isMuted()).isFalse();
+ verify(mVolumeController).setMuted(mDevice, false);
+ }
+
+ private void prepareDevice(boolean hasMember) {
+ when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT);
+ when(mCachedDevice.getDevice()).thenReturn(mDevice);
+ when(mCachedDevice.getBondState()).thenReturn(BOND_BONDED);
+ when(mDevice.getAddress()).thenReturn(TEST_ADDRESS);
+ when(mDevice.getAnonymizedAddress()).thenReturn(TEST_ADDRESS);
+ when(mDevice.isConnected()).thenReturn(true);
+ if (hasMember) {
+ when(mCachedDevice.getMemberDevice()).thenReturn(Set.of(mCachedMemberDevice));
+ when(mCachedMemberDevice.getDeviceSide()).thenReturn(SIDE_RIGHT);
+ when(mCachedMemberDevice.getDevice()).thenReturn(mMemberDevice);
+ when(mCachedMemberDevice.getBondState()).thenReturn(BOND_BONDED);
+ when(mMemberDevice.getAddress()).thenReturn(TEST_MEMBER_ADDRESS);
+ when(mMemberDevice.getAnonymizedAddress()).thenReturn(TEST_MEMBER_ADDRESS);
+ when(mMemberDevice.isConnected()).thenReturn(true);
+ }
+ }
+
+ private void prepareRemoteData(BluetoothDevice device, int gainSetting, int mute) {
+ when(mVolumeController.isAmbientControlAvailable(device)).thenReturn(true);
+ when(mVolumeController.refreshAmbientState(device)).thenReturn(
+ new AmbientVolumeController.RemoteAmbientState(gainSetting, mute));
+ }
+
+ private void verifyDeviceDataUpdated(BluetoothDevice device) {
+ verify(mLocalDataManager, atLeastOnce()).updateAmbient(eq(device), anyInt());
+ verify(mLocalDataManager, atLeastOnce()).updateGroupAmbient(eq(device), anyInt());
+ verify(mLocalDataManager, atLeastOnce()).updateAmbientControlExpanded(eq(device),
+ anyBoolean());
+ }
+
+ private AmbientVolumePreference getPreference() {
+ return mScreen.findPreference(KEY_AMBIENT_VOLUME);
+ }
+
+ @Implements(value = Settings.Global.class)
+ public static class ShadowGlobal extends ShadowSettings.ShadowGlobal {
+ private static final Map> 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 get(ContentResolver cr) {
+ return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
+ }
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java
index 2a50f892add..4e3c742e284 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 @@ public class BluetoothDetailsHearingDeviceControllerTest extends
@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 @@ public class BluetoothDetailsHearingDeviceControllerTest extends
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();
+ }
}