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