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

Commit 5af5011f authored by Angela Wang's avatar Angela Wang
Browse files

[Ambient Volume] Setup the UI elements in hearing device dialog

Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control
Bug: 357878944
Test: atest AmbientVolumeLayoutTest
Test: atest AmbientVolumeSliderTest
Test: atest AmbientVolumeUiControllerTest
Test: atest HearingDevicesDialogDelegateTest
Change-Id: I91ee7e6b12adb0b7e0e8350e889abfeabb3da68e
parent 57002f45
Loading
Loading
Loading
Loading
+170 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settingslib.bluetooth;

import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;

import android.bluetooth.BluetoothDevice;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.util.List;
import java.util.Map;

/** Interface for the ambient volume UI. */
public interface AmbientVolumeUi {

    /** Interface definition for a callback to be invoked when event happens in AmbientVolumeUi. */
    interface AmbientVolumeUiListener {
        /** Called when the expand icon is clicked. */
        void onExpandIconClick();

        /** Called when the ambient volume icon is clicked. */
        void onAmbientVolumeIconClick();

        /** Called when the slider of the specified side is changed. */
        void onSliderValueChange(int side, int value);
    };

    /** The rotation degree of the expand icon when the UI is in collapsed mode. */
    float ROTATION_COLLAPSED = 0f;
    /** The rotation degree of the expand icon when the UI is in expanded mode. */
    float ROTATION_EXPANDED = 180f;

    /**
     * The default ambient volume level for hearing device ambient volume icon
     *
     * <p> This icon visually represents the current ambient volume. It displays separate
     * levels for the left and right sides, each with 5 levels ranging from 0 to 4.
     *
     * <p> To represent the combined left/right levels with a single value, the following
     * calculation is used:
     *      finalLevel = (leftLevel * 5) + rightLevel
     * For example:
     * <ul>
     *    <li>If left level is 2 and right level is 3, the final level will be 13 (2 * 5 + 3)</li>
     *    <li>If both left and right levels are 0, the final level will be 0</li>
     *    <li>If both left and right levels are 4, the final level will be 24</li>
     * </ul>
     */
    int AMBIENT_VOLUME_LEVEL_DEFAULT = 24;
    /**
     * The minimum ambient volume level for hearing device ambient volume icon
     *
     * @see #AMBIENT_VOLUME_LEVEL_DEFAULT
     */
    int AMBIENT_VOLUME_LEVEL_MIN = 0;
    /**
     * The maximum ambient volume level for hearing device ambient volume icon
     *
     * @see #AMBIENT_VOLUME_LEVEL_DEFAULT
     */
    int AMBIENT_VOLUME_LEVEL_MAX = 24;

    /**
     * Ths side identifier for slider in collapsed mode which can unified control the ambient
     * volume of all devices in the same set.
     */
    int SIDE_UNIFIED = 999;

    /** All valid side of the sliders in the UI. */
    List<Integer> VALID_SIDES = List.of(SIDE_UNIFIED, SIDE_LEFT, SIDE_RIGHT);

    /** Sets if the UI is visible. */
    void setVisible(boolean visible);

    /**
     * Sets if the UI is expandable between expanded and collapsed mode.
     *
     * <p> If the UI is not expandable, it implies the UI will always stay in collapsed mode
     */
    void setExpandable(boolean expandable);

    /** @return if the UI is expandable. */
    boolean isExpandable();

    /** Sets if the UI is in expanded mode. */
    void setExpanded(boolean expanded);

    /** @return if the UI is in expanded mode. */
    boolean isExpanded();

    /**
     * Sets if the UI is capable to mute the ambient of the remote device.
     *
     * <p> If the value is {@code false}, it implies the remote device ambient will always be
     * unmute and can not be mute from the UI
     */
    void setMutable(boolean mutable);

    /** @return if the UI is capable to mute the ambient of remote device. */
    boolean isMutable();

    /** Sets if the UI shows mute state. */
    void setMuted(boolean muted);

    /** @return if the UI shows mute state */
    boolean isMuted();

    /**
     * Sets listener on the UI.
     *
     * @see AmbientVolumeUiListener
     */
    void setListener(@Nullable AmbientVolumeUiListener listener);

