chromium/media/base/android/java/src/org/chromium/media/AudioDeviceListener.java

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.media;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothStatusCodes;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.hardware.usb.UsbConstants;
import android.hardware.usb.UsbDevice;
import android.hardware.usb.UsbInterface;
import android.hardware.usb.UsbManager;
import android.os.Build;

import androidx.annotation.IntDef;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordHistogram;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Map;

class AudioDeviceListener {
    private static final boolean DEBUG = false;

    private static final String TAG = "media";

    private static final String CONNECTION_HISTOGRAM_PREFIX = "Media.AudioDeviceConnectionStatus.";

    // Common enum for recording audio device connection status.
    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @IntDef({
        ConnectionStatus.DISCONNECTED,
        ConnectionStatus.CONNECTING,
        ConnectionStatus.CONNECTED,
        ConnectionStatus.DISCONNECTING,
        ConnectionStatus.MAX_VALUE
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface ConnectionStatus {
        int DISCONNECTED = 0;
        int CONNECTING = 1;
        int CONNECTED = 2;
        int DISCONNECTING = 3;
        int MAX_VALUE = DISCONNECTING + 1;
    }

    // Enabled during initialization if BLUETOOTH permission is granted.
    private boolean mHasBluetoothPermission;

    // True if the current device supports Bluetooth LE Audio.
    private boolean mIsBluetoothLeAudioSupported;

    // Broadcast receiver for wired headset intent broadcasts.
    private BroadcastReceiver mWiredHeadsetReceiver;

    // Broadcast receiver for Bluetooth headset intent broadcasts.
    // Utilized to detect changes in Bluetooth headset availability.
    private BroadcastReceiver mBluetoothHeadsetReceiver;

    // The UsbManager of this system.
    private final UsbManager mUsbManager;

    // Broadcast receiver for USB audio devices intent broadcasts.
    // Utilized to detect if a USB device is attached or detached.
    private BroadcastReceiver mUsbAudioReceiver;

    private final AudioDeviceSelector.Devices mDeviceStates;

    public AudioDeviceListener(AudioDeviceSelector.Devices devices) {
        mUsbManager =
                (UsbManager)
                        ContextUtils.getApplicationContext().getSystemService(Context.USB_SERVICE);
        mDeviceStates = devices;
    }

    public void init(boolean hasBluetoothPermission) {
        // Initialize audio device list with things we know is always available.
        mDeviceStates.setDeviceExistence(AudioDeviceSelector.Devices.ID_EARPIECE, hasEarpiece());
        mDeviceStates.setDeviceExistence(AudioDeviceSelector.Devices.ID_USB_AUDIO, hasUsbAudio());
        mDeviceStates.setDeviceExistence(AudioDeviceSelector.Devices.ID_SPEAKERPHONE, true);

        mHasBluetoothPermission = hasBluetoothPermission;
        BluetoothAdapter adapter = getBluetoothAdapter();
        mIsBluetoothLeAudioSupported = isLeAudioSupported(adapter);

        // Register receivers for broadcasting intents related to Bluetooth device
        // and Bluetooth SCO notifications. Requires BLUETOOTH permission.
        registerBluetoothIntentsIfNeeded(adapter);

        // Register receiver for broadcasting intents related to adding/
        // removing a wired headset (Intent.ACTION_HEADSET_PLUG).
        registerForWiredHeadsetIntentBroadcast();

        // Register receiver for broadcasting intents related to adding/removing a
        // USB audio device (ACTION_USB_DEVICE_ATTACHED/DETACHED);
        registerForUsbAudioIntentBroadcast();
    }

    public void close() {
        unregisterForWiredHeadsetIntentBroadcast();
        unregisterBluetoothIntentsIfNeeded();
        unregisterForUsbAudioIntentBroadcast();
    }

    /**
     * Register for BT intents if we have the BLUETOOTH permission. Also extends the list of
     * available devices with a BT device if one exists.
     */
    private void registerBluetoothIntentsIfNeeded(BluetoothAdapter adapter) {
        // Add a Bluetooth headset to the list of available devices if a BT
        // headset is detected and if we have the BLUETOOTH permission.
        // We must do this initial check using a dedicated method since the
        // broadcasted intent BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED
        // is not sticky and will only be received if a BT headset is connected
        // after this method has been called.
        if (!mHasBluetoothPermission) {
            Log.w(TAG, "registerBluetoothIntentsIfNeeded: Requires BLUETOOTH permission");
            return;
        }
        mDeviceStates.setDeviceExistence(
                AudioDeviceSelector.Devices.ID_BLUETOOTH_HEADSET, hasBluetoothHeadset(adapter));

        // Register receivers for broadcast intents related to changes in
        // Bluetooth headset availability.
        registerForBluetoothHeadsetIntentBroadcast();
    }

    /** Unregister for BT intents if a registration has been made. */
    private void unregisterBluetoothIntentsIfNeeded() {
        // No need to unregister if we don't have BT permissions.
        if (!mHasBluetoothPermission) return;

        ContextUtils.getApplicationContext().unregisterReceiver(mBluetoothHeadsetReceiver);
        mBluetoothHeadsetReceiver = null;
    }

    private BluetoothAdapter getBluetoothAdapter() {
        if (!mHasBluetoothPermission) {
            Log.w(TAG, "getBluetoothAdapter() requires BLUETOOTH permission");
            return null;
        }

        BluetoothManager btManager =
                (BluetoothManager)
                        ContextUtils.getApplicationContext()
                                .getSystemService(Context.BLUETOOTH_SERVICE);

        BluetoothAdapter adapter = btManager.getAdapter();

        if (adapter == null) {
            Log.w(TAG, "Couldn't get BluetoothAdapter. Bluetooth not supported on this device");
        }

        return adapter;
    }

    /** Returns whether the current device supports Bluetooth LE Audio. */
    public boolean isLeAudioSupported(BluetoothAdapter adapter) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
            // isLeAudioSupported() requires API Level 33.
            return false;
        }

        if (adapter == null) {
            // Bluetooth not supported on this platform.
            return false;
        }

        return adapter.isLeAudioSupported() == BluetoothStatusCodes.FEATURE_SUPPORTED;
    }

