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

Commit 7bfb54f7 authored by wenyu zhang's avatar wenyu zhang
Browse files

Add InputRouteManager and InputMediaDevice to support input routing

InputRouteManager interacts with AudioManager to get/observe
available input routes.

Change-Id: I5503aa3f7a51420c0af87ca225c03818e8451056
Bug: b/355684672, b/357122624
Test: atest MediaOutputControllerTest, LocalMediaManagerTest
      atest InputRouteManagerTest, InputMediaDeviceTest
Flag: com.android.media.flags.enable_audio_input_device_routing_and_volume_control
parent ac60bc85
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -1411,6 +1411,8 @@
    <string name="media_transfer_this_device_name_tablet">This tablet</string>
    <!-- Name of the default media output of the TV. [CHAR LIMIT=30] -->
    <string name="media_transfer_this_device_name_tv">@string/tv_media_transfer_default</string>
    <!-- Name of the internal mic. [CHAR LIMIT=30] -->
    <string name="media_transfer_internal_mic">Microphone (internal)</string>
    <!-- Name of the dock device. [CHAR LIMIT=30] -->
    <string name="media_transfer_dock_speaker_device_name">Dock speaker</string>
    <!-- Default name of the external device. [CHAR LIMIT=30] -->
@@ -1637,6 +1639,12 @@
    <!-- Name of the 3.5mm and usb audio device. [CHAR LIMIT=50] -->
    <string name="media_transfer_wired_usb_device_name">Wired headphone</string>

    <!-- Name of the 3.5mm audio device mic. [CHAR LIMIT=50] -->
    <string name="media_transfer_wired_device_mic_name">Mic jack</string>

    <!-- Name of the usb audio device mic. [CHAR LIMIT=50] -->
    <string name="media_transfer_usb_device_mic_name">USB mic</string>

    <!-- Label for Wifi hotspot switch on. Toggles hotspot on [CHAR LIMIT=30] -->
    <string name="wifi_hotspot_switch_on_text">On</string>
    <!-- Label for Wifi hotspot switch off. Toggles hotspot off [CHAR LIMIT=30] -->
+161 −0
Original line number Diff line number Diff line
/*
 * Copyright 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.media;

import static android.media.AudioDeviceInfo.TYPE_BUILTIN_MIC;
import static android.media.AudioDeviceInfo.TYPE_USB_ACCESSORY;
import static android.media.AudioDeviceInfo.TYPE_USB_DEVICE;
import static android.media.AudioDeviceInfo.TYPE_USB_HEADSET;
import static android.media.AudioDeviceInfo.TYPE_WIRED_HEADSET;

import static com.android.settingslib.media.MediaDevice.SelectionBehavior.SELECTION_BEHAVIOR_TRANSFER;

import android.content.Context;
import android.graphics.drawable.Drawable;
import android.media.AudioDeviceInfo.AudioDeviceType;

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

import com.android.settingslib.R;

/** {@link MediaDevice} implementation that represents an input device. */
public class InputMediaDevice extends MediaDevice {

    private static final String TAG = "InputMediaDevice";

    private final String mId;

    private final @AudioDeviceType int mAudioDeviceInfoType;

    private final int mMaxVolume;

    private final int mCurrentVolume;

    private final boolean mIsVolumeFixed;

    private InputMediaDevice(
            @NonNull Context context,
            @NonNull String id,
            @AudioDeviceType int audioDeviceInfoType,
            int maxVolume,
            int currentVolume,
            boolean isVolumeFixed) {
        super(context, /* info= */ null, /* item= */ null);
        mId = id;
        mAudioDeviceInfoType = audioDeviceInfoType;
        mMaxVolume = maxVolume;
        mCurrentVolume = currentVolume;
        mIsVolumeFixed = isVolumeFixed;
        initDeviceRecord();
    }

    @Nullable
    public static InputMediaDevice create(
            @NonNull Context context,
            @NonNull String id,
            @AudioDeviceType int audioDeviceInfoType,
            int maxVolume,
            int currentVolume,
            boolean isVolumeFixed) {
        if (!isSupportedInputDevice(audioDeviceInfoType)) {
            return null;
        }

        return new InputMediaDevice(
                context, id, audioDeviceInfoType, maxVolume, currentVolume, isVolumeFixed);
    }

    public static boolean isSupportedInputDevice(@AudioDeviceType int audioDeviceInfoType) {
        return switch (audioDeviceInfoType) {
            case TYPE_BUILTIN_MIC,
                            TYPE_WIRED_HEADSET,
                            TYPE_USB_DEVICE,
                            TYPE_USB_HEADSET,
                            TYPE_USB_ACCESSORY ->
                    true;
            default -> false;
        };
    }

