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

Commit 4b683601 authored by Angela Wang's avatar Angela Wang
Browse files

The controller to handle remote ambient AICS control points

Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control
Bug: 357878944
Test: atest AmbientVolumeControllerTest
Change-Id: I02b842fc8ade3d1f2bfadfad2136459655e87156
parent 564c9301
Loading
Loading
Loading
Loading
+327 −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.HearingDeviceLocalDataManager.Data.INVALID_VOLUME;

import android.bluetooth.AudioInputControl;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.util.ArrayMap;
import android.util.Log;

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

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;

/**
 * AmbientVolumeController manages the {@link AudioInputControl}s of
 * {@link AudioInputControl#AUDIO_INPUT_TYPE_AMBIENT} on the remote device.
 */
public class AmbientVolumeController implements LocalBluetoothProfileManager.ServiceListener {

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

    private final LocalBluetoothProfileManager mProfileManager;
    private final VolumeControlProfile mVolumeControlProfile;
    private final Map<BluetoothDevice, List<AudioInputControl>> mDeviceAmbientControlsMap =
            new ArrayMap<>();
    private final Map<BluetoothDevice, AmbientCallback> mDeviceCallbackMap = new ArrayMap<>();
    final Map<BluetoothDevice, RemoteAmbientState> mDeviceAmbientStateMap =
            new ArrayMap<>();
    @Nullable
    private final AmbientVolumeControlCallback mCallback;

    public AmbientVolumeController(
            @NonNull LocalBluetoothProfileManager profileManager,
            @Nullable AmbientVolumeControlCallback callback) {
        mProfileManager = profileManager;
        mVolumeControlProfile = profileManager.getVolumeControlProfile();
        if (mVolumeControlProfile != null && !mVolumeControlProfile.isProfileReady()) {
            mProfileManager.addServiceListener(this);
        }
        mCallback = callback;
    }

    @Override
    public void onServiceConnected() {
        if (mVolumeControlProfile != null && mVolumeControlProfile.isProfileReady()) {
            mProfileManager.removeServiceListener(this);
            if (mCallback != null) {
                mCallback.onVolumeControlServiceConnected();
            }
        }
    }

    @Override
    public void onServiceDisconnected() {
        // Do nothing
    }

    /**
     * Registers the same {@link AmbientCallback} on all ambient control points of the remote
     * device. The {@link AmbientCallback} will pass the event to registered
     * {@link AmbientVolumeControlCallback} if exists.
     *
     * @param executor the executor to run the callback
     * @param device the remote device
     */
    public void registerCallback(@NonNull Executor executor, @NonNull BluetoothDevice device) {
        AmbientCallback ambientCallback = new AmbientCallback(device, mCallback);
        synchronized (mDeviceCallbackMap) {
            mDeviceCallbackMap.put(device, ambientCallback);
        }

        // register callback on all ambient input control points of this device
        List<AudioInputControl> controls = getAmbientControls(device);
        controls.forEach((control) -> {
            try {
                control.registerCallback(executor, ambientCallback);
            } catch (IllegalArgumentException e) {
                // The callback was already registered
                Log.i(TAG, "Skip registering the callback, " + e.getMessage());
            }
        });
    }

    /**
     * Unregisters the {@link AmbientCallback} on all ambient control points of the remote
     * device which is previously registered with {@link #registerCallback}.
     *
     * @param device the remote device
     */
    public void unregisterCallback(@NonNull BluetoothDevice device) {
        AmbientCallback ambientCallback;
        synchronized (mDeviceCallbackMap) {
            ambientCallback = mDeviceCallbackMap.remove(device);
        }
        if (ambientCallback == null) {
            // callback not found, no need to unregister
            return;
        }

        // unregister callback on all ambient input control points of this device
        List<AudioInputControl> controls = getAmbientControls(device);
        controls.forEach(control -> {
            try {
                control.unregisterCallback(ambientCallback);
            } catch (IllegalArgumentException e) {
                // The callback was never registered or was already unregistered
                Log.i(TAG, "Skip unregistering the callback, " + e.getMessage());
            }
        });
    }

    /**
     * Gets the gain setting max value from first ambient control point of the remote device.
     *
     * @param device the remote device
     */
    public int getAmbientMax(@NonNull BluetoothDevice device) {
        List<AudioInputControl> ambientControls = getAmbientControls(device);
        int value = INVALID_VOLUME;
        if (!ambientControls.isEmpty()) {
            value = ambientControls.getFirst().getGainSettingMax();
        }
        return value;
    }

