Merge changes from topics "ha-aics", "ha-aics-mute", "ha-local-data" into main
* changes:
[Ambient Volume] Ambient volume icon
[Ambient Volume] Show value with remote data
[Ambient Volume] Show value with local data
[Ambient Volume] UI of volume sliders in Settings
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 89f6d8f..5a408d2 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -164,6 +164,22 @@
<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. The text to show the control is for left side device. [CHAR LIMIT=30] -->
+ <string name="bluetooth_ambient_volume_control_left">Left</string>
+ <!-- Connected devices settings. The text to show the control is for right side device. [CHAR LIMIT=30] -->
+ <string name="bluetooth_ambient_volume_control_right">Right</string>
+ <!-- Connected devices settings. Content description for a button, that mute ambient volume [CHAR_LIMIT=NONE] -->
+ <string name="bluetooth_ambient_volume_mute">Mute surroundings</string>
+ <!-- Connected devices settings. Content description for a button, that unmute ambient volume [CHAR LIMIT=NONE] -->
+ <string name="bluetooth_ambient_volume_unmute">Unmute surroundings</string>
+ <!-- Message when changing ambient state failed. [CHAR LIMIT=NONE] -->
+ <string name="bluetooth_ambient_volume_error">Couldn\u2019t update surroundings</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..e916c04
--- /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.
+ *
+ * <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();
+
+ /** 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<Integer> 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<Integer, SeekBarPreference> mSideToSliderMap = new ArrayMap<>();
+
+ /**
+ * Ambient volume level for hearing device ambient control icon
+ * <p>
+ * 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.
+ * <p>
+ * To represent the combined left/right levels with a single value, the following calculation
+ * is used:
+ * finalLevel = (leftLevel * 5) + rightLevel
+ * For example:
+ * <ul>
+ * <li>If left level is 2 and right level is 3, the final level will be 13 (2 * 5 + 3)</li>
+ * <li>If both left and right levels are 0, the final level will be 0</li>
+ * <li>If both left and right levels are 4, the final level will be 24</li>
+ * </ul>
+ */
+ 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<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);
+ 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 0000000..f237ffe
--- /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<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
+ private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
+ private final BiMap<Integer, SeekBarPreference> 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<Integer, BluetoothDevice> 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 3703b71..8af0879 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,
+ 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 0000000..ec406c4
--- /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<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);
+ 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 0000000..975d3b4
--- /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<ContentResolver, Map<String, String>> sDataMap = new HashMap<>();
+
+ @Implementation
+ protected static boolean putStringForUser(
+ ContentResolver cr, String name, String value, int userHandle) {
+ get(cr).put(name, value);
+ return true;
+ }
+
+ @Implementation
+ protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
+ return get(cr).get(name);
+ }
+
+ private static Map<String, String> get(ContentResolver cr) {
+ return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
+ }
+ }
+}
diff --git a/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();
+ }
}