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

Commit ada98fa1 authored by Wenyu Zhang's avatar Wenyu Zhang Committed by Android (Google) Code Review
Browse files

Merge "Add InputRouteManager and InputMediaDevice to support input routing" into main

parents f9a051da 7bfb54f7
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();
    }
}