    /**
     * Gets the gain setting min value from first ambient control point of the remote device.
     *
     * @param device the remote device
     */
    public int getAmbientMin(@NonNull BluetoothDevice device) {
        List<AudioInputControl> ambientControls = getAmbientControls(device);
        int value = INVALID_VOLUME;
        if (!ambientControls.isEmpty()) {
            value = ambientControls.getFirst().getGainSettingMin();
        }
        return value;
    }

    /**
     * Gets the latest values in {@link RemoteAmbientState}.
     *
     * @param device the remote device
     * @return the {@link RemoteAmbientState} represents current remote ambient control point state
     */
    @Nullable
    public RemoteAmbientState refreshAmbientState(@Nullable BluetoothDevice device) {
        if (device == null || !device.isConnected()) {
            return null;
        }
        int gainSetting = getAmbient(device);
        return new RemoteAmbientState(gainSetting);
    }

    /**
     * Gets the gain setting value from first ambient control point of the remote device and
     * stores it in cached {@link RemoteAmbientState}.
     *
     * When any audio input point receives {@link AmbientCallback#onGainSettingChanged(int)}
     * callback, only the changed value which is different from the value stored in the cached
     * state will be notified to the {@link AmbientVolumeControlCallback} of this controller.
     *
     * @param device the remote device
     */
    public int getAmbient(@NonNull BluetoothDevice device) {
        List<AudioInputControl> ambientControls = getAmbientControls(device);
        int value = INVALID_VOLUME;
        if (!ambientControls.isEmpty()) {
            synchronized (mDeviceAmbientStateMap) {
                value = ambientControls.getFirst().getGainSetting();
                RemoteAmbientState updatedState = new RemoteAmbientState(value);
                mDeviceAmbientStateMap.put(device, updatedState);
            }
        }
        return value;
    }

    /**
     * Sets the gain setting value to all ambient control points of the remote device.
     *
     * @param device the remote device
     * @param value the gain setting value to be updated
     */
    public void setAmbient(@NonNull BluetoothDevice device, int value) {
        if (DEBUG) {
            Log.d(TAG, "setAmbient, value:" + value + ", device:" + device);
        }
        List<AudioInputControl> ambientControls = getAmbientControls(device);
        if (!ambientControls.isEmpty()) {
            ambientControls.forEach(control -> control.setGainSetting(value));
        }
    }

    /**
     * Checks if there's any valid ambient control point exists on the remote device
     *
     * @param device the remote device
     */
    public boolean isAmbientControlAvailable(@NonNull BluetoothDevice device) {
        final boolean hasAmbientControlPoint = !getAmbientControls(device).isEmpty();
        final boolean connectedToVcp = mVolumeControlProfile.getConnectionStatus(device)
                == BluetoothProfile.STATE_CONNECTED;
        return hasAmbientControlPoint && connectedToVcp;
    }

    @NonNull
    private List<AudioInputControl> getAmbientControls(@NonNull BluetoothDevice device) {
        if (mVolumeControlProfile == null) {
            return Collections.emptyList();
        }
        synchronized (mDeviceAmbientControlsMap) {
            if (mDeviceAmbientControlsMap.containsKey(device)) {
                return mDeviceAmbientControlsMap.get(device);
            }
            List<AudioInputControl> ambientControls =
                    mVolumeControlProfile.getAudioInputControlServices(device).stream().filter(
                            this::isValidAmbientControl).toList();
            if (!ambientControls.isEmpty()) {
                mDeviceAmbientControlsMap.put(device, ambientControls);
            }
            return ambientControls;
        }
    }

    private boolean isValidAmbientControl(AudioInputControl control) {
        boolean isAmbientControl =
                control.getAudioInputType() == AudioInputControl.AUDIO_INPUT_TYPE_AMBIENT;
        boolean isManual = control.getGainMode() == AudioInputControl.GAIN_MODE_MANUAL
                || control.getGainMode() == AudioInputControl.GAIN_MODE_MANUAL_ONLY;
        boolean isActive =
                control.getAudioInputStatus() == AudioInputControl.AUDIO_INPUT_STATUS_ACTIVE;

        return isAmbientControl && isManual && isActive;
    }

    /**
     * Callback providing information about the status and received events of
     * {@link AmbientVolumeController}.
     */
    public interface AmbientVolumeControlCallback {

        /** This method is called when the Volume Control Service is connected */
        default void onVolumeControlServiceConnected() {
        }

        /**
         * This method is called when one of the remote device's ambient control point's gain
         * settings value is changed.
         *
         * @param device the remote device
         * @param gainSettings the new gain setting value
         */
        default void onAmbientChanged(@NonNull BluetoothDevice device, int gainSettings) {
        }

        /**
         * This method is called when any command to the remote device's ambient control point
         * is failed.
         *
         * @param device the remote device.
         */
        default void onCommandFailed(@NonNull BluetoothDevice device) {
        }
    }