    /**
     * Gets the current Bluetooth headset state.
     * android.bluetooth.BluetoothAdapter.getProfileConnectionState() requires the BLUETOOTH
     * permission.
     */
    private boolean hasBluetoothHeadset(BluetoothAdapter btAdapter) {
        if (btAdapter == null) {
            // Bluetooth not supported on this platform.
            return false;
        }

        boolean btClassicHeadsetConnected =
                btAdapter.getProfileConnectionState(android.bluetooth.BluetoothProfile.HEADSET)
                        == android.bluetooth.BluetoothAdapter.STATE_CONNECTED;

        boolean btLeHeadsetConnected = false;
        if (mIsBluetoothLeAudioSupported) {
            btLeHeadsetConnected =
                    btAdapter.getProfileConnectionState(android.bluetooth.BluetoothProfile.LE_AUDIO)
                            == android.bluetooth.BluetoothAdapter.STATE_CONNECTED;
        }

        // Ensure that Bluetooth is enabled and that a device which supports the
        // headset and handsfree profile is connected.
        // TODO(henrika): it is possible that btAdapter.isEnabled() is
        // redundant. It might be sufficient to only check the profile state.
        return btAdapter.isEnabled() && (btClassicHeadsetConnected || btLeHeadsetConnected);
    }

    /**
     * Get the current USB audio device state. Android detects a compatible USB digital audio
     * peripheral and automatically routes audio playback and capture appropriately on Android5.0
     * and higher in the order of wired headset first, then USB audio device and earpiece at last.
     */
    private boolean hasUsbAudio() {
        // UsbManager fails internally with NullPointerException on the emulator created without
        // Google APIs.
        Map<String, UsbDevice> devices;
        try {
            devices = mUsbManager.getDeviceList();
        } catch (NullPointerException e) {
            return false;
        }

        for (UsbDevice device : devices.values()) {
            // A USB device with USB_CLASS_AUDIO and USB_CLASS_COMM interface is
            // considerred as a USB audio device here.
            if (hasUsbAudioCommInterface(device)) {
                if (DEBUG) {
                    logd("USB audio device: " + device.getProductName());
                }
                return true;
            }
        }

        return false;
    }

