Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c2ca7dad authored by Angela Wang's avatar Angela Wang
Browse files

[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
parent 0595aed3
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -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] -->
+127 −4
Original line number Diff line number Diff line
@@ -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);
        });
    }
}
+146 −1
Original line number Diff line number Diff line
@@ -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<>());
        }
    }
}