    /**
     * A wrapper callback that will pass {@link AudioInputControl.AudioInputCallback} with extra
     * device information to {@link AmbientVolumeControlCallback}.
     */
    class AmbientCallback implements AudioInputControl.AudioInputCallback {

        private final BluetoothDevice mDevice;
        private final AmbientVolumeControlCallback mCallback;

        AmbientCallback(@NonNull BluetoothDevice device,
                @Nullable AmbientVolumeControlCallback callback) {
            mDevice = device;
            mCallback = callback;
        }

        @Override
        public void onGainSettingChanged(int gainSetting) {
            if (mCallback != null) {
                synchronized (mDeviceAmbientStateMap) {
                    RemoteAmbientState previousState = mDeviceAmbientStateMap.get(mDevice);
                    if (previousState.gainSetting != gainSetting) {
                        mCallback.onAmbientChanged(mDevice, gainSetting);
                    }
                }
            }
        }

        @Override
        public void onSetGainSettingFailed() {
            Log.w(TAG, "onSetGainSettingFailed, device=" + mDevice);
            if (mCallback != null) {
                mCallback.onCommandFailed(mDevice);
            }
        }
    }

    public record RemoteAmbientState(int gainSetting) {

    }
}
+260 −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.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.bluetooth.AudioInputControl;
import android.bluetooth.BluetoothDevice;
import android.content.Context;

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.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.RobolectricTestRunner;

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

/** Tests for {@link AmbientVolumeController}. */
@RunWith(RobolectricTestRunner.class)
public class AmbientVolumeControllerTest {

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

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

    @Mock
    private LocalBluetoothProfileManager mProfileManager;
    @Mock
    private VolumeControlProfile mVolumeControlProfile;
    @Mock
    private AmbientVolumeController.AmbientVolumeControlCallback mCallback;
    @Mock
    private BluetoothDevice mDevice;

    private final Context mContext = ApplicationProvider.getApplicationContext();
    private AmbientVolumeController mVolumeController;