    /**
     * Registers receiver for the broadcasted intent when a wired headset is
     * plugged in or unplugged. The received intent will have an extra
     * 'state' value where 0 means unplugged, and 1 means plugged.
     */
    private void registerForWiredHeadsetIntentBroadcast() {
        IntentFilter filter = new IntentFilter(Intent.ACTION_HEADSET_PLUG);

        /** Receiver which handles changes in wired headset availability. */
        mWiredHeadsetReceiver =
                new BroadcastReceiver() {
                    private static final int STATE_UNPLUGGED = 0;
                    private static final int STATE_PLUGGED = 1;
                    private static final int HAS_NO_MIC = 0;
                    private static final int HAS_MIC = 1;

                    @Override
                    public void onReceive(Context context, Intent intent) {
                        int state = intent.getIntExtra("state", STATE_UNPLUGGED);
                        if (DEBUG) {
                            int microphone = intent.getIntExtra("microphone", HAS_NO_MIC);
                            String name = intent.getStringExtra("name");
                            logd(
                                    "BroadcastReceiver.onReceive: a="
                                            + intent.getAction()
                                            + ", s="
                                            + state
                                            + ", m="
                                            + microphone
                                            + ", n="
                                            + name
                                            + ", sb="
                                            + isInitialStickyBroadcast());
                        }
                        @ConnectionStatus int histogramValue = ConnectionStatus.DISCONNECTED;
                        switch (state) {
                            case STATE_UNPLUGGED:
                                mDeviceStates.setDeviceExistence(
                                        AudioDeviceSelector.Devices.ID_WIRED_HEADSET, false);
                                histogramValue = ConnectionStatus.DISCONNECTED;
                                break;
                            case STATE_PLUGGED:
                                mDeviceStates.setDeviceExistence(
                                        AudioDeviceSelector.Devices.ID_WIRED_HEADSET, true);
                                histogramValue = ConnectionStatus.CONNECTED;
                                break;
                            default:
                                break;
                        }

                        mDeviceStates.onPotentialDeviceStatusChange();
                        recordConnectionHistogram("Wired", histogramValue);
                    }
                };

        // Note: the intent we register for here is sticky, so it'll tell us
        // immediately what the last action was (plugged or unplugged).
        // It will enable us to set the speakerphone correctly.
        ContextUtils.registerProtectedBroadcastReceiver(
                ContextUtils.getApplicationContext(), mWiredHeadsetReceiver, filter);
    }

    /** Unregister receiver for broadcasted ACTION_HEADSET_PLUG intent. */
    private void unregisterForWiredHeadsetIntentBroadcast() {
        ContextUtils.getApplicationContext().unregisterReceiver(mWiredHeadsetReceiver);
        mWiredHeadsetReceiver = null;
    }

    /**
     * Registers receiver for the broadcasted intent related to BT headset availability or a change
     * in connection state of the local Bluetooth adapter. Example: triggers when the BT device is
     * turned on or off. BLUETOOTH permission is required to receive this one.
     */
    private void registerForBluetoothHeadsetIntentBroadcast() {
        /** Receiver which handles changes in BT headset availability. */
        mBluetoothHeadsetReceiver =
                new BroadcastReceiver() {
                    @Override
                    public void onReceive(Context context, Intent intent) {
                        // A change in connection state of the Headset profile has
                        // been detected, e.g. BT headset has been connected or
                        // disconnected. This broadcast is *not* sticky.
                        int profileState =
                                intent.getIntExtra(
                                        android.bluetooth.BluetoothProfile.EXTRA_STATE,
                                        android.bluetooth.BluetoothProfile.STATE_DISCONNECTED);
                        if (DEBUG) {
                            logd(
                                    "BroadcastReceiver.onReceive: a="
                                            + intent.getAction()
                                            + ", s="
                                            + profileState
                                            + ", sb="
                                            + isInitialStickyBroadcast());
                        }

                        @ConnectionStatus int histogramValue = ConnectionStatus.DISCONNECTED;
                        switch (profileState) {
                            case android.bluetooth.BluetoothProfile.STATE_DISCONNECTED:
                                // We do not have to explicitly call stopBluetoothSco()
                                // since BT SCO will be disconnected automatically when
                                // the BT headset is disabled.

                                // Android supports connecting to 2 BT devices. We might get a
                                // STATE_DISCONNECTED here when either device disconnects. This
                                // could be a potential issue with our Pre-S code, which relies on
                                // the accuracy of `setDeviceExistence()`. In the Post-S path, we
                                // always re-query for existing communication devices, so this
                                // should not be an issue.
                                mDeviceStates.setDeviceExistence(
                                        AudioDeviceSelector.Devices.ID_BLUETOOTH_HEADSET, false);
                                mDeviceStates.onPotentialDeviceStatusChange();

                                histogramValue = ConnectionStatus.DISCONNECTED;
                                break;
                            case android.bluetooth.BluetoothProfile.STATE_CONNECTED:
                                mDeviceStates.setDeviceExistence(
                                        AudioDeviceSelector.Devices.ID_BLUETOOTH_HEADSET, true);
                                mDeviceStates.onPotentialDeviceStatusChange();
                                histogramValue = ConnectionStatus.CONNECTED;
                                break;
                            case android.bluetooth.BluetoothProfile.STATE_CONNECTING:
                                // Bluetooth service is switching from off to on.
                                histogramValue = ConnectionStatus.CONNECTING;
                                break;
                            case android.bluetooth.BluetoothProfile.STATE_DISCONNECTING:
                                // Bluetooth service is switching from on to off.
                                histogramValue = ConnectionStatus.DISCONNECTING;
                                break;
                            default:
                                break;
                        }

                        // Note, disconnection may take more than 15 seconds to detect.
                        recordConnectionHistogram("Bluetooth", histogramValue);
                    }
                };

        IntentFilter filter =
                new IntentFilter(
                        android.bluetooth.BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);

        if (mIsBluetoothLeAudioSupported) {
            // Re-use the same broadcast listener for both "classic" and "LE Audio" BT state
            // changes. Android allows for 2 BT devices to be connected at once, so we could
            // track both profiles separately if needed one day.
            filter.addAction(
                    android.bluetooth.BluetoothLeAudio.ACTION_LE_AUDIO_CONNECTION_STATE_CHANGED);
        }

        ContextUtils.registerProtectedBroadcastReceiver(
                ContextUtils.getApplicationContext(), mBluetoothHeadsetReceiver, filter);
    }

