Update input device selection APIs for Android S

This CL avoids using startBluetoothSco()/stopBluetoothSco() and
setSpeakerphoneOn()/isSpeakerPhoneOn() on Android S. These calls are
instead replaced by getAvailableCommunicationDevices() and
setCommunicationDevices().

This CL also checks for the BLUETOOTH_CONNECT permission, instead of
the pre-S BLUETOOTH permission.

Note: We only check for permissions without prompting for the moment.
A future CL will prompts users for "nearby devices" permission. In the
mean time, users can manually give "nearby devices" permissions to
Chrome to circumvent the attached issue.

Note: There are still issues with switching to a BT headset after a
page load, or after the device disconnects. Refreshing the page, or
refreshing + reconnecting the device normally fixes the issue.

Bug: 1317548
Change-Id: I94920aa81b7c79524099dd6d580e1e6f7bd334b6
Binary-Size: Replacing deprecated methods on Android S+
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3803424
Reviewed-by: Dale Curtis <dalecurtis@chromium.org>
Commit-Queue: Thomas Guilbert <tguilbert@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1031251}
diff --git a/media/base/android/BUILD.gn b/media/base/android/BUILD.gn
index e2d9961b..7d45fe6f 100644
--- a/media/base/android/BUILD.gn
+++ b/media/base/android/BUILD.gn
@@ -179,6 +179,7 @@
     sources = [
       "java/src/org/chromium/media/AudioDeviceListener.java",
       "java/src/org/chromium/media/AudioDeviceSelector.java",
+      "java/src/org/chromium/media/AudioDeviceSelectorPostS.java",
       "java/src/org/chromium/media/AudioDeviceSelectorPreS.java",
       "java/src/org/chromium/media/AudioManagerAndroid.java",
       "java/src/org/chromium/media/AudioTrackOutputStream.java",
diff --git a/media/base/android/java/src/org/chromium/media/AudioDeviceSelector.java b/media/base/android/java/src/org/chromium/media/AudioDeviceSelector.java
index 2c4f57b..84616da 100644
--- a/media/base/android/java/src/org/chromium/media/AudioDeviceSelector.java
+++ b/media/base/android/java/src/org/chromium/media/AudioDeviceSelector.java
@@ -64,6 +64,12 @@
      */
     protected abstract void setAudioDevice(int device);
 
+    public abstract boolean[] getAvailableDevices_Locked();
+
+    public void setDeviceExistence_Locked(int deviceId, boolean exists) {
+        // Overridden by AudioDeviceSelectorPreS.
+    }
+
     public AudioManagerAndroid.AudioDeviceName[] getAudioInputDeviceNames() {
         return mDeviceStates.getAudioInputDeviceNames();
     }
@@ -78,6 +84,8 @@
 
         int nextDevice = mDeviceStates.setRequestedDeviceIdAndGetNextId(deviceId);
 
+        if (DEBUG) logd("selectDevice: id=" + DeviceHelpers.getDeviceName(nextDevice));
+
         // `deviceId` is invalid, or its corresponding device is not available.
         if (nextDevice == Devices.ID_INVALID) return false;
 
@@ -93,6 +101,8 @@
     protected void maybeUpdateSelectedDevice() {
         int nextDevice = mDeviceStates.getNextDeviceIfRequested();
 
+        if (DEBUG) logd("maybeUpdateSelectedDevice: id=" + DeviceHelpers.getDeviceName(nextDevice));
+
         // No device was explicitly requested.
         if (nextDevice == Devices.ID_INVALID) return;
 
@@ -100,7 +110,7 @@
     }
 
     // Collection of static helpers.
-    private static class DeviceHelpers {
+    protected static class DeviceHelpers {
         // Maps audio device types to string values. This map must be in sync
         // with the Devices.ID_* below.
         // TODO(henrika): add support for proper detection of device names and
@@ -117,6 +127,14 @@
         private static final int ID_VALID_LOWER_BOUND = Devices.ID_SPEAKERPHONE;
         private static final int ID_VALID_UPPER_BOUND = Devices.ID_USB_AUDIO;
 
+        public static String getDeviceName(int deviceId) {
+            if (deviceId == Devices.ID_INVALID) return "invalid-ID";
+
+            if (deviceId == Devices.ID_DEFAULT) return "default-device";
+
+            return DEVICE_NAMES[deviceId];
+        }
+
         /**
          * Use a special selection scheme if the default device is selected.
          * The "most unique" device will be selected; Wired headset first, then USB
@@ -170,14 +188,12 @@
         public static final int ID_EARPIECE = 2;
         public static final int ID_BLUETOOTH_HEADSET = 3;
         public static final int ID_USB_AUDIO = 4;
-        private static final int DEVICE_COUNT = 5;
+        public static final int DEVICE_COUNT = 5;
 
         private Object mLock = new Object();
 
         private int mRequestedAudioDevice = ID_INVALID;
 
-        private boolean[] mDeviceExistence = new boolean[DEVICE_COUNT];
-
         /**
          * Sets the whether a device exists.
          *
@@ -187,8 +203,10 @@
         public void setDeviceExistence(int deviceId, boolean exists) {
             if (!DeviceHelpers.isDeviceValid(deviceId)) return;
 
+            if (DEBUG) logd("Setting [" + DeviceHelpers.getDeviceName(deviceId) + "]=" + exists);
+
             synchronized (mLock) {
-                mDeviceExistence[deviceId] = exists;
+                setDeviceExistence_Locked(deviceId, exists);
             }
         }
 
@@ -243,26 +261,6 @@
         }
 
         /**
-         * Computes the list of available devices based off of exiting devices.
-         * We consider the availability of wired headsets, USB audio and earpices to be
-         * mutually exclusive.
-         */
-        private boolean[] getAvailableDevices_Locked() {
-            boolean[] availableDevices = mDeviceExistence.clone();
-
-            // Wired headset, USB audio and earpiece are mutually exclusive, and
-            // prioritized in that order.
-            if (availableDevices[ID_WIRED_HEADSET]) {
-                availableDevices[ID_USB_AUDIO] = false;
-                availableDevices[ID_EARPIECE] = false;
-            } else if (availableDevices[ID_USB_AUDIO]) {
-                availableDevices[ID_EARPIECE] = false;
-            }
-
-            return availableDevices;
-        }
-
-        /**
          * Returns the list of currently available devices, to be used by the native side.
          */
         public AudioManagerAndroid.AudioDeviceName[] getAudioInputDeviceNames() {
@@ -271,7 +269,9 @@
                 devices = getAvailableDevices_Locked();
             }
             List<String> list = new ArrayList<String>();
+
             int activeDeviceCount = DeviceHelpers.getActiveDeviceCount(devices);
+
             AudioManagerAndroid.AudioDeviceName[] array =
                     new AudioManagerAndroid.AudioDeviceName[activeDeviceCount];
 
@@ -289,6 +289,7 @@
         }
 
         public void clearRequestedDevice() {
+            if (DEBUG) logd("Clearing requested device");
             synchronized (mLock) {
                 mRequestedAudioDevice = ID_INVALID;
             }
diff --git a/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPostS.java b/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPostS.java
new file mode 100644
index 0000000..0831947b
--- /dev/null
+++ b/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPostS.java
@@ -0,0 +1,168 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// 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.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+
+import org.chromium.base.Log;
+import org.chromium.base.compat.ApiHelperForS;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RequiresApi(Build.VERSION_CODES.S)
+class AudioDeviceSelectorPostS extends AudioDeviceSelector {
+    private static final String TAG = "media";
+
+    private boolean mHasBluetoothConnectPermission;
+
+    public AudioDeviceSelectorPostS(AudioManager audioManager) {
+        super(audioManager);
+    }
+
+    private static List<Integer> getTargetTypesFromId(int deviceId) {
+        List<Integer> types = new ArrayList<Integer>();
+
+        switch (deviceId) {
+            case Devices.ID_SPEAKERPHONE:
+                types.add(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+                break;
+            case Devices.ID_WIRED_HEADSET:
+                types.add(AudioDeviceInfo.TYPE_WIRED_HEADSET);
+                types.add(AudioDeviceInfo.TYPE_WIRED_HEADPHONES);
+                break;
+            case Devices.ID_EARPIECE:
+                types.add(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE);
+                break;
+            case Devices.ID_BLUETOOTH_HEADSET:
+                types.add(AudioDeviceInfo.TYPE_BLUETOOTH_SCO);
+                types.add(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
+                break;
+            case Devices.ID_USB_AUDIO:
+                types.add(AudioDeviceInfo.TYPE_USB_HEADSET);
+                types.add(AudioDeviceInfo.TYPE_USB_DEVICE);
+                break;
+        }
+
+        return types;
+    }
+
+    @Override
+    public void init() {
+        mHasBluetoothConnectPermission = ApiHelperForS.hasBluetoothConnectPermission();
+
+        if (!mHasBluetoothConnectPermission) {
+            Log.w(TAG, "BLUETOOTH_CONNECT permission is missing.");
+        }
+
+        mDeviceListener.init(mHasBluetoothConnectPermission);
+    }
+
+    @Override
+    public void close() {
+        mDeviceListener.close();
+    }
+
+    @Override
+    public void setCommunicationAudioModeOn(boolean on) {
+        if (on) {
+            // TODO(crbug.com/1317548): Prompt for BLUETOOTH_CONNECT permission at this point if we
+            // don't have it.
+        } else {
+            mDeviceStates.clearRequestedDevice();
+            mAudioManager.clearCommunicationDevice();
+        }
+    }
+
+    @Override
+    public boolean isSpeakerphoneOn() {
+        AudioDeviceInfo currentDevice = mAudioManager.getCommunicationDevice();
+        return currentDevice != null
+                && currentDevice.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER;
+    }
+
+    @Override
+    public void setSpeakerphoneOn(boolean on) {
+        boolean isCurrentlyOn = isSpeakerphoneOn();
+
+        if (isCurrentlyOn == on) return;
+
+        if (on) {
+            setAudioDevice(Devices.ID_SPEAKERPHONE);
+        } else {
+            // Turn speakerphone OFF.
+            mAudioManager.clearCommunicationDevice();
+            maybeUpdateSelectedDevice();
+        }
+    }
+
+    @Override
+    public boolean[] getAvailableDevices_Locked() {
+        List<AudioDeviceInfo> communicationDevices =
+                mAudioManager.getAvailableCommunicationDevices();
+
+        boolean[] availableDevices = new boolean[Devices.DEVICE_COUNT];
+
+        for (AudioDeviceInfo device : communicationDevices) {
+            switch (device.getType()) {
+                case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
+                    availableDevices[Devices.ID_SPEAKERPHONE] = true;
+                    break;
+
+                case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
+                case AudioDeviceInfo.TYPE_WIRED_HEADSET:
+                    availableDevices[Devices.ID_WIRED_HEADSET] = true;
+                    break;
+
+                case AudioDeviceInfo.TYPE_USB_DEVICE:
+                case AudioDeviceInfo.TYPE_USB_HEADSET:
+                    availableDevices[Devices.ID_USB_AUDIO] = true;
+                    break;
+
+                case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
+                case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
+                    availableDevices[Devices.ID_BLUETOOTH_HEADSET] = true;
+                    break;
+
+                case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
+                    availableDevices[Devices.ID_EARPIECE] = true;
+                    break;
+            }
+        }
+
+        return availableDevices;
+    }
+
+    public AudioDeviceInfo getMatchingCommunicationDevice(List<Integer> targetTypes) {
+        List<AudioDeviceInfo> availableDevices = mAudioManager.getAvailableCommunicationDevices();
+
+        for (AudioDeviceInfo device : availableDevices) {
+            if (targetTypes.contains(device.getType())) return device;
+        }
+
+        return null;
+    }
+
+    @Override
+    protected void setAudioDevice(int deviceId) {
+        if (!DeviceHelpers.isDeviceValid(deviceId)) return;
+
+        AudioDeviceInfo targetDevice =
+                getMatchingCommunicationDevice(getTargetTypesFromId(deviceId));
+
+        if (targetDevice != null) {
+            boolean result = mAudioManager.setCommunicationDevice(targetDevice);
+            if (!result) {
+                loge("Error setting communication device");
+            }
+        } else {
+            loge("Couldn't find available device for: " + DeviceHelpers.getDeviceName(deviceId));
+        }
+    }
+}
\ No newline at end of file
diff --git a/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPreS.java b/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPreS.java
index 527da8b..87228b0 100644
--- a/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPreS.java
+++ b/media/base/android/java/src/org/chromium/media/AudioDeviceSelectorPreS.java
@@ -10,10 +10,8 @@
 import android.content.IntentFilter;
 import android.content.pm.PackageManager;
 import android.media.AudioManager;
-import android.os.Build;
 
 import org.chromium.base.ContextUtils;
-import org.chromium.base.compat.ApiHelperForS;
 
 class AudioDeviceSelectorPreS extends AudioDeviceSelector {
     private static final String TAG = "media";
@@ -33,6 +31,8 @@
 
     private boolean mHasBluetoothPermission;
 
+    private boolean[] mDeviceExistence = new boolean[Devices.DEVICE_COUNT];
+
     public AudioDeviceSelectorPreS(AudioManager audioManager) {
         super(audioManager);
     }
@@ -45,12 +45,6 @@
     public void init() {
         mHasBluetoothPermission = hasPermission(android.Manifest.permission.BLUETOOTH);
 
-        // TODO(crbug.com/1317548): Remove this check once there is an AudioDeviceSelector
-        // for S and above.
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            mHasBluetoothPermission &= ApiHelperForS.hasBluetoothConnectPermission();
-        }
-
         mDeviceListener.init(mHasBluetoothPermission);
 
         if (mHasBluetoothPermission) registerForBluetoothScoIntentBroadcast();
@@ -84,6 +78,27 @@
         mAudioManager.setSpeakerphoneOn(on);
     }
 
+    @Override
+    public boolean[] getAvailableDevices_Locked() {
+        boolean[] availableDevices = mDeviceExistence.clone();
+
+        // Wired headset, USB audio and earpiece are mutually exclusive, and
+        // prioritized in that order.
+        if (availableDevices[Devices.ID_WIRED_HEADSET]) {
+            availableDevices[Devices.ID_USB_AUDIO] = false;
+            availableDevices[Devices.ID_EARPIECE] = false;
+        } else if (availableDevices[Devices.ID_USB_AUDIO]) {
+            availableDevices[Devices.ID_EARPIECE] = false;
+        }
+
+        return availableDevices;
+    }
+
+    @Override
+    public void setDeviceExistence_Locked(int deviceId, boolean exists) {
+        mDeviceExistence[deviceId] = exists;
+    }
+
     /** Checks if the process has as specified permission or not. */
     private boolean hasPermission(String permission) {
         return ContextUtils.getApplicationContext().checkSelfPermission(permission)
@@ -125,7 +140,7 @@
                         // do nothing
                         break;
                     default:
-                        loge("Invalid state");
+                        break;
                 }
             }
         };
diff --git a/media/base/android/java/src/org/chromium/media/AudioManagerAndroid.java b/media/base/android/java/src/org/chromium/media/AudioManagerAndroid.java
index 2056357b..bf772e2 100644
--- a/media/base/android/java/src/org/chromium/media/AudioManagerAndroid.java
+++ b/media/base/android/java/src/org/chromium/media/AudioManagerAndroid.java
@@ -98,9 +98,11 @@
                 Context.AUDIO_SERVICE);
         mContentResolver = ContextUtils.getApplicationContext().getContentResolver();
 
-        // TODO(crbug.com/1317548): For now, we also use AudioDeviceSelectorPreS on Android S and
-        // above. Fix this by adding an S and above implementation.
-        mAudioDeviceSelector = new AudioDeviceSelectorPreS(mAudioManager);
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+            mAudioDeviceSelector = new AudioDeviceSelectorPreS(mAudioManager);
+        } else {
+            mAudioDeviceSelector = new AudioDeviceSelectorPostS(mAudioManager);
+        }
     }
 
     /**