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

Commit 48594a2d authored by Angela Wang's avatar Angela Wang
Browse files

[Ambient Volume] load data into hearing device dialog

Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control
Bug: 357878944
Test: atest AmbientVolumeUiControllerTest
Change-Id: I00f40e08be844bfc054d4f25d59a66ffb63b127a
parent 5af5011f
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -229,6 +229,8 @@
    <string name="bluetooth_hearing_aid_right_active">Active (right only)</string>
    <!-- Connected device settings. Message when the left-side and right-side hearing aids device are active. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_hearing_aid_left_and_right_active">Active (left and right)</string>
    <!-- Connected device settings.: Message when changing remote ambient state failed. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_hearing_device_ambient_error">Couldn\u2019t update surroundings</string>

    <!-- Connected devices settings. Message when Bluetooth is connected and active for media only, showing remote device status and battery level. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_active_media_only_battery_level">Active (media only). <xliff:g id="battery_level_as_percentage">%1$s</xliff:g> battery.</string>
+280 −4
Original line number Diff line number Diff line
@@ -16,21 +16,29 @@

package com.android.settingslib.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.settingslib.bluetooth.AmbientVolumeUi.SIDE_UNIFIED;
import static com.android.settingslib.bluetooth.AmbientVolumeUi.VALID_SIDES;
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.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;
import androidx.annotation.VisibleForTesting;

import com.android.settingslib.R;
import com.android.settingslib.utils.ThreadUtils;

import com.google.common.collect.BiMap;
@@ -41,14 +49,19 @@ import java.util.Set;

