From 0595aed386395ffadb9e19d7b62b846217b3115c Mon Sep 17 00:00:00 2001 From: Angela Wang Date: Thu, 31 Oct 2024 05:22:46 +0000 Subject: [PATCH 1/4] [Ambient Volume] UI of volume sliders in Settings Collapse/expand the controls when clicking on the hearder with arrow. Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control Bug: 357878944 Test: atest AmbientVolumePreferenceTest Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest Test: atest BluetoothDetailsHearingDeviceControllerTest Change-Id: I845a4397601e563ed027d7d2a0a13651e95de708 --- res/layout/preference_ambient_volume.xml | 53 +++++ res/values/strings.xml | 6 + .../bluetooth/AmbientVolumePreference.java | 182 ++++++++++++++++ ...ailsAmbientVolumePreferenceController.java | 198 ++++++++++++++++++ ...uetoothDetailsHearingDeviceController.java | 5 + .../AmbientVolumePreferenceTest.java | 152 ++++++++++++++ ...AmbientVolumePreferenceControllerTest.java | 143 +++++++++++++ ...othDetailsHearingDeviceControllerTest.java | 24 ++- 8 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 res/layout/preference_ambient_volume.xml create mode 100644 src/com/android/settings/bluetooth/AmbientVolumePreference.java create mode 100644 src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java create mode 100644 tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java create mode 100644 tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java 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 f7afbd6d9c6..c7cdd6e7af9 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -164,6 +164,12 @@ There are no presets programmed by your audiologist Couldn\u2019t update preset + + Surroundings + + Expand to left and right separated controls + + Collapse to unified control 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..01222039a9b --- /dev/null +++ b/src/com/android/settings/bluetooth/AmbientVolumePreference.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT; + +import android.content.Context; +import android.util.ArrayMap; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceGroup; +import androidx.preference.PreferenceViewHolder; + +import com.android.settings.R; +import com.android.settings.widget.SeekBarPreference; + +import java.util.List; +import java.util.Map; + +/** + * A preference group of ambient volume controls. + * + *

It consists of a header with an expand icon and volume sliders for unified control and + * separated control for devices in the same set. Toggle the expand icon will make the UI switch + * between unified and separated control. + */ +public class AmbientVolumePreference extends PreferenceGroup { + + /** Interface definition for a callback to be invoked when the icon is clicked. */ + public interface OnIconClickListener { + /** Called when the expand icon is clicked. */ + void onExpandIconClick(); + }; + + static final float ROTATION_COLLAPSED = 0f; + static final float ROTATION_EXPANDED = 180f; + static final int SIDE_UNIFIED = 999; + static final List VALID_SIDES = List.of(SIDE_UNIFIED, SIDE_LEFT, SIDE_RIGHT); + + @Nullable + private OnIconClickListener mListener; + @Nullable + private View mExpandIcon; + private boolean mExpandable = true; + private boolean mExpanded = false; + private Map mSideToSliderMap = new ArrayMap<>(); + + public AmbientVolumePreference(@NonNull Context context) { + super(context, null); + setLayoutResource(R.layout.preference_ambient_volume); + setIcon(com.android.settingslib.R.drawable.ic_ambient_volume); + setTitle(R.string.bluetooth_ambient_volume_control); + setSelectable(false); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + holder.setDividerAllowedAbove(false); + holder.setDividerAllowedBelow(false); + + mExpandIcon = holder.itemView.requireViewById(R.id.expand_icon); + mExpandIcon.setOnClickListener(v -> { + setExpanded(!mExpanded); + if (mListener != null) { + mListener.onExpandIconClick(); + } + }); + updateExpandIcon(); + } + + void setExpandable(boolean expandable) { + mExpandable = expandable; + if (!mExpandable) { + setExpanded(false); + } + updateExpandIcon(); + } + + boolean isExpandable() { + return mExpandable; + } + + void setExpanded(boolean expanded) { + if (!mExpandable && expanded) { + return; + } + mExpanded = expanded; + updateExpandIcon(); + updateLayout(); + } + + boolean isExpanded() { + return mExpanded; + } + + void setOnIconClickListener(@Nullable OnIconClickListener listener) { + mListener = listener; + } + + void setSliders(Map sideToSliderMap) { + mSideToSliderMap = sideToSliderMap; + for (SeekBarPreference preference : sideToSliderMap.values()) { + if (findPreference(preference.getKey()) == null) { + addPreference(preference); + } + } + updateLayout(); + } + + void setSliderEnabled(int side, boolean enabled) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider != null && slider.isEnabled() != enabled) { + slider.setEnabled(enabled); + updateLayout(); + } + } + + void setSliderValue(int side, int value) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider != null && slider.getProgress() != value) { + slider.setProgress(value); + } + } + + void setSliderRange(int side, int min, int max) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider != null) { + slider.setMin(min); + slider.setMax(max); + } + } + + void updateLayout() { + mSideToSliderMap.forEach((side, slider) -> { + if (side == SIDE_UNIFIED) { + slider.setVisible(!mExpanded); + } else { + slider.setVisible(mExpanded); + } + if (!slider.isEnabled()) { + slider.setProgress(slider.getMin()); + } + }); + } + + private void updateExpandIcon() { + if (mExpandIcon == null) { + return; + } + mExpandIcon.setVisibility(mExpandable ? VISIBLE : GONE); + mExpandIcon.setRotation(mExpanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED); + if (mExpandable) { + final int stringRes = mExpanded + ? R.string.bluetooth_ambient_volume_control_collapse + : R.string.bluetooth_ambient_volume_control_expand; + mExpandIcon.setContentDescription(getContext().getString(stringRes)); + } else { + mExpandIcon.setContentDescription(null); + } + } +} diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java new file mode 100644 index 00000000000..0629e6e9a45 --- /dev/null +++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth; + +import static com.android.settings.bluetooth.AmbientVolumePreference.SIDE_UNIFIED; +import static com.android.settings.bluetooth.AmbientVolumePreference.VALID_SIDES; +import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.KEY_HEARING_DEVICE_GROUP; +import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.ORDER_AMBIENT_VOLUME; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_INVALID; + +import android.bluetooth.BluetoothDevice; +import android.content.Context; +import android.util.ArraySet; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceScreen; + +import com.android.settings.widget.SeekBarPreference; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.VolumeControlProfile; +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.utils.ThreadUtils; + +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; + +import java.util.Set; + +/** A {@link BluetoothDetailsController} that manages ambient volume control preferences. */ +public class BluetoothDetailsAmbientVolumePreferenceController extends + BluetoothDetailsController implements Preference.OnPreferenceChangeListener { + + private static final boolean DEBUG = true; + private static final String TAG = "AmbientPrefController"; + + static final String KEY_AMBIENT_VOLUME = "ambient_volume"; + static final String KEY_AMBIENT_VOLUME_SLIDER = "ambient_volume_slider"; + private static final int ORDER_AMBIENT_VOLUME_CONTROL_UNIFIED = 0; + private static final int ORDER_AMBIENT_VOLUME_CONTROL_SEPARATED = 1; + + private final Set mCachedDevices = new ArraySet<>(); + private final BiMap mSideToDeviceMap = HashBiMap.create(); + private final BiMap mSideToSliderMap = HashBiMap.create(); + + @Nullable + private PreferenceCategory mDeviceControls; + @Nullable + private AmbientVolumePreference mPreference; + + public BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context, + @NonNull PreferenceFragmentCompat fragment, + @NonNull CachedBluetoothDevice device, + @NonNull Lifecycle lifecycle) { + super(context, fragment, device, lifecycle); + } + + @Override + protected void init(PreferenceScreen screen) { + mDeviceControls = screen.findPreference(KEY_HEARING_DEVICE_GROUP); + if (mDeviceControls == null) { + return; + } + loadDevices(); + } + + @Override + protected void refresh() { + if (!isAvailable()) { + return; + } + // TODO: load data from remote + refreshControlUi(); + } + + @Override + public boolean isAvailable() { + boolean isDeviceSupportVcp = mCachedDevice.getProfiles().stream().anyMatch( + profile -> profile instanceof VolumeControlProfile); + return isDeviceSupportVcp; + } + + @Nullable + @Override + public String getPreferenceKey() { + return KEY_AMBIENT_VOLUME; + } + + @Override + public boolean onPreferenceChange(@NonNull Preference preference, @Nullable Object newValue) { + if (preference instanceof SeekBarPreference && newValue instanceof final Integer value) { + final int side = mSideToSliderMap.inverse().getOrDefault(preference, SIDE_INVALID); + if (DEBUG) { + Log.d(TAG, "onPreferenceChange: side=" + side + ", value=" + value); + } + if (side == SIDE_UNIFIED) { + // TODO: set the value on the devices + } else { + // TODO: set the value on the side device + } + return true; + } + return false; + } + + @Override + public void onDeviceAttributesChanged() { + mCachedDevices.forEach(device -> { + device.unregisterCallback(this); + }); + mContext.getMainExecutor().execute(() -> { + loadDevices(); + if (!mCachedDevices.isEmpty()) { + refresh(); + } + ThreadUtils.postOnBackgroundThread(() -> + mCachedDevices.forEach(device -> { + device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); + }) + ); + }); + } + + private void loadDevices() { + mSideToDeviceMap.clear(); + mCachedDevices.clear(); + if (VALID_SIDES.contains(mCachedDevice.getDeviceSide())) { + mSideToDeviceMap.put(mCachedDevice.getDeviceSide(), mCachedDevice.getDevice()); + mCachedDevices.add(mCachedDevice); + } + for (CachedBluetoothDevice memberDevice : mCachedDevice.getMemberDevice()) { + if (VALID_SIDES.contains(memberDevice.getDeviceSide())) { + mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice()); + mCachedDevices.add(memberDevice); + } + } + createAmbientVolumePreference(); + createSliderPreferences(); + if (mPreference != null) { + mPreference.setExpandable(mSideToDeviceMap.size() > 1); + mPreference.setSliders((mSideToSliderMap)); + } + } + + private void createAmbientVolumePreference() { + if (mPreference != null || mDeviceControls == null) { + return; + } + mPreference = new AmbientVolumePreference(mDeviceControls.getContext()); + mPreference.setKey(KEY_AMBIENT_VOLUME); + mPreference.setOrder(ORDER_AMBIENT_VOLUME); + if (mDeviceControls.findPreference(mPreference.getKey()) == null) { + mDeviceControls.addPreference(mPreference); + } + } + + private void createSliderPreferences() { + mSideToDeviceMap.forEach((s, d) -> + createSliderPreference(s, ORDER_AMBIENT_VOLUME_CONTROL_SEPARATED + s)); + createSliderPreference(SIDE_UNIFIED, ORDER_AMBIENT_VOLUME_CONTROL_UNIFIED); + } + + private void createSliderPreference(int side, int order) { + if (mSideToSliderMap.containsKey(side) || mDeviceControls == null) { + return; + } + SeekBarPreference preference = new SeekBarPreference(mDeviceControls.getContext()); + preference.setKey(KEY_AMBIENT_VOLUME_SLIDER + "_" + side); + preference.setOrder(order); + preference.setOnPreferenceChangeListener(this); + mSideToSliderMap.put(side, preference); + } + + /** Refreshes the control UI visibility and enabled state. */ + private void refreshControlUi() { + if (mPreference != null) { + mPreference.updateLayout(); + } + } +} diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java index 3703b7180af..7236d8cecc8 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, + 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..75f3c9a1180 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth; + +import static com.android.settings.bluetooth.AmbientVolumePreference.ROTATION_COLLAPSED; +import static com.android.settings.bluetooth.AmbientVolumePreference.ROTATION_EXPANDED; +import static com.android.settings.bluetooth.AmbientVolumePreference.SIDE_UNIFIED; +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME; +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME_SLIDER; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.util.ArrayMap; +import android.view.View; +import android.widget.ImageView; + +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; +import androidx.preference.PreferenceViewHolder; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.widget.SeekBarPreference; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +import java.util.Map; + +/** Tests for {@link AmbientVolumePreference}. */ +@RunWith(RobolectricTestRunner.class) +public class AmbientVolumePreferenceTest { + + private static final String KEY_UNIFIED_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_UNIFIED; + private static final String KEY_LEFT_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_LEFT; + private static final String KEY_RIGHT_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_RIGHT; + + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Spy + private Context mContext = ApplicationProvider.getApplicationContext(); + @Mock + private AmbientVolumePreference.OnIconClickListener mListener; + @Mock + private View mItemView; + + private AmbientVolumePreference mPreference; + private ImageView mExpandIcon; + private final Map mSideToSlidersMap = new ArrayMap<>(); + + @Before + public void setUp() { + PreferenceManager preferenceManager = new PreferenceManager(mContext); + PreferenceScreen preferenceScreen = preferenceManager.createPreferenceScreen(mContext); + mPreference = new AmbientVolumePreference(mContext); + mPreference.setKey(KEY_AMBIENT_VOLUME); + mPreference.setOnIconClickListener(mListener); + mPreference.setExpandable(true); + preferenceScreen.addPreference(mPreference); + + prepareSliders(); + mPreference.setSliders(mSideToSlidersMap); + + mExpandIcon = new ImageView(mContext); + when(mItemView.requireViewById(R.id.expand_icon)).thenReturn(mExpandIcon); + + PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests( + mItemView); + mPreference.onBindViewHolder(preferenceViewHolder); + } + + @Test + public void setExpandable_expandable_expandIconVisible() { + mPreference.setExpandable(true); + + assertThat(mExpandIcon.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void setExpandable_notExpandable_expandIconGone() { + mPreference.setExpandable(false); + + assertThat(mExpandIcon.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void setExpanded_expanded_assertControlUiCorrect() { + mPreference.setExpanded(true); + + assertControlUiCorrect(); + } + + @Test + public void setExpanded_notExpanded_assertControlUiCorrect() { + mPreference.setExpanded(false); + + assertControlUiCorrect(); + } + + private void assertControlUiCorrect() { + final boolean expanded = mPreference.isExpanded(); + assertThat(mSideToSlidersMap.get(SIDE_UNIFIED).isVisible()).isEqualTo(!expanded); + assertThat(mSideToSlidersMap.get(SIDE_LEFT).isVisible()).isEqualTo(expanded); + assertThat(mSideToSlidersMap.get(SIDE_RIGHT).isVisible()).isEqualTo(expanded); + final float rotation = expanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED; + assertThat(mExpandIcon.getRotation()).isEqualTo(rotation); + } + + private void prepareSliders() { + prepareSlider(SIDE_UNIFIED); + prepareSlider(SIDE_LEFT); + prepareSlider(SIDE_RIGHT); + } + + private void prepareSlider(int side) { + SeekBarPreference slider = new SeekBarPreference(mContext); + if (side == SIDE_LEFT) { + slider.setKey(KEY_LEFT_SLIDER); + } else if (side == SIDE_RIGHT) { + slider.setKey(KEY_RIGHT_SLIDER); + } else { + slider.setKey(KEY_UNIFIED_SLIDER); + } + mSideToSlidersMap.put(side, slider); + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java new file mode 100644 index 00000000000..89209d1db26 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth; + +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME; +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME_SLIDER; +import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.KEY_HEARING_DEVICE_GROUP; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.bluetooth.BluetoothDevice; +import android.os.Looper; + +import androidx.preference.PreferenceCategory; + +import com.android.settings.testutils.shadow.ShadowThreadUtils; +import com.android.settings.widget.SeekBarPreference; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Set; + +/** Tests for {@link BluetoothDetailsAmbientVolumePreferenceController}. */ +@RunWith(RobolectricTestRunner.class) +@Config(shadows = { + ShadowThreadUtils.class +}) +public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends + BluetoothDetailsControllerTestBase { + + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + + private static final String LEFT_CONTROL_KEY = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_LEFT; + private static final String RIGHT_CONTROL_KEY = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_RIGHT; + private static final String TEST_ADDRESS = "00:00:00:00:11"; + private static final String TEST_MEMBER_ADDRESS = "00:00:00:00:22"; + + @Mock + private CachedBluetoothDevice mCachedMemberDevice; + @Mock + private BluetoothDevice mDevice; + @Mock + private BluetoothDevice mMemberDevice; + + private BluetoothDetailsAmbientVolumePreferenceController mController; + + @Before + public void setUp() { + super.setUp(); + + PreferenceCategory deviceControls = new PreferenceCategory(mContext); + deviceControls.setKey(KEY_HEARING_DEVICE_GROUP); + mScreen.addPreference(deviceControls); + mController = new BluetoothDetailsAmbientVolumePreferenceController(mContext, mFragment, + mCachedDevice, mLifecycle); + } + + @Test + public void init_deviceWithoutMember_controlNotExpandable() { + prepareDevice(/* hasMember= */ false); + + mController.init(mScreen); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpandable()).isFalse(); + } + + @Test + public void init_deviceWithMember_controlExpandable() { + prepareDevice(/* hasMember= */ true); + + mController.init(mScreen); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpandable()).isTrue(); + } + + @Test + public void onDeviceAttributesChanged_newDevice_newPreference() { + prepareDevice(/* hasMember= */ false); + + mController.init(mScreen); + + // check the right control is null before onDeviceAttributesChanged() + SeekBarPreference leftControl = mScreen.findPreference(LEFT_CONTROL_KEY); + SeekBarPreference rightControl = mScreen.findPreference(RIGHT_CONTROL_KEY); + assertThat(leftControl).isNotNull(); + assertThat(rightControl).isNull(); + + prepareDevice(/* hasMember= */ true); + mController.onDeviceAttributesChanged(); + shadowOf(Looper.getMainLooper()).idle(); + + // check the right control is created after onDeviceAttributesChanged() + SeekBarPreference updatedLeftControl = mScreen.findPreference(LEFT_CONTROL_KEY); + SeekBarPreference updatedRightControl = mScreen.findPreference(RIGHT_CONTROL_KEY); + assertThat(updatedLeftControl).isEqualTo(leftControl); + assertThat(updatedRightControl).isNotNull(); + } + + private void prepareDevice(boolean hasMember) { + when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT); + when(mCachedDevice.getDevice()).thenReturn(mDevice); + when(mDevice.getAddress()).thenReturn(TEST_ADDRESS); + if (hasMember) { + when(mCachedDevice.getMemberDevice()).thenReturn(Set.of(mCachedMemberDevice)); + when(mCachedMemberDevice.getDeviceSide()).thenReturn(SIDE_RIGHT); + when(mCachedMemberDevice.getDevice()).thenReturn(mMemberDevice); + when(mMemberDevice.getAddress()).thenReturn(TEST_MEMBER_ADDRESS); + } + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java index 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(); + } } From c2ca7dadd9fb1c577b336944690b3ad25614280c Mon Sep 17 00:00:00 2001 From: Angela Wang Date: Thu, 31 Oct 2024 02:45:07 +0000 Subject: [PATCH 2/4] [Ambient Volume] Show value with local data Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control Bug: 357878944 Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest Change-Id: I3dad0f5424b44fee6d049fd778c4f8f71db0b58e --- res/values/strings.xml | 4 + ...ailsAmbientVolumePreferenceController.java | 131 +++++++++++++++- ...AmbientVolumePreferenceControllerTest.java | 147 +++++++++++++++++- 3 files changed, 277 insertions(+), 5 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index c7cdd6e7af9..ee80dae7d24 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -170,6 +170,10 @@ Expand to left and right separated controls Collapse to unified control + + Left + + Right Audio output diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java index 0629e6e9a45..90727035c0b 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java @@ -16,11 +16,16 @@ package com.android.settings.bluetooth; +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.content.Context; @@ -29,15 +34,21 @@ import android.util.Log; 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.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager; +import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data; 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; @@ -47,7 +58,8 @@ import java.util.Set; /** A {@link BluetoothDetailsController} that manages ambient volume control preferences. */ public class BluetoothDetailsAmbientVolumePreferenceController extends - BluetoothDetailsController implements Preference.OnPreferenceChangeListener { + BluetoothDetailsController implements Preference.OnPreferenceChangeListener, + HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, OnStart, OnStop { private static final boolean DEBUG = true; private static final String TAG = "AmbientPrefController"; @@ -60,6 +72,7 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends private final Set mCachedDevices = new ArraySet<>(); private final BiMap mSideToDeviceMap = HashBiMap.create(); private final BiMap mSideToSliderMap = HashBiMap.create(); + private final HearingDeviceLocalDataManager mLocalDataManager; @Nullable private PreferenceCategory mDeviceControls; @@ -71,6 +84,19 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends @NonNull CachedBluetoothDevice device, @NonNull Lifecycle lifecycle) { super(context, fragment, device, lifecycle); + mLocalDataManager = new HearingDeviceLocalDataManager(context); + mLocalDataManager.setOnDeviceLocalDataChangeListener(this, + ThreadUtils.getBackgroundExecutor()); + } + + @VisibleForTesting + BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context, + @NonNull PreferenceFragmentCompat fragment, + @NonNull CachedBluetoothDevice device, + @NonNull Lifecycle lifecycle, + @NonNull HearingDeviceLocalDataManager localSettings) { + super(context, fragment, device, lifecycle); + mLocalDataManager = localSettings; } @Override @@ -82,13 +108,33 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends loadDevices(); } + @Override + public void onStart() { + ThreadUtils.postOnBackgroundThread(() -> { + mLocalDataManager.start(); + mCachedDevices.forEach(device -> { + device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); + }); + }); + } + + @Override + public void onStop() { + ThreadUtils.postOnBackgroundThread(() -> { + mLocalDataManager.stop(); + mCachedDevices.forEach(device -> { + device.unregisterCallback(this); + }); + }); + } + @Override protected void refresh() { if (!isAvailable()) { return; } // TODO: load data from remote - refreshControlUi(); + loadLocalDataToUi(); } @Override @@ -111,6 +157,8 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends if (DEBUG) { Log.d(TAG, "onPreferenceChange: side=" + side + ", value=" + value); } + setVolumeIfValid(side, value); + if (side == SIDE_UNIFIED) { // TODO: set the value on the devices } else { @@ -139,15 +187,31 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends }); } + @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; + } + } + } + private void loadDevices() { mSideToDeviceMap.clear(); mCachedDevices.clear(); - if (VALID_SIDES.contains(mCachedDevice.getDeviceSide())) { + 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())) { + if (VALID_SIDES.contains(memberDevice.getDeviceSide()) + && memberDevice.getBondState() == BOND_BONDED) { mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice()); mCachedDevices.add(memberDevice); } @@ -164,9 +228,16 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends if (mPreference != null || mDeviceControls == null) { return; } + mPreference = new AmbientVolumePreference(mDeviceControls.getContext()); mPreference.setKey(KEY_AMBIENT_VOLUME); mPreference.setOrder(ORDER_AMBIENT_VOLUME); + mPreference.setOnIconClickListener(() -> { + mSideToDeviceMap.forEach((s, d) -> { + // Update new value to local data + mLocalDataManager.updateAmbientControlExpanded(d, isControlExpanded()); + }); + }); if (mDeviceControls.findPreference(mPreference.getKey()) == null) { mDeviceControls.addPreference(mPreference); } @@ -186,6 +257,12 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends 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); } @@ -195,4 +272,50 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends 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); + setVolumeIfValid(side, data.ambient()); + setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient()); + setControlExpanded(data.ambientControlExpanded()); + refreshControlUi(); + } + + 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); + }); + } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java index 89209d1db26..71da4b272c6 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java @@ -16,6 +16,8 @@ package com.android.settings.bluetooth; +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; @@ -24,17 +26,26 @@ import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_R 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.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; import android.bluetooth.BluetoothDevice; +import android.content.ContentResolver; 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.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager; import org.junit.Before; import org.junit.Rule; @@ -45,12 +56,19 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowSettings; +import java.util.HashMap; +import java.util.Map; 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 @@ -70,6 +88,8 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends private BluetoothDevice mDevice; @Mock private BluetoothDevice mMemberDevice; + @Mock + private HearingDeviceLocalDataManager mLocalDataManager; private BluetoothDetailsAmbientVolumePreferenceController mController; @@ -81,7 +101,7 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends deviceControls.setKey(KEY_HEARING_DEVICE_GROUP); mScreen.addPreference(deviceControls); mController = new BluetoothDetailsAmbientVolumePreferenceController(mContext, mFragment, - mCachedDevice, mLifecycle); + mCachedDevice, mLifecycle, mLocalDataManager); } @Test @@ -106,6 +126,88 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends assertThat(preference.isExpandable()).isTrue(); } + @Test + public void onDeviceLocalDataChange_noMemberAndExpanded_uiCorrectAndDataUpdated() { + prepareDevice(/* hasMember= */ false, /* controlExpanded= */ true); + + mController.init(mScreen); + mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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, /* controlExpanded= */ false); + + mController.init(mScreen); + mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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, /* controlExpanded= */ true); + + mController.init(mScreen); + mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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, /* controlExpanded= */ false); + + mController.init(mScreen); + mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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(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(mCachedDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class)); + verify(mCachedMemberDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class)); + } + @Test public void onDeviceAttributesChanged_newDevice_newPreference() { prepareDevice(/* hasMember= */ false); @@ -130,14 +232,57 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends } private void prepareDevice(boolean hasMember) { + prepareDevice(hasMember, false); + } + + private void prepareDevice(boolean hasMember, boolean controlExpanded) { 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); 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); + } + HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() + .ambient(0).groupAmbient(0).ambientControlExpanded(controlExpanded).build(); + when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(data); + } + + private HearingDeviceLocalDataManager.Data prepareEmptyData() { + return new HearingDeviceLocalDataManager.Data.Builder().build(); + } + + 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()); + } + + @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<>()); } } } From 46537a6576b889917f7c64335fa0a85140c59258 Mon Sep 17 00:00:00 2001 From: Angela Wang Date: Mon, 11 Nov 2024 07:23:46 +0000 Subject: [PATCH 3/4] [Ambient Volume] Show value with remote data Sync local data with remote data when UI need to refresh and set the corresponding local value to remote when the control expanded/collapsed. Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control Bug: 357878944 Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest Change-Id: If748e696eb62b199d4fd9abafa2300d301a8079c --- res/values/strings.xml | 2 + ...ailsAmbientVolumePreferenceController.java | 222 +++++++++++++++++- ...uetoothDetailsHearingDeviceController.java | 2 +- ...AmbientVolumePreferenceControllerTest.java | 125 +++++++--- 4 files changed, 315 insertions(+), 36 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index ee80dae7d24..5ab38a48afd 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -174,6 +174,8 @@ Left Right + + Couldn\u2019t update surroundings Audio output diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java index 90727035c0b..887c220c99f 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java @@ -28,9 +28,11 @@ import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_R 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; @@ -42,9 +44,12 @@ 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; @@ -54,12 +59,14 @@ 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 { + HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, OnStart, OnStop, + AmbientVolumeController.AmbientVolumeControlCallback, BluetoothCallback { private static final boolean DEBUG = true; private static final String TAG = "AmbientPrefController"; @@ -69,34 +76,45 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends 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 HearingDeviceLocalDataManager localSettings, + @NonNull AmbientVolumeController volumeController) { super(context, fragment, device, lifecycle); + mBluetoothManager = manager; mLocalDataManager = localSettings; + mVolumeController = volumeController; } @Override @@ -111,19 +129,33 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends @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()); }); }); } @@ -133,8 +165,17 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends if (!isAvailable()) { return; } - // TODO: load data from remote - loadLocalDataToUi(); + boolean shouldShowAmbientControl = isAmbientControlAvailable(); + if (shouldShowAmbientControl) { + if (mPreference != null) { + mPreference.setVisible(true); + } + loadRemoteDataToUi(); + } else { + if (mPreference != null) { + mPreference.setVisible(false); + } + } } @Override @@ -160,19 +201,33 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends setVolumeIfValid(side, value); if (side == SIDE_UNIFIED) { - // TODO: set the value on the devices + mSideToDeviceMap.forEach((s, d) -> mVolumeController.setAmbient(d, value)); } else { - // TODO: set the value on the side device + final BluetoothDevice device = mSideToDeviceMap.get(side); + mVolumeController.setAmbient(device, value); } 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(); @@ -182,6 +237,8 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends ThreadUtils.postOnBackgroundThread(() -> mCachedDevices.forEach(device -> { device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); + mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(), + device.getDevice()); }) ); }); @@ -201,6 +258,41 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends } } + @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 onCommandFailed(@NonNull BluetoothDevice device) { + Log.w(TAG, "onCommandFailed, device:" + device); + mContext.getMainExecutor().execute(() -> { + showErrorToast(); + refresh(); + }); + } + private void loadDevices() { mSideToDeviceMap.clear(); mCachedDevices.clear(); @@ -234,6 +326,11 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends mPreference.setOrder(ORDER_AMBIENT_VOLUME); mPreference.setOnIconClickListener(() -> { mSideToDeviceMap.forEach((s, d) -> { + // 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()); }); @@ -269,6 +366,16 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends /** 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(); } } @@ -299,12 +406,74 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device); } final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID); - setVolumeIfValid(side, data.ambient()); - setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient()); + if (isDeviceConnectedToVcp(device)) { + 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(); + + 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(); } @@ -318,4 +487,41 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends mLocalDataManager.updateAmbientControlExpanded(d, expanded); }); } + + 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 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 7236d8cecc8..8af08792180 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java @@ -110,7 +110,7 @@ public class BluetoothDetailsHearingDeviceController extends BluetoothDetailsCon } if (com.android.settingslib.flags.Flags.hearingDevicesAmbientVolumeControl()) { mControllers.add(new BluetoothDetailsAmbientVolumePreferenceController(mContext, - mFragment, mCachedDevice, mLifecycle)); + mManager, mFragment, mCachedDevice, mLifecycle)); } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java index 71da4b272c6..b7aaab4527a 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java @@ -29,14 +29,19 @@ 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; @@ -44,8 +49,13 @@ 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; @@ -90,6 +100,18 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends 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; @@ -97,11 +119,29 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends public void setUp() { super.setUp(); + mContext = spy(mContext); PreferenceCategory deviceControls = new PreferenceCategory(mContext); deviceControls.setKey(KEY_HEARING_DEVICE_GROUP); mScreen.addPreference(deviceControls); - mController = new BluetoothDetailsAmbientVolumePreferenceController(mContext, mFragment, - mCachedDevice, mLifecycle, mLocalDataManager); + 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(mContext.getMainThreadHandler()).thenReturn(mTestHandler); + when(mTestHandler.postDelayed(any(Runnable.class), anyLong())).thenAnswer( + invocationOnMock -> { + invocationOnMock.getArgument(0, Runnable.class).run(); + return null; + }); } @Test @@ -128,10 +168,13 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @Test public void onDeviceLocalDataChange_noMemberAndExpanded_uiCorrectAndDataUpdated() { - prepareDevice(/* hasMember= */ false, /* controlExpanded= */ true); - + prepareDevice(/* hasMember= */ false); mController.init(mScreen); - mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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); @@ -142,10 +185,13 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @Test public void onDeviceLocalDataChange_noMemberAndCollapsed_uiCorrectAndDataUpdated() { - prepareDevice(/* hasMember= */ false, /* controlExpanded= */ false); - + prepareDevice(/* hasMember= */ false); mController.init(mScreen); - mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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); @@ -156,10 +202,13 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @Test public void onDeviceLocalDataChange_hasMemberAndExpanded_uiCorrectAndDataUpdated() { - prepareDevice(/* hasMember= */ true, /* controlExpanded= */ true); - + prepareDevice(/* hasMember= */ true); mController.init(mScreen); - mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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); @@ -170,10 +219,13 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @Test public void onDeviceLocalDataChange_hasMemberAndCollapsed_uiCorrectAndDataUpdated() { - prepareDevice(/* hasMember= */ true, /* controlExpanded= */ false); - + prepareDevice(/* hasMember= */ true); mController.init(mScreen); - mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData()); + 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); @@ -185,11 +237,13 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @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), @@ -199,11 +253,13 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @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)); } @@ -211,7 +267,6 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends @Test public void onDeviceAttributesChanged_newDevice_newPreference() { prepareDevice(/* hasMember= */ false); - mController.init(mScreen); // check the right control is null before onDeviceAttributesChanged() @@ -231,16 +286,34 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends assertThat(updatedRightControl).isNotNull(); } - private void prepareDevice(boolean hasMember) { - prepareDevice(hasMember, false); + @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(); } - private void prepareDevice(boolean hasMember, boolean controlExpanded) { + 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); @@ -248,14 +321,8 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends 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); } - HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() - .ambient(0).groupAmbient(0).ambientControlExpanded(controlExpanded).build(); - when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(data); - } - - private HearingDeviceLocalDataManager.Data prepareEmptyData() { - return new HearingDeviceLocalDataManager.Data.Builder().build(); } private void verifyDeviceDataUpdated(BluetoothDevice device) { @@ -265,6 +332,10 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends 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<>(); From 0724f2a811e6fab1d9ce8431e71ca37858068b53 Mon Sep 17 00:00:00 2001 From: Angela Wang Date: Sat, 30 Nov 2024 05:40:25 +0000 Subject: [PATCH 4/4] [Ambient Volume] Ambient volume icon 1. Click on the icon on the header will mute/unmute the remote devices in the same set if the remote devices are both mutable. 2. Show different icon when the value of the volume slide bars changed. Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control Bug: 357878944 Test: atest AmbientVolumePreferenceTest Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest Change-Id: I829c5e08f1456c8ef9936d0314045dc8da37cd95 --- res/values/strings.xml | 4 + .../bluetooth/AmbientVolumePreference.java | 125 ++++++++++++++++++ ...ailsAmbientVolumePreferenceController.java | 123 ++++++++++++++--- .../AmbientVolumePreferenceTest.java | 86 ++++++++++++ ...AmbientVolumePreferenceControllerTest.java | 75 +++++++++++ 5 files changed, 395 insertions(+), 18 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 5ab38a48afd..3300cc61c38 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -174,6 +174,10 @@ Left Right + + Mute surroundings + + Unmute surroundings Couldn\u2019t update surroundings diff --git a/src/com/android/settings/bluetooth/AmbientVolumePreference.java b/src/com/android/settings/bluetooth/AmbientVolumePreference.java index 01222039a9b..e916c046df6 100644 --- a/src/com/android/settings/bluetooth/AmbientVolumePreference.java +++ b/src/com/android/settings/bluetooth/AmbientVolumePreference.java @@ -17,6 +17,8 @@ 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; @@ -25,6 +27,7 @@ import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_R import android.content.Context; import android.util.ArrayMap; import android.view.View; +import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -34,6 +37,8 @@ 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; @@ -50,10 +55,16 @@ public class AmbientVolumePreference extends PreferenceGroup { 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); @@ -61,10 +72,33 @@ public class AmbientVolumePreference extends PreferenceGroup { 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: + *

+ */ + private int mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT; + public AmbientVolumePreference(@NonNull Context context) { super(context, null); setLayoutResource(R.layout.preference_ambient_volume); @@ -79,6 +113,21 @@ public class AmbientVolumePreference extends PreferenceGroup { 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); @@ -114,6 +163,36 @@ public class AmbientVolumePreference extends PreferenceGroup { 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; } @@ -140,6 +219,7 @@ public class AmbientVolumePreference extends PreferenceGroup { SeekBarPreference slider = mSideToSliderMap.get(side); if (slider != null && slider.getProgress() != value) { slider.setProgress(value); + updateVolumeLevel(); } } @@ -162,6 +242,34 @@ public class AmbientVolumePreference extends PreferenceGroup { 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() { @@ -179,4 +287,21 @@ public class AmbientVolumePreference extends PreferenceGroup { 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 index 887c220c99f..f237ffe50c3 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java @@ -16,6 +16,8 @@ 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; @@ -180,9 +182,8 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends @Override public boolean isAvailable() { - boolean isDeviceSupportVcp = mCachedDevice.getProfiles().stream().anyMatch( + return mCachedDevice.getProfiles().stream().anyMatch( profile -> profile instanceof VolumeControlProfile); - return isDeviceSupportVcp; } @Nullable @@ -200,11 +201,30 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends } setVolumeIfValid(side, value); - if (side == SIDE_UNIFIED) { - mSideToDeviceMap.forEach((s, d) -> mVolumeController.setAmbient(d, 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 { - final BluetoothDevice device = mSideToDeviceMap.get(side); - mVolumeController.setAmbient(device, value); + setAmbientRunnable.run(); } return true; } @@ -284,6 +304,24 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends 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); @@ -324,17 +362,33 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends mPreference = new AmbientVolumePreference(mDeviceControls.getContext()); mPreference.setKey(KEY_AMBIENT_VOLUME); mPreference.setOrder(ORDER_AMBIENT_VOLUME); - mPreference.setOnIconClickListener(() -> { - mSideToDeviceMap.forEach((s, d) -> { - // 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()); - }); - }); + 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); } @@ -406,7 +460,7 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device); } final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID); - if (isDeviceConnectedToVcp(device)) { + if (isDeviceConnectedToVcp(device) && !isControlMuted()) { setVolumeIfValid(side, data.ambient()); setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient()); } @@ -456,6 +510,26 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends // 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(); } @@ -488,6 +562,10 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends }); } + private boolean isControlMuted() { + return mPreference != null && mPreference.isMuted(); + } + private void initLocalDataIfNeeded() { int smallerVolumeAmongGroup = Integer.MAX_VALUE; for (BluetoothDevice device : mSideToDeviceMap.values()) { @@ -510,6 +588,15 @@ public class BluetoothDetailsAmbientVolumePreferenceController extends } } + 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() diff --git a/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java index 75f3c9a1180..ec406c45503 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java @@ -57,6 +57,9 @@ import java.util.Map; @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; @@ -72,6 +75,7 @@ public class AmbientVolumePreferenceTest { private AmbientVolumePreference mPreference; private ImageView mExpandIcon; + private ImageView mVolumeIcon; private final Map mSideToSlidersMap = new ArrayMap<>(); @Before @@ -82,13 +86,19 @@ public class AmbientVolumePreferenceTest { 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); @@ -123,6 +133,77 @@ public class AmbientVolumePreferenceTest { 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); @@ -140,12 +221,17 @@ public class AmbientVolumePreferenceTest { 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 index b7aaab4527a..975d3b491aa 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java @@ -16,6 +16,9 @@ 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; @@ -71,6 +74,7 @@ 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; @@ -135,6 +139,9 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends 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( @@ -307,6 +314,68 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends 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); @@ -325,6 +394,12 @@ public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends } } + 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());