    /**
     * Sets up sliders in the UI.
     *
     * <p> For each side of device, the UI should hava a corresponding slider to control it's
     * ambient volume.
     * <p> For all devices in the same set, the UI should have a slider to control all devices'
     * ambient volume at once.
     * @param sideToDeviceMap the side and device mapping of all devices in the same set
     */
    void setupSliders(@NonNull Map<Integer, BluetoothDevice> sideToDeviceMap);

    /**
     * Sets if the slider is enabled.
     *
     * @param side the side of the slider
     * @param enabled the enabled state
     */
    void setSliderEnabled(int side, boolean enabled);

    /**
     * Sets the slider value.
     *
     * @param side the side of the slider
     * @param value the ambient value
     */
    void setSliderValue(int side, int value);

    /**
     * Sets the slider's minimum and maximum value.
     *
     * @param side the side of the slider
     * @param min the minimum ambient value
     * @param max the maximum ambient value
     */
    void setSliderRange(int side, int min, int max);

    /** Updates the UI according to current state. */
    void updateLayout();
}
+251 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settingslib.bluetooth;

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 android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.util.ArraySet;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.android.settingslib.utils.ThreadUtils;

import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;

import java.util.Map;
import java.util.Set;

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

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

    private final Set<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
    private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
    private CachedBluetoothDevice mCachedDevice;
    private boolean mShowUiWhenLocalDataExist = true;

    public AmbientVolumeUiController(@NonNull Context context,
            @NonNull LocalBluetoothManager bluetoothManager,
            @NonNull AmbientVolumeUi ambientLayout) {
        mContext = context;
        mProfileManager = bluetoothManager.getProfileManager();
        mEventManager = bluetoothManager.getEventManager();
        mAmbientLayout = ambientLayout;
        mVolumeController = new AmbientVolumeController(mProfileManager, this);
    }

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

    @Override
    public void onVolumeControlServiceConnected() {
        mCachedDevices.forEach(device -> mVolumeController.registerCallback(
                ThreadUtils.getBackgroundExecutor(), device.getDevice()));
    }

    @Override
    public void onAmbientChanged(@NonNull BluetoothDevice device, int gainSettings) {
    }

    @Override
    public void onMuteChanged(@NonNull BluetoothDevice device, int mute) {
    }

    @Override
    public void onCommandFailed(@NonNull BluetoothDevice device) {
    }

    @Override
    public void onProfileConnectionStateChanged(@NonNull CachedBluetoothDevice cachedDevice,
            int state, int bluetoothProfile) {
        if (bluetoothProfile == BluetoothProfile.VOLUME_CONTROL
                && state == BluetoothProfile.STATE_CONNECTED
                && mCachedDevices.contains(cachedDevice)) {
            // After VCP connected, AICS may not ready yet and still return invalid value, delay
            // a while to wait AICS ready as a workaround
            postDelayedOnMainThread(this::refresh, 1000L);
        }
    }

    @Override
    public void onDeviceAttributesChanged() {
        mCachedDevices.forEach(device -> {
            device.unregisterCallback(this);
            mVolumeController.unregisterCallback(device.getDevice());
        });
        postOnMainThread(()-> {
            loadDevice(mCachedDevice);
            ThreadUtils.postOnBackgroundThread(()-> {
                mCachedDevices.forEach(device -> {
                    device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
                    mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
                            device.getDevice());
                });
            });
        });
    }

    /**
     * Registers callbacks and listeners, this should be called when needs to start listening to
     * events.
     */
    public void start() {
        mEventManager.registerCallback(this);
        mCachedDevices.forEach(device -> {
            device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
            mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
                    device.getDevice());
        });
    }

    /**
     * Unregisters callbacks and listeners, this should be called when no longer needs to listen to
     * events.
     */
    public void stop() {
        mEventManager.unregisterCallback(this);
        mCachedDevices.forEach(device -> {
            device.unregisterCallback(this);
            mVolumeController.unregisterCallback(device.getDevice());
        });
    }

    /**
     * Loads all devices in the same set with {@code cachedDevice} and create corresponding sliders.
     *
     * <p>If the devices has valid ambient control points, the ambient volume UI will be visible.
     * @param cachedDevice the remote device
     */
    public void loadDevice(CachedBluetoothDevice cachedDevice) {
        mCachedDevice = cachedDevice;
        mSideToDeviceMap.clear();
        mCachedDevices.clear();
        boolean deviceSupportVcp =
                cachedDevice != null && cachedDevice.getProfiles().stream().anyMatch(
                        p -> p instanceof VolumeControlProfile);
        if (!deviceSupportVcp) {
            mAmbientLayout.setVisible(false);
            return;
        }

        // load devices in the same set
        if (VALID_SIDES.contains(cachedDevice.getDeviceSide())
                && cachedDevice.getBondState() == BOND_BONDED) {
            mSideToDeviceMap.put(cachedDevice.getDeviceSide(), cachedDevice.getDevice());
            mCachedDevices.add(cachedDevice);
        }
        for (CachedBluetoothDevice memberDevice : cachedDevice.getMemberDevice()) {
            if (VALID_SIDES.contains(memberDevice.getDeviceSide())
                    && memberDevice.getBondState() == BOND_BONDED) {
                mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice());
                mCachedDevices.add(memberDevice);
            }
        }

        mAmbientLayout.setExpandable(mSideToDeviceMap.size() >  1);
        mAmbientLayout.setupSliders(mSideToDeviceMap);
        refresh();
    }

    /** Refreshes the ambient volume UI. */
    public void refresh() {
        if (isAmbientControlAvailable()) {
            mAmbientLayout.setVisible(true);
            updateSliderUi();
        } else {
            mAmbientLayout.setVisible(false);
        }
    }

    /** Sets if the ambient volume UI should be visible when local ambient data exist. */
    public void setShowUiWhenLocalDataExist(boolean shouldShow) {
        mShowUiWhenLocalDataExist = shouldShow;
    }

    /** Updates the ambient sliders according to current state. */
    private void updateSliderUi() {
        boolean isAnySliderEnabled = false;
        for (Map.Entry<Integer, BluetoothDevice> entry : mSideToDeviceMap.entrySet()) {
            final int side = entry.getKey();
            final BluetoothDevice device = entry.getValue();
            final boolean enabled = isDeviceConnectedToVcp(device)
                    && mVolumeController.isAmbientControlAvailable(device);
            isAnySliderEnabled |= enabled;
            mAmbientLayout.setSliderEnabled(side, enabled);
        }
        mAmbientLayout.setSliderEnabled(SIDE_UNIFIED, isAnySliderEnabled);
        mAmbientLayout.updateLayout();
    }

    /** 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 remote ambient control points
            if (mVolumeController.isAmbientControlAvailable(device)) {
                return true;
            }
        }
        return false;
    }

    private boolean isDeviceConnectedToVcp(@Nullable BluetoothDevice device) {
        return device != null && device.isConnected()
                && mProfileManager.getVolumeControlProfile().getConnectionStatus(device)
                == BluetoothProfile.STATE_CONNECTED;
    }

    private void postOnMainThread(Runnable runnable) {
        mContext.getMainThreadHandler().post(runnable);
    }

    private void postDelayedOnMainThread(Runnable runnable, long delay) {
        mContext.getMainThreadHandler().postDelayed(runnable, delay);
    }

    private void showErrorToast(int stringResId) {
        Toast.makeText(mContext, stringResId, Toast.LENGTH_SHORT).show();
    }
}
+211 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settingslib.bluetooth;

import static android.bluetooth.BluetoothDevice.BOND_BONDED;

import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

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

import androidx.test.core.app.ApplicationProvider;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.mockito.stubbing.Answer;
import org.robolectric.RobolectricTestRunner;

import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;

/** Tests for {@link AmbientVolumeUiController}. */
@RunWith(RobolectricTestRunner.class)
public class AmbientVolumeUiControllerTest {