/** This class controls ambient volume UI with local and remote ambient data. */
public class AmbientVolumeUiController implements
        HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener,
        AmbientVolumeController.AmbientVolumeControlCallback,
        BluetoothCallback, CachedBluetoothDevice.Callback {
        AmbientVolumeUi.AmbientVolumeUiListener, BluetoothCallback, CachedBluetoothDevice.Callback {

    private static final boolean DEBUG = true;
    private static final String TAG = "AmbientVolumeUiController";

    private final Context mContext;
    private final LocalBluetoothProfileManager mProfileManager;
    private final BluetoothEventManager mEventManager;
    private final AmbientVolumeUi mAmbientLayout;
    private final AmbientVolumeController mVolumeController;
    private final HearingDeviceLocalDataManager mLocalDataManager;

    private final Set<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
    private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
@@ -62,19 +75,44 @@ public class AmbientVolumeUiController implements
        mProfileManager = bluetoothManager.getProfileManager();
        mEventManager = bluetoothManager.getEventManager();
        mAmbientLayout = ambientLayout;
        mAmbientLayout.setListener(this);
        mVolumeController = new AmbientVolumeController(mProfileManager, this);
        mLocalDataManager = new HearingDeviceLocalDataManager(context);
        mLocalDataManager.setOnDeviceLocalDataChangeListener(this,
                ThreadUtils.getBackgroundExecutor());
    }

    @VisibleForTesting
    public AmbientVolumeUiController(@NonNull Context context,
            @NonNull LocalBluetoothManager bluetoothManager,
            @NonNull AmbientVolumeUi ambientLayout,
            @NonNull AmbientVolumeController volumeController) {
            @NonNull AmbientVolumeController volumeController,
            @NonNull HearingDeviceLocalDataManager localDataManager) {
        mContext = context;
        mProfileManager = bluetoothManager.getProfileManager();
        mEventManager = bluetoothManager.getEventManager();
        mAmbientLayout = ambientLayout;
        mVolumeController = volumeController;
        mLocalDataManager = localDataManager;
    }


    @Override
    public void onDeviceLocalDataChange(@NonNull String address,
            @Nullable HearingDeviceLocalDataManager.Data data) {
        if (data == null) {
            // The local data is removed because the device is unpaired, do nothing
            return;
        }
        if (DEBUG) {
            Log.d(TAG, "onDeviceLocalDataChange, address:" + address + ", data:" + data);
        }
        for (BluetoothDevice device : mSideToDeviceMap.values()) {
            if (device.getAnonymizedAddress().equals(address)) {
                postOnMainThread(() -> loadLocalDataToUi(device));
                return;
            }
        }
    }

    @Override
@@ -85,14 +123,110 @@ public class AmbientVolumeUiController implements

    @Override
    public void onAmbientChanged(@NonNull BluetoothDevice device, int gainSettings) {
        if (DEBUG) {
            Log.d(TAG, "onAmbientChanged, value:" + gainSettings + ", device:" + device);
        }
        HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
        final boolean expanded = mAmbientLayout.isExpanded();
        final boolean isInitiatedFromUi = (expanded && data.ambient() == gainSettings)
                || (!expanded && 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.
        postDelayedOnMainThread(this::refresh, 1200L);
    }

    @Override
    public void onMuteChanged(@NonNull BluetoothDevice device, int mute) {
        if (DEBUG) {
            Log.d(TAG, "onMuteChanged, mute:" + mute + ", device:" + device);
        }
        final boolean muted = mAmbientLayout.isMuted();
        boolean isInitiatedFromUi = (muted && mute == MUTE_MUTED)
                || (!muted && 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.
        postDelayedOnMainThread(this::refresh, 1200L);
    }

    @Override
    public void onCommandFailed(@NonNull BluetoothDevice device) {
        Log.w(TAG, "onCommandFailed, device:" + device);
        postOnMainThread(() -> {
            showErrorToast(R.string.bluetooth_hearing_device_ambient_error);
            refresh();
        });
    }

    @Override
    public void onExpandIconClick() {
        mSideToDeviceMap.forEach((s, d) -> {
            if (!mAmbientLayout.isMuted()) {
                // Apply previous collapsed/expanded volume to remote device
                HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(d);
                int volume = mAmbientLayout.isExpanded()
                        ? data.ambient() : data.groupAmbient();
                mVolumeController.setAmbient(d, volume);
            }
            // Update new value to local data
            mLocalDataManager.updateAmbientControlExpanded(d,
                    mAmbientLayout.isExpanded());
        });
        mLocalDataManager.flush();
    }

    @Override
    public void onAmbientVolumeIconClick() {
        if (!mAmbientLayout.isMuted()) {
            loadLocalDataToUi();
        }
        for (BluetoothDevice device : mSideToDeviceMap.values()) {
            mVolumeController.setMuted(device, mAmbientLayout.isMuted());
        }
    }

    @Override
    public void onSliderValueChange(int side, int value) {
        if (DEBUG) {
            Log.d(TAG, "onSliderValueChange: side=" + side + ", value=" + value);
        }
        setVolumeIfValid(side, 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 (mAmbientLayout.isMuted()) {
            // User drag on the volume slider when muted. Unmute the devices first.
            mAmbientLayout.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
            postDelayedOnMainThread(setAmbientRunnable, 1000L);
        } else {
            setAmbientRunnable.run();
        }
    }

    @Override
@@ -131,6 +265,7 @@ public class AmbientVolumeUiController implements
     */
    public void start() {
        mEventManager.registerCallback(this);
        mLocalDataManager.start();
        mCachedDevices.forEach(device -> {
            device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
            mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
@@ -144,6 +279,7 @@ public class AmbientVolumeUiController implements
     */
    public void stop() {
        mEventManager.unregisterCallback(this);
        mLocalDataManager.stop();
        mCachedDevices.forEach(device -> {
            device.unregisterCallback(this);
            mVolumeController.unregisterCallback(device.getDevice());
@@ -157,6 +293,9 @@ public class AmbientVolumeUiController implements
     * @param cachedDevice the remote device
     */
    public void loadDevice(CachedBluetoothDevice cachedDevice) {
        if (DEBUG) {
            Log.d(TAG, "loadDevice, device=" + cachedDevice);
        }
        mCachedDevice = cachedDevice;
        mSideToDeviceMap.clear();
        mCachedDevices.clear();
@@ -191,7 +330,7 @@ public class AmbientVolumeUiController implements
    public void refresh() {
        if (isAmbientControlAvailable()) {
            mAmbientLayout.setVisible(true);
            updateSliderUi();
            loadRemoteDataToUi();
        } else {
            mAmbientLayout.setVisible(false);
        }
@@ -217,11 +356,116 @@ public class AmbientVolumeUiController implements
        mAmbientLayout.updateLayout();
    }

    /** Sets the ambient to the corresponding control slider. */
    private void setVolumeIfValid(int side, int volume) {
        if (volume == INVALID_VOLUME) {
            return;
        }
        mAmbientLayout.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);
        }
        mLocalDataManager.flush();
    }

    private void loadLocalDataToUi() {
        mSideToDeviceMap.forEach((s, d) -> loadLocalDataToUi(d));
    }

    private void loadLocalDataToUi(BluetoothDevice device) {
        final HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
        if (DEBUG) {
            Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device);
        }
        if (isDeviceConnectedToVcp(device) && !mAmbientLayout.isMuted()) {
            final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID);
            setVolumeIfValid(side, data.ambient());
            setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient());
        }
        setAmbientControlExpanded(data.ambientControlExpanded());
        updateSliderUi();
    }

    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);
        }
        mSideToDeviceMap.forEach((side, device) -> {
            int ambientMax = mVolumeController.getAmbientMax(device);
            int ambientMin = mVolumeController.getAmbientMin(device);
            if (ambientMin != ambientMax) {
                mAmbientLayout.setSliderRange(side, ambientMin, ambientMax);
                mAmbientLayout.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 (mAmbientLayout.isExpanded()) {
            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);
                setAmbientControlExpanded(true);
            } else {
                int unifiedAmbient = leftAmbient != INVALID_VOLUME ? leftAmbient : rightAmbient;
                setVolumeIfValid(SIDE_UNIFIED, unifiedAmbient);
            }
        }
        // Initialize local data between side and group value
        initLocalAmbientDataIfNeeded();

        // 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();
        }
        mAmbientLayout.setMutable(mutable);
        mAmbientLayout.setMuted(muted);

        // Ensure remote device mute state is synced
        syncMuteStateIfNeeded(leftDevice, leftState, muted);
        syncMuteStateIfNeeded(rightDevice, rightState, muted);

        updateSliderUi();
    }

    private void setAmbientControlExpanded(boolean expanded) {
        mAmbientLayout.setExpanded(expanded);
        mSideToDeviceMap.forEach((s, d) -> {
            // Update new value to local data
            mLocalDataManager.updateAmbientControlExpanded(d, expanded);
        });
        mLocalDataManager.flush();
    }

    /** Checks if any device in the same set has valid ambient control points */
    private boolean isAmbientControlAvailable() {
        for (BluetoothDevice device : mSideToDeviceMap.values()) {
            if (mShowUiWhenLocalDataExist) {
                // TODO: check if local data is available
                // Found local ambient data
                if (mLocalDataManager.get(device).hasAmbientData()) {
                    return true;
                }
            }
            // Found remote ambient control points
            if (mVolumeController.isAmbientControlAvailable(device)) {
@@ -231,6 +475,38 @@ public class AmbientVolumeUiController implements
        return false;
    }

    private void initLocalAmbientDataIfNeeded() {
        int smallerVolumeAmongGroup = Integer.MAX_VALUE;
        for (BluetoothDevice device : mSideToDeviceMap.values()) {
            HearingDeviceLocalDataManager.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()) {
                HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
                if (data.groupAmbient() == INVALID_VOLUME) {
                    // Initialize group ambient from smaller side ambient value
                    mLocalDataManager.updateGroupAmbient(device, smallerVolumeAmongGroup);
                }
            }
        }
        mLocalDataManager.flush();
    }

    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()
                && mProfileManager.getVolumeControlProfile().getConnectionStatus(device)
+106 −2
Original line number Diff line number Diff line
@@ -16,6 +16,9 @@

package com.android.settingslib.bluetooth;

import static android.bluetooth.AudioInputControl.MUTE_DISABLED;
import static android.bluetooth.AudioInputControl.MUTE_MUTED;
import static android.bluetooth.AudioInputControl.MUTE_NOT_MUTED;
import static android.bluetooth.BluetoothDevice.BOND_BONDED;

import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
@@ -24,13 +27,19 @@ import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_R
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.never;
import static org.robolectric.Shadows.shadowOf;

import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;

import androidx.test.core.app.ApplicationProvider;

@@ -73,6 +82,8 @@ public class AmbientVolumeUiControllerTest {
    @Mock
    private AmbientVolumeController mVolumeController;
    @Mock
    private HearingDeviceLocalDataManager mLocalDataManager;
    @Mock
    private CachedBluetoothDevice mCachedDevice;
    @Mock
    private CachedBluetoothDevice mCachedMemberDevice;
@@ -92,8 +103,8 @@ public class AmbientVolumeUiControllerTest {
        when(mBluetoothManager.getProfileManager()).thenReturn(mProfileManager);
        when(mBluetoothManager.getEventManager()).thenReturn(mEventManager);

        mController = new AmbientVolumeUiController(mContext, mBluetoothManager, mAmbientLayout,
                mVolumeController);
        mController = spy(new AmbientVolumeUiController(mContext, mBluetoothManager,
                mAmbientLayout, mVolumeController, mLocalDataManager));

        when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile);
        when(mVolumeControlProfile.getConnectionStatus(mDevice)).thenReturn(
@@ -102,6 +113,8 @@ public class AmbientVolumeUiControllerTest {
                BluetoothProfile.STATE_CONNECTED);
        when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(true);
        when(mVolumeController.isAmbientControlAvailable(mMemberDevice)).thenReturn(true);
        when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(
                new HearingDeviceLocalDataManager.Data.Builder().build());

        when(mContext.getMainThreadHandler()).thenReturn(mTestHandler);
        Answer<Object> answer = invocationOnMock -> {
@@ -113,6 +126,7 @@ public class AmbientVolumeUiControllerTest {

        prepareDevice(/* hasMember= */ true);
        mController.loadDevice(mCachedDevice);
        Mockito.reset(mController);
        Mockito.reset(mAmbientLayout);
    }

@@ -168,6 +182,7 @@ public class AmbientVolumeUiControllerTest {
        mController.start();

        verify(mEventManager).registerCallback(mController);
        verify(mLocalDataManager).start();
        verify(mVolumeController).registerCallback(any(Executor.class), eq(mDevice));
        verify(mVolumeController).registerCallback(any(Executor.class), eq(mMemberDevice));
        verify(mCachedDevice).registerCallback(any(Executor.class),
@@ -181,12 +196,89 @@ public class AmbientVolumeUiControllerTest {
        mController.stop();

        verify(mEventManager).unregisterCallback(mController);
        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));
    }

    @Test
    public void onDeviceLocalDataChange_verifySetExpandedAndDataUpdated() {
        final boolean testExpanded = true;
        HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
                .ambient(0).groupAmbient(0).ambientControlExpanded(testExpanded).build();
        when(mLocalDataManager.get(mDevice)).thenReturn(data);

        mController.onDeviceLocalDataChange(TEST_ADDRESS, data);
        shadowOf(Looper.getMainLooper()).idle();

        verify(mAmbientLayout).setExpanded(testExpanded);
        verifyDeviceDataUpdated(mDevice);
    }

    @Test
    public void onAmbientChanged_refreshWhenNotInitiateFromUi() {
        HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
                .ambient(10).groupAmbient(10).ambientControlExpanded(true).build();
        when(mLocalDataManager.get(mDevice)).thenReturn(data);
        when(mAmbientLayout.isExpanded()).thenReturn(true);

        mController.onAmbientChanged(mDevice, 10);
        verify(mController, never()).refresh();

        mController.onAmbientChanged(mDevice, 20);
        verify(mController).refresh();
    }

    @Test
    public void onMuteChanged_refreshWhenNotInitiateFromUi() {
        AmbientVolumeController.RemoteAmbientState state =
                new AmbientVolumeController.RemoteAmbientState(MUTE_NOT_MUTED, 0);
        when(mVolumeController.refreshAmbientState(mDevice)).thenReturn(state);
        when(mAmbientLayout.isExpanded()).thenReturn(false);

        mController.onMuteChanged(mDevice, MUTE_NOT_MUTED);
        verify(mController, never()).refresh();

        mController.onMuteChanged(mDevice, MUTE_MUTED);
        verify(mController).refresh();
    }

    @Test
    public void refresh_leftAndRightDifferentGainSetting_expandControl() {
        prepareRemoteData(mDevice, 10, MUTE_NOT_MUTED);
        prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
        when(mAmbientLayout.isExpanded()).thenReturn(false);

        mController.refresh();

        verify(mAmbientLayout).setExpanded(true);
    }

    @Test
    public void refresh_oneSideNotMutable_controlNotMutableAndNotMuted() {
        prepareRemoteData(mDevice, 10, MUTE_DISABLED);
        prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);

        mController.refresh();

        verify(mAmbientLayout).setMutable(false);
        verify(mAmbientLayout).setMuted(false);
    }

    @Test
    public void refresh_oneSideNotMuted_controlNotMutedAndSyncToRemote() {
        prepareRemoteData(mDevice, 10, MUTE_MUTED);
        prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);

        mController.refresh();

        verify(mAmbientLayout).setMutable(true);
        verify(mAmbientLayout).setMuted(false);
        verify(mVolumeController).setMuted(mDevice, false);
    }

    private void prepareDevice(boolean hasMember) {
        when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT);
        when(mCachedDevice.getDevice()).thenReturn(mDevice);
@@ -208,4 +300,16 @@ public class AmbientVolumeUiControllerTest {
            when(mCachedDevice.getMemberDevice()).thenReturn(Set.of());
        }
    }

    private void prepareRemoteData(BluetoothDevice device, int gainSetting, int mute) {
        when(mVolumeController.refreshAmbientState(device)).thenReturn(
                new AmbientVolumeController.RemoteAmbientState(gainSetting, mute));
    }

    private void verifyDeviceDataUpdated(BluetoothDevice device) {
        verify(mLocalDataManager).updateAmbient(eq(device), anyInt());
        verify(mLocalDataManager).updateGroupAmbient(eq(device), anyInt());
        verify(mLocalDataManager).updateAmbientControlExpanded(eq(device),
                anyBoolean());
    }
}