    @Before
    public void setUp() {
        when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile);
        when(mDevice.getAddress()).thenReturn(TEST_ADDRESS);
        when(mDevice.isConnected()).thenReturn(true);
        mVolumeController = new AmbientVolumeController(mProfileManager, mCallback);
    }

    @Test
    public void onServiceConnected_notifyCallback() {
        when(mVolumeControlProfile.isProfileReady()).thenReturn(true);

        mVolumeController.onServiceConnected();

        verify(mCallback).onVolumeControlServiceConnected();
    }

    @Test
    public void isAmbientControlAvailable_validControls_assertTrue() {
        prepareValidAmbientControls();

        assertThat(mVolumeController.isAmbientControlAvailable(mDevice)).isTrue();
    }

    @Test
    public void isAmbientControlAvailable_streamingControls_assertFalse() {
        prepareStreamingControls();

        assertThat(mVolumeController.isAmbientControlAvailable(mDevice)).isFalse();
    }

    @Test
    public void isAmbientControlAvailable_automaticAmbientControls_assertFalse() {
        prepareAutomaticAmbientControls();

        assertThat(mVolumeController.isAmbientControlAvailable(mDevice)).isFalse();
    }

    @Test
    public void isAmbientControlAvailable_inactiveAmbientControls_assertFalse() {
        prepareInactiveAmbientControls();

        assertThat(mVolumeController.isAmbientControlAvailable(mDevice)).isFalse();
    }

    @Test
    public void registerCallback_verifyRegisterOnAllControls() {
        List<AudioInputControl> controls = prepareValidAmbientControls();

        mVolumeController.registerCallback(mContext.getMainExecutor(), mDevice);

        for (AudioInputControl control : controls) {
            verify(control).registerCallback(any(Executor.class), any());
        }
    }

    @Test
    public void unregisterCallback_verifyUnregisterOnAllControls() {
        List<AudioInputControl> controls = prepareValidAmbientControls();

        mVolumeController.registerCallback(mContext.getMainExecutor(), mDevice);
        mVolumeController.unregisterCallback(mDevice);

        for (AudioInputControl control : controls) {
            verify(control).unregisterCallback(any());
        }
    }

    @Test
    public void getAmbientMax_verifyGetOnFirstControl() {
        List<AudioInputControl> controls = prepareValidAmbientControls();

        mVolumeController.getAmbientMax(mDevice);

        verify(controls.getFirst()).getGainSettingMax();
    }

    @Test
    public void getAmbientMin_verifyGetOnFirstControl() {
        List<AudioInputControl> controls = prepareValidAmbientControls();

        mVolumeController.getAmbientMin(mDevice);

        verify(controls.getFirst()).getGainSettingMin();
    }

    @Test
    public void getAmbient_verifyGetOnFirstControl() {
        List<AudioInputControl> controls = prepareValidAmbientControls();

        mVolumeController.getAmbient(mDevice);

        verify(controls.getFirst()).getGainSetting();
    }

    @Test
    public void setAmbient_verifySetOnAllControls() {
        List<AudioInputControl> controls = prepareValidAmbientControls();

        mVolumeController.setAmbient(mDevice, 10);

        for (AudioInputControl control : controls) {
            verify(control).setGainSetting(10);
        }
    }

    @Test
    public void ambientCallback_onGainSettingChanged_verifyCallbackIsCalledWhenStateChange() {
        AmbientVolumeController.AmbientCallback ambientCallback =
                mVolumeController.new AmbientCallback(mDevice, mCallback);
        final int testAmbient = 10;
        List<AudioInputControl> controls = prepareValidAmbientControls();
        when(controls.getFirst().getGainSetting()).thenReturn(testAmbient);

        mVolumeController.refreshAmbientState(mDevice);
        ambientCallback.onGainSettingChanged(testAmbient);
        verify(mCallback, never()).onAmbientChanged(mDevice, testAmbient);

        final int updatedTestAmbient = 20;
        ambientCallback.onGainSettingChanged(updatedTestAmbient);
        verify(mCallback).onAmbientChanged(mDevice, updatedTestAmbient);
    }


    @Test
    public void ambientCallback_onSetAmbientFailed_verifyCallbackIsCalled() {
        AmbientVolumeController.AmbientCallback ambientCallback =
                mVolumeController.new AmbientCallback(mDevice, mCallback);

        ambientCallback.onSetGainSettingFailed();

        verify(mCallback).onCommandFailed(mDevice);
    }

    private List<AudioInputControl> prepareValidAmbientControls() {
        List<AudioInputControl> controls = new ArrayList<>();
        final int controlsCount = 2;
        for (int i = 0; i < controlsCount; i++) {
            controls.add(prepareAudioInputControl(
                    AudioInputControl.AUDIO_INPUT_TYPE_AMBIENT,
                    AudioInputControl.GAIN_MODE_MANUAL,
                    AudioInputControl.AUDIO_INPUT_STATUS_ACTIVE));
        }
        when(mVolumeControlProfile.getAudioInputControlServices(mDevice)).thenReturn(controls);
        return controls;
    }

    private List<AudioInputControl> prepareStreamingControls() {
        List<AudioInputControl> controls = new ArrayList<>();
        final int controlsCount = 2;
        for (int i = 0; i < controlsCount; i++) {
            controls.add(prepareAudioInputControl(
                    AudioInputControl.AUDIO_INPUT_TYPE_STREAMING,
                    AudioInputControl.GAIN_MODE_MANUAL,
                    AudioInputControl.AUDIO_INPUT_STATUS_ACTIVE));
        }
        when(mVolumeControlProfile.getAudioInputControlServices(mDevice)).thenReturn(controls);
        return controls;
    }

    private List<AudioInputControl> prepareAutomaticAmbientControls() {
        List<AudioInputControl> controls = new ArrayList<>();
        final int controlsCount = 2;
        for (int i = 0; i < controlsCount; i++) {
            controls.add(prepareAudioInputControl(
                    AudioInputControl.AUDIO_INPUT_TYPE_STREAMING,
                    AudioInputControl.GAIN_MODE_AUTOMATIC,
                    AudioInputControl.AUDIO_INPUT_STATUS_ACTIVE));
        }
        when(mVolumeControlProfile.getAudioInputControlServices(mDevice)).thenReturn(controls);
        return controls;
    }

    private List<AudioInputControl> prepareInactiveAmbientControls() {
        List<AudioInputControl> controls = new ArrayList<>();
        final int controlsCount = 2;
        for (int i = 0; i < controlsCount; i++) {
            controls.add(prepareAudioInputControl(
                    AudioInputControl.AUDIO_INPUT_TYPE_STREAMING,
                    AudioInputControl.GAIN_MODE_AUTOMATIC,
                    AudioInputControl.AUDIO_INPUT_STATUS_INACTIVE));
        }
        when(mVolumeControlProfile.getAudioInputControlServices(mDevice)).thenReturn(controls);
        return controls;
    }

    private AudioInputControl prepareAudioInputControl(int type, int mode, int status) {
        AudioInputControl control = mock(AudioInputControl.class);
        when(control.getAudioInputType()).thenReturn(type);
        when(control.getGainMode()).thenReturn(mode);
        when(control.getAudioInputStatus()).thenReturn(status);
        return control;
    }
}