    @Rule
    public MockitoRule mockito = MockitoJUnit.rule();

    private static final String TEST_ADDRESS = "00:00:00:00:11";
    private static final String TEST_MEMBER_ADDRESS = "00:00:00:00:22";

    @Mock
    LocalBluetoothManager mBluetoothManager;
    @Mock
    LocalBluetoothProfileManager mProfileManager;
    @Mock
    BluetoothEventManager mEventManager;
    @Mock
    VolumeControlProfile mVolumeControlProfile;
    @Mock
    AmbientVolumeUi mAmbientLayout;
    @Mock
    private AmbientVolumeController mVolumeController;
    @Mock
    private CachedBluetoothDevice mCachedDevice;
    @Mock
    private CachedBluetoothDevice mCachedMemberDevice;
    @Mock
    private BluetoothDevice mDevice;
    @Mock
    private BluetoothDevice mMemberDevice;
    @Mock
    private Handler mTestHandler;

    @Spy
    private final Context mContext = ApplicationProvider.getApplicationContext();
    private AmbientVolumeUiController mController;

    @Before
    public void setUp() {
        when(mBluetoothManager.getProfileManager()).thenReturn(mProfileManager);
        when(mBluetoothManager.getEventManager()).thenReturn(mEventManager);

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

        when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile);
        when(mVolumeControlProfile.getConnectionStatus(mDevice)).thenReturn(
                BluetoothProfile.STATE_CONNECTED);
        when(mVolumeControlProfile.getConnectionStatus(mMemberDevice)).thenReturn(
                BluetoothProfile.STATE_CONNECTED);
        when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(true);
        when(mVolumeController.isAmbientControlAvailable(mMemberDevice)).thenReturn(true);

        when(mContext.getMainThreadHandler()).thenReturn(mTestHandler);
        Answer<Object> answer = invocationOnMock -> {
            invocationOnMock.getArgument(0, Runnable.class).run();
            return null;
        };
        when(mTestHandler.post(any(Runnable.class))).thenAnswer(answer);
        when(mTestHandler.postDelayed(any(Runnable.class), anyLong())).thenAnswer(answer);

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

    @Test
    public void loadDevice_deviceWithoutMember_controlNotExpandable() {
        prepareDevice(/* hasMember= */ false);

        mController.loadDevice(mCachedDevice);

        verify(mAmbientLayout).setExpandable(false);
    }

    @Test
    public void loadDevice_deviceWithMember_controlExpandable() {
        prepareDevice(/* hasMember= */ true);

        mController.loadDevice(mCachedDevice);

        verify(mAmbientLayout).setExpandable(true);
    }

    @Test
    public void loadDevice_deviceNotSupportVcp_ambientLayoutGone() {
        when(mCachedDevice.getProfiles()).thenReturn(List.of());

        mController.loadDevice(mCachedDevice);

        verify(mAmbientLayout).setVisible(false);
    }

    @Test
    public void loadDevice_ambientControlNotAvailable_ambientLayoutGone() {
        when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(false);
        when(mVolumeController.isAmbientControlAvailable(mMemberDevice)).thenReturn(false);

        mController.loadDevice(mCachedDevice);

        verify(mAmbientLayout).setVisible(false);
    }

    @Test
    public void loadDevice_supportVcpAndAmbientControlAvailable_ambientLayoutVisible() {
        when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
        when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(true);

        mController.loadDevice(mCachedDevice);

        verify(mAmbientLayout).setVisible(true);
    }

    @Test
    public void start_callbackRegistered() {
        mController.start();

        verify(mEventManager).registerCallback(mController);
        verify(mVolumeController).registerCallback(any(Executor.class), eq(mDevice));
        verify(mVolumeController).registerCallback(any(Executor.class), eq(mMemberDevice));
        verify(mCachedDevice).registerCallback(any(Executor.class),
                any(CachedBluetoothDevice.Callback.class));
        verify(mCachedMemberDevice).registerCallback(any(Executor.class),
                any(CachedBluetoothDevice.Callback.class));
    }

    @Test
    public void stop_callbackUnregistered() {
        mController.stop();

        verify(mEventManager).unregisterCallback(mController);
        verify(mVolumeController).unregisterCallback(mDevice);
        verify(mVolumeController).unregisterCallback(mMemberDevice);
        verify(mCachedDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
        verify(mCachedMemberDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
    }

    private void prepareDevice(boolean hasMember) {
        when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT);
        when(mCachedDevice.getDevice()).thenReturn(mDevice);
        when(mCachedDevice.getBondState()).thenReturn(BOND_BONDED);
        when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
        when(mDevice.getAddress()).thenReturn(TEST_ADDRESS);
        when(mDevice.getAnonymizedAddress()).thenReturn(TEST_ADDRESS);
        when(mDevice.isConnected()).thenReturn(true);
        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(mCachedMemberDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
            when(mMemberDevice.getAddress()).thenReturn(TEST_MEMBER_ADDRESS);
            when(mMemberDevice.getAnonymizedAddress()).thenReturn(TEST_MEMBER_ADDRESS);
            when(mMemberDevice.isConnected()).thenReturn(true);
        } else {
            when(mCachedDevice.getMemberDevice()).thenReturn(Set.of());
        }
    }
}
+221 −0

File added.

Preview size limit exceeded, changes collapsed.

+91 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading