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<>());
}
}
}