    @Override
    public @NonNull String getName() {
        CharSequence name =
                switch (mAudioDeviceInfoType) {
                    case TYPE_WIRED_HEADSET ->
                            mContext.getString(R.string.media_transfer_wired_device_mic_name);
                    case TYPE_USB_DEVICE, TYPE_USB_HEADSET, TYPE_USB_ACCESSORY ->
                            mContext.getString(R.string.media_transfer_usb_device_mic_name);
                    default -> mContext.getString(R.string.media_transfer_internal_mic);
                };
        return name.toString();
    }

    @Override
    public @SelectionBehavior int getSelectionBehavior() {
        // We don't allow apps to override the selection behavior of system routes.
        return SELECTION_BEHAVIOR_TRANSFER;
    }

    @Override
    public @NonNull String getSummary() {
        return "";
    }

    @Override
    public @Nullable Drawable getIcon() {
        return getIconWithoutBackground();
    }

    @Override
    public @Nullable Drawable getIconWithoutBackground() {
        return mContext.getDrawable(getDrawableResId());
    }

    @VisibleForTesting
    int getDrawableResId() {
        // TODO(b/357122624): check with UX to obtain the icon for desktop devices.
        return R.drawable.ic_media_tablet;
    }

    @Override
    public @NonNull String getId() {
        return mId;
    }

    @Override
    public boolean isConnected() {
        // Indicating if the device is connected and thus showing the status of STATE_CONNECTED.
        // Upon creation, this device is already connected.
        return true;
    }

    @Override
    public int getMaxVolume() {
        return mMaxVolume;
    }

    @Override
    public int getCurrentVolume() {
        return mCurrentVolume;
    }

    @Override
    public boolean isVolumeFixed() {
        return mIsVolumeFixed;
    }
}
+126 −0
Original line number Diff line number Diff line
/*
 * Copyright 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.media;

import android.content.Context;
import android.media.AudioDeviceCallback;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.Handler;

import androidx.annotation.NonNull;

import com.android.internal.annotations.VisibleForTesting;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/** Provides functionalities to get/observe input routes, control input routing and volume gain. */
public final class InputRouteManager {

    private static final String TAG = "InputRouteManager";

    private final Context mContext;

    private final AudioManager mAudioManager;

    @VisibleForTesting final List<MediaDevice> mInputMediaDevices = new CopyOnWriteArrayList<>();

    private final Collection<InputDeviceCallback> mCallbacks = new CopyOnWriteArrayList<>();

    @VisibleForTesting
    final AudioDeviceCallback mAudioDeviceCallback =
            new AudioDeviceCallback() {
                @Override
                public void onAudioDevicesAdded(@NonNull AudioDeviceInfo[] addedDevices) {
                    dispatchInputDeviceListUpdate();
                }

                @Override
                public void onAudioDevicesRemoved(@NonNull AudioDeviceInfo[] removedDevices) {
                    dispatchInputDeviceListUpdate();
                }
            };

    /* package */ InputRouteManager(@NonNull Context context, @NonNull AudioManager audioManager) {
        mContext = context;
        mAudioManager = audioManager;
        Handler handler = new Handler(context.getMainLooper());

        mAudioManager.registerAudioDeviceCallback(mAudioDeviceCallback, handler);
    }

    public void registerCallback(@NonNull InputDeviceCallback callback) {
        if (!mCallbacks.contains(callback)) {
            mCallbacks.add(callback);
            dispatchInputDeviceListUpdate();
        }
    }

    public void unregisterCallback(@NonNull InputDeviceCallback callback) {
        mCallbacks.remove(callback);
    }

    private void dispatchInputDeviceListUpdate() {
        // TODO (b/360175574): Get selected input device.

        // Get all input devices.
        AudioDeviceInfo[] audioDeviceInfos =
                mAudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS);
        mInputMediaDevices.clear();
        for (AudioDeviceInfo info : audioDeviceInfos) {
            MediaDevice mediaDevice =
                    InputMediaDevice.create(
                            mContext,
                            String.valueOf(info.getId()),
                            info.getType(),
                            getMaxInputGain(),
                            getCurrentInputGain(),
                            isInputGainFixed());
            if (mediaDevice != null) {
                mInputMediaDevices.add(mediaDevice);
            }
        }

        final List<MediaDevice> inputMediaDevices = new ArrayList<>(mInputMediaDevices);
        for (InputDeviceCallback callback : mCallbacks) {
            callback.onInputDeviceListUpdated(inputMediaDevices);
        }
    }

    public int getMaxInputGain() {
        // TODO (b/357123335): use real input gain implementation.
        // Using 15 for now since it matches the max index for output.
        return 15;
    }

    public int getCurrentInputGain() {
        // TODO (b/357123335): use real input gain implementation.
        return 8;
    }

    public boolean isInputGainFixed() {
        // TODO (b/357123335): use real input gain implementation.
        return true;
    }

    /** Callback for listening to input device changes. */
    public interface InputDeviceCallback {
        void onInputDeviceListUpdated(@NonNull List<MediaDevice> devices);
    }
}
+114 −0
Original line number Diff line number Diff line
/*
 * Copyright 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.media;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.media.AudioDeviceInfo;
import android.platform.test.flag.junit.SetFlagsRule;

import com.android.settingslib.R;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;

@RunWith(RobolectricTestRunner.class)
public class InputMediaDeviceTest {

    private final int BUILTIN_MIC_ID = 1;
    private final int WIRED_HEADSET_ID = 2;
    private final int USB_HEADSET_ID = 3;
    private final int MAX_VOLUME = 1;
    private final int CURRENT_VOLUME = 0;
    private final boolean IS_VOLUME_FIXED = true;

    @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();

    private Context mContext;

    @Before
    public void setUp() {
        mContext = RuntimeEnvironment.application;
    }

    @Test
    public void getDrawableResId_returnCorrectResId() {
        InputMediaDevice builtinMediaDevice =
                InputMediaDevice.create(
                        mContext,
                        String.valueOf(BUILTIN_MIC_ID),
                        AudioDeviceInfo.TYPE_BUILTIN_MIC,
                        MAX_VOLUME,
                        CURRENT_VOLUME,
                        IS_VOLUME_FIXED);
        assertThat(builtinMediaDevice).isNotNull();
        assertThat(builtinMediaDevice.getDrawableResId()).isEqualTo(R.drawable.ic_media_tablet);
    }

    @Test
    public void getName_returnCorrectName_builtinMic() {
        InputMediaDevice builtinMediaDevice =
                InputMediaDevice.create(
                        mContext,
                        String.valueOf(BUILTIN_MIC_ID),
                        AudioDeviceInfo.TYPE_BUILTIN_MIC,
                        MAX_VOLUME,
                        CURRENT_VOLUME,
                        IS_VOLUME_FIXED);
        assertThat(builtinMediaDevice).isNotNull();
        assertThat(builtinMediaDevice.getName())
                .isEqualTo(mContext.getString(R.string.media_transfer_internal_mic));
    }

    @Test
    public void getName_returnCorrectName_wiredHeadset() {
        InputMediaDevice wiredMediaDevice =
                InputMediaDevice.create(
                        mContext,
                        String.valueOf(WIRED_HEADSET_ID),
                        AudioDeviceInfo.TYPE_WIRED_HEADSET,
                        MAX_VOLUME,
                        CURRENT_VOLUME,
                        IS_VOLUME_FIXED);
        assertThat(wiredMediaDevice).isNotNull();
        assertThat(wiredMediaDevice.getName())
                .isEqualTo(mContext.getString(R.string.media_transfer_wired_device_mic_name));
    }

    @Test
    public void getName_returnCorrectName_usbHeadset() {
        InputMediaDevice usbMediaDevice =
                InputMediaDevice.create(
                        mContext,
                        String.valueOf(USB_HEADSET_ID),
                        AudioDeviceInfo.TYPE_USB_HEADSET,
                        MAX_VOLUME,
                        CURRENT_VOLUME,
                        IS_VOLUME_FIXED);
        assertThat(usbMediaDevice).isNotNull();
        assertThat(usbMediaDevice.getName())
                .isEqualTo(mContext.getString(R.string.media_transfer_usb_device_mic_name));
    }
}
+140 −0
Original line number Diff line number Diff line
/*
 * Copyright 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.media;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;

import com.android.settingslib.testutils.shadow.ShadowRouter2Manager;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;

@RunWith(RobolectricTestRunner.class)
@Config(shadows = {ShadowRouter2Manager.class})
public class InputRouteManagerTest {
    private static final int BUILTIN_MIC_ID = 1;
    private static final int INPUT_WIRED_HEADSET_ID = 2;
    private static final int INPUT_USB_DEVICE_ID = 3;
    private static final int INPUT_USB_HEADSET_ID = 4;
    private static final int INPUT_USB_ACCESSORY_ID = 5;

    private final Context mContext = spy(RuntimeEnvironment.application);
    private InputRouteManager mInputRouteManager;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);

        final AudioManager audioManager = mock(AudioManager.class);
        mInputRouteManager = new InputRouteManager(mContext, audioManager);
    }

    @Test
    public void onAudioDevicesAdded_shouldUpdateInputMediaDevice() {
        final AudioDeviceInfo info1 = mock(AudioDeviceInfo.class);
        when(info1.getType()).thenReturn(AudioDeviceInfo.TYPE_BUILTIN_MIC);
        when(info1.getId()).thenReturn(BUILTIN_MIC_ID);

        final AudioDeviceInfo info2 = mock(AudioDeviceInfo.class);
        when(info2.getType()).thenReturn(AudioDeviceInfo.TYPE_WIRED_HEADSET);
        when(info2.getId()).thenReturn(INPUT_WIRED_HEADSET_ID);

        final AudioDeviceInfo info3 = mock(AudioDeviceInfo.class);
        when(info3.getType()).thenReturn(AudioDeviceInfo.TYPE_USB_DEVICE);
        when(info3.getId()).thenReturn(INPUT_USB_DEVICE_ID);

        final AudioDeviceInfo info4 = mock(AudioDeviceInfo.class);
        when(info4.getType()).thenReturn(AudioDeviceInfo.TYPE_USB_HEADSET);
        when(info4.getId()).thenReturn(INPUT_USB_HEADSET_ID);

        final AudioDeviceInfo info5 = mock(AudioDeviceInfo.class);
        when(info5.getType()).thenReturn(AudioDeviceInfo.TYPE_USB_ACCESSORY);
        when(info5.getId()).thenReturn(INPUT_USB_ACCESSORY_ID);

        final AudioDeviceInfo unsupportedInfo = mock(AudioDeviceInfo.class);
        when(unsupportedInfo.getType()).thenReturn(AudioDeviceInfo.TYPE_HDMI);

        final AudioManager audioManager = mock(AudioManager.class);
        AudioDeviceInfo[] devices = {info1, info2, info3, info4, info5, unsupportedInfo};
        when(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).thenReturn(devices);

        InputRouteManager inputRouteManager = new InputRouteManager(mContext, audioManager);

        assertThat(inputRouteManager.mInputMediaDevices).isEmpty();

        inputRouteManager.mAudioDeviceCallback.onAudioDevicesAdded(devices);

        // The unsupported info should be filtered out.
        assertThat(inputRouteManager.mInputMediaDevices).hasSize(devices.length - 1);
        assertThat(inputRouteManager.mInputMediaDevices.get(0).getId())
                .isEqualTo(String.valueOf(BUILTIN_MIC_ID));
        assertThat(inputRouteManager.mInputMediaDevices.get(1).getId())
                .isEqualTo(String.valueOf(INPUT_WIRED_HEADSET_ID));
        assertThat(inputRouteManager.mInputMediaDevices.get(2).getId())
                .isEqualTo(String.valueOf(INPUT_USB_DEVICE_ID));
        assertThat(inputRouteManager.mInputMediaDevices.get(3).getId())
                .isEqualTo(String.valueOf(INPUT_USB_HEADSET_ID));
        assertThat(inputRouteManager.mInputMediaDevices.get(4).getId())
                .isEqualTo(String.valueOf(INPUT_USB_ACCESSORY_ID));
    }

    @Test
    public void onAudioDevicesRemoved_shouldUpdateInputMediaDevice() {
        final AudioManager audioManager = mock(AudioManager.class);
        when(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS))
                .thenReturn(new AudioDeviceInfo[] {});

        InputRouteManager inputRouteManager = new InputRouteManager(mContext, audioManager);

        final MediaDevice device = mock(MediaDevice.class);
        inputRouteManager.mInputMediaDevices.add(device);

        final AudioDeviceInfo info = mock(AudioDeviceInfo.class);
        when(info.getType()).thenReturn(AudioDeviceInfo.TYPE_WIRED_HEADSET);
        inputRouteManager.mAudioDeviceCallback.onAudioDevicesRemoved(new AudioDeviceInfo[] {info});

        assertThat(inputRouteManager.mInputMediaDevices).isEmpty();
    }

    @Test
    public void getMaxInputGain_returnMaxInputGain() {
        assertThat(mInputRouteManager.getMaxInputGain()).isEqualTo(15);
    }

    @Test
    public void getCurrentInputGain_returnCurrentInputGain() {
        assertThat(mInputRouteManager.getCurrentInputGain()).isEqualTo(8);
    }

    @Test
    public void isInputGainFixed() {
        assertThat(mInputRouteManager.isInputGainFixed()).isTrue();
    }
}