    /**
     * Enumerates the USB interfaces of the given USB device for interface with USB_CLASS_AUDIO
     * class (USB class for audio devices) and USB_CLASS_COMM subclass (USB class for communication
     * devices). Any device that supports these conditions will be considered a USB audio device.
     *
     * @param device USB device to be checked.
     * @return Whether the USB device has such an interface.
     */
    private boolean hasUsbAudioCommInterface(UsbDevice device) {
        for (int i = 0; i < device.getInterfaceCount(); ++i) {
            UsbInterface iface = device.getInterface(i);
            if (iface.getInterfaceClass() == UsbConstants.USB_CLASS_AUDIO
                    && iface.getInterfaceSubclass() == UsbConstants.USB_CLASS_COMM) {
                // There is at least one interface supporting audio communication.
                return true;
            }
        }

        return false;
    }

    /**
     * Registers receiver for the broadcasted intent when a USB device is plugged in or unplugged.
     * Notice: Android supports multiple USB audio devices connected through a USB hub and OS will
     * select the capture device and playback device from them. But plugging them in/out during a
     * call may cause some unexpected result, i.e capturing error or zero capture length.
     */
    private void registerForUsbAudioIntentBroadcast() {
        mUsbAudioReceiver =
                new BroadcastReceiver() {
                    @Override
                    public void onReceive(Context context, Intent intent) {
                        UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
                        if (DEBUG) {
                            logd(
                                    "UsbDeviceBroadcastReceiver.onReceive: a= "
                                            + intent.getAction()
                                            + ", Device: "
                                            + device.toString());
                        }

                        // Not a USB audio device.
                        if (!hasUsbAudioCommInterface(device)) return;

                        @ConnectionStatus int histogramValue = ConnectionStatus.DISCONNECTED;
                        if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) {
                            mDeviceStates.setDeviceExistence(
                                    AudioDeviceSelector.Devices.ID_USB_AUDIO, true);
                            histogramValue = ConnectionStatus.CONNECTED;
                        } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(intent.getAction())
                                && !hasUsbAudio()) {
                            mDeviceStates.setDeviceExistence(
                                    AudioDeviceSelector.Devices.ID_USB_AUDIO, false);
                            histogramValue = ConnectionStatus.DISCONNECTED;
                        }

                        mDeviceStates.onPotentialDeviceStatusChange();
                        // Note, this may also be recorded for headphones plugged in with a
                        // 3.5mm-to-USB adapter.
                        recordConnectionHistogram("USB", histogramValue);
                    }
                };

        IntentFilter filter = new IntentFilter();
        filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
        filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);

        ContextUtils.registerProtectedBroadcastReceiver(
                ContextUtils.getApplicationContext(), mUsbAudioReceiver, filter);
    }

    /** Unregister receiver for broadcasted ACTION_USB_DEVICE_ATTACHED/DETACHED intent. */
    private void unregisterForUsbAudioIntentBroadcast() {
        ContextUtils.getApplicationContext().unregisterReceiver(mUsbAudioReceiver);
        mUsbAudioReceiver = null;
    }

    /** Gets the current earpiece state. */
    private boolean hasEarpiece() {
        return ContextUtils.getApplicationContext()
                .getPackageManager()
                .hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
    }

    private static void recordConnectionHistogram(String name, @ConnectionStatus int value) {
        RecordHistogram.recordEnumeratedHistogram(
                CONNECTION_HISTOGRAM_PREFIX + name, value, ConnectionStatus.MAX_VALUE);
    }

    /** Trivial helper method for debug logging */
    private static void logd(String msg) {
        Log.d(TAG, msg);
    }

    /** Trivial helper method for error logging */
    private static void loge(String msg) {
        Log.e(TAG, msg);
    }

    BroadcastReceiver getWiredHeadsetReceiverForTesting() {
        return mWiredHeadsetReceiver;
    }

    BroadcastReceiver getBluetoothHeadsetReceiverForTesting() {
        return mBluetoothHeadsetReceiver;
    }

    BroadcastReceiver getUsbReceiverForTesting() {
        return mUsbAudioReceiver;
    }
}