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

Commit 3b83ce8c authored by Angela Wang's avatar Angela Wang Committed by Android (Google) Code Review
Browse files

Merge changes from topic "ha-ui-controller" into main

* changes:
  [Ambient Volume] load data into hearing device dialog
  [Ambient Volume] Setup the UI elements in hearing device dialog
  Add flush() method to flush the data whenever needed
parents f5a19955 48594a2d
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>
+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();
}
+527 −0

File added.

Preview size limit exceeded, changes collapsed.

+12 −9
Original line number Diff line number Diff line
@@ -148,6 +148,14 @@ public class HearingDeviceLocalDataManager {
        }
    }

    /** Flushes the data into Settings . */
    public synchronized void flush() {
        if (!mIsStarted) {
            return;
        }
        putAmbientVolumeSettings();
    }

    /**
     * Puts the local data of the corresponding hearing device.
     *
@@ -274,9 +282,6 @@ public class HearingDeviceLocalDataManager {
            notifyIfDataChanged(mAddrToDataMap, updatedAddrToDataMap);
            mAddrToDataMap.clear();
            mAddrToDataMap.putAll(updatedAddrToDataMap);
            if (DEBUG) {
                Log.v(TAG, "getLocalDataFromSettings, " + mAddrToDataMap + ", manager: " + this);
            }
        }
    }

@@ -287,12 +292,10 @@ public class HearingDeviceLocalDataManager {
                builder.append(KEY_ADDR).append("=").append(entry.getKey());
                builder.append(entry.getValue().toSettingsFormat()).append(";");
            }
            if (DEBUG) {
                Log.v(TAG, "putAmbientVolumeSettings, " + builder + ", manager: " + this);
            }
            ThreadUtils.postOnBackgroundThread(() -> {
                Settings.Global.putStringForUser(mContext.getContentResolver(),
                    LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(),
                    UserHandle.USER_SYSTEM);
                        LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(), UserHandle.USER_SYSTEM);
            });
        }
    }

+315 −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.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;
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.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;

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 HearingDeviceLocalDataManager mLocalDataManager;
    @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 = spy(new AmbientVolumeUiController(mContext, mBluetoothManager,
                mAmbientLayout, mVolumeController, mLocalDataManager));

        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(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(
                new HearingDeviceLocalDataManager.Data.Builder().build());

        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(mController);
        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(mLocalDataManager).start();
        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(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);
        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());
        }
    }

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