[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
This commit is contained in:
Angela Wang
2024-10-31 02:45:07 +00:00
parent 0595aed386
commit c2ca7dadd9
3 changed files with 277 additions and 5 deletions

View File

@@ -170,6 +170,10 @@
<string name="bluetooth_ambient_volume_control_expand">Expand to left and right separated controls</string>
<!-- Connected devices settings. Content description for the icon to collapse the left and right separated ambient volume controls to unified control. [CHAR LIMIT=NONE] -->
<string name="bluetooth_ambient_volume_control_collapse">Collapse to unified control</string>
<!-- Connected devices settings. The text to show the control is for left side device. [CHAR LIMIT=30] -->
<string name="bluetooth_ambient_volume_control_left">Left</string>
<!-- Connected devices settings. The text to show the control is for right side device. [CHAR LIMIT=30] -->
<string name="bluetooth_ambient_volume_control_right">Right</string>
<!-- Connected devices settings. Title of the preference to show the entrance of the audio output page. It can change different types of audio are played on phone or other bluetooth devices. [CHAR LIMIT=35] -->
<string name="bluetooth_audio_routing_title">Audio output</string>
<!-- Title for bluetooth audio routing page footer. [CHAR LIMIT=30] -->

View File

@@ -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<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
private final BiMap<Integer, SeekBarPreference> 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);
});
}
}

View File

@@ -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<ContentResolver, Map<String, String>> sDataMap = new HashMap<>();
@Implementation
protected static boolean putStringForUser(
ContentResolver cr, String name, String value, int userHandle) {
get(cr).put(name, value);
return true;
}
@Implementation
protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
return get(cr).get(name);
}
private static Map<String, String> get(ContentResolver cr) {
return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
}
}
}