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

// Copyright 2013 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.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.audiofx.AcousticEchoCanceler;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.provider.Settings;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils.ThreadChecker;

import java.lang.reflect.Method;
import java.util.Optional;

@JNINamespace("media")
class AudioManagerAndroid {
    private static final String TAG = "media";

    // Set to true to enable debug logs. Avoid in production builds.
    // NOTE: always check in as false.
    private static final boolean DEBUG = false;

    /** Simple container for device information. */
    public static class AudioDeviceName {
        private final int mId;
        private final String mName;

        public AudioDeviceName(int id, String name) {
            mId = id;
            mName = name;
        }

        @CalledByNative("AudioDeviceName")
        private String id() {
            return String.valueOf(mId);
        }

        @CalledByNative("AudioDeviceName")
        private String name() {
            return mName;
        }
    }

    // Use 44.1kHz as the default sampling rate.
    private static final int DEFAULT_SAMPLING_RATE = 44100;
    // Randomly picked up frame size which is close to return value on N4.
    // Return this value when getProperty(PROPERTY_OUTPUT_FRAMES_PER_BUFFER)
    // fails.
    private static final int DEFAULT_FRAME_PER_BUFFER = 256;

    private final AudioManager mAudioManager;
    private final long mNativeAudioManagerAndroid;

    // Enabled during initialization if MODIFY_AUDIO_SETTINGS permission is
    // granted. Required to shift system-wide audio settings.
    private boolean mHasModifyAudioSettingsPermission;

    private boolean mIsInitialized;
    private boolean mSavedIsSpeakerphoneOn;
    private boolean mSavedIsMicrophoneMute;

    // This class should be created, initialized and closed on the audio thread
    // in the audio manager. We use |mThreadChecker| to ensure that this is
    // the case.
    private final ThreadChecker mThreadChecker = new ThreadChecker();

    private final ContentResolver mContentResolver;
    private ContentObserver mSettingsObserver;
    private HandlerThread mSettingsObserverThread;

    private AudioDeviceSelector mAudioDeviceSelector;

    /** Construction */
    @CalledByNative
    private static AudioManagerAndroid createAudioManagerAndroid(long nativeAudioManagerAndroid) {
        return new AudioManagerAndroid(nativeAudioManagerAndroid);
    }

    private AudioManagerAndroid(long nativeAudioManagerAndroid) {
        mNativeAudioManagerAndroid = nativeAudioManagerAndroid;
        mAudioManager =
                (AudioManager)
                        ContextUtils.getApplicationContext()
                                .getSystemService(Context.AUDIO_SERVICE);
        mContentResolver = ContextUtils.getApplicationContext().getContentResolver();

        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
            mAudioDeviceSelector = new AudioDeviceSelectorPreS(mAudioManager);
        } else {
            mAudioDeviceSelector = new AudioDeviceSelectorPostS(mAudioManager);
        }
    }

    /**
     * Saves the initial speakerphone and microphone state.
     * Populates the list of available audio devices and registers receivers for broadcasting
     * intents related to wired headset and Bluetooth devices and USB audio devices.
     */
    @CalledByNative
    private void init() {
        mThreadChecker.assertOnValidThread();
        if (DEBUG) logd("init");
        if (DEBUG) logDeviceInfo();
        if (mIsInitialized) return;

        // Check if process has MODIFY_AUDIO_SETTINGS and RECORD_AUDIO
        // permissions. Both are required for full functionality.
        mHasModifyAudioSettingsPermission =
                hasPermission(android.Manifest.permission.MODIFY_AUDIO_SETTINGS);
        if (DEBUG && !mHasModifyAudioSettingsPermission) {
            logd("MODIFY_AUDIO_SETTINGS permission is missing");
        }

        mAudioDeviceSelector.init();

        mIsInitialized = true;
    }

    /**
     * Unregister all previously registered intent receivers and restore
     * the stored state (stored in {@link #init()}).
     */
    @CalledByNative
    private void close() {
        mThreadChecker.assertOnValidThread();
        if (DEBUG) logd("close");
        if (!mIsInitialized) return;

        stopObservingVolumeChanges();

        mAudioDeviceSelector.close();

        mIsInitialized = false;
    }

    /**
     * Sets audio mode as COMMUNICATION if input parameter is true.
     * Restores audio mode to NORMAL if input parameter is false.
     * Required permission: android.Manifest.permission.MODIFY_AUDIO_SETTINGS.
     */
    @CalledByNative
    private void setCommunicationAudioModeOn(boolean on) {
        mThreadChecker.assertOnValidThread();
        if (DEBUG) logd("setCommunicationAudioModeOn" + on + ")");
        if (!mIsInitialized) return;

        // The MODIFY_AUDIO_SETTINGS permission is required to allow an
        // application to modify global audio settings.
        if (!mHasModifyAudioSettingsPermission) {
            Log.w(
                    TAG,
                    "MODIFY_AUDIO_SETTINGS is missing => client will run "
                            + "with reduced functionality");
            return;
        }

        // TODO(crbug.com/40222537): Should we exit early if we are already in/out of
        // communication mode?
        if (on) {
            // Store microphone mute state and speakerphone state so it can
            // be restored when closing.
            mSavedIsSpeakerphoneOn = mAudioDeviceSelector.isSpeakerphoneOn();
            mSavedIsMicrophoneMute = mAudioManager.isMicrophoneMute();

            mAudioDeviceSelector.setCommunicationAudioModeOn(true);

            // Start observing volume changes to detect when the
            // voice/communication stream volume is at its lowest level.
            // It is only possible to pull down the volume slider to about 20%
            // of the absolute minimum (slider at far left) in communication
            // mode but we want to be able to mute it completely.
            startObservingVolumeChanges();
        } else {
            stopObservingVolumeChanges();

            mAudioDeviceSelector.setCommunicationAudioModeOn(false);

            // Restore previously stored audio states.
            setMicrophoneMute(mSavedIsMicrophoneMute);
            mAudioDeviceSelector.setSpeakerphoneOn(mSavedIsSpeakerphoneOn);
        }

        setCommunicationAudioModeOnInternal(on);
    }

    /**
     * Sets audio mode to MODE_IN_COMMUNICATION if input parameter is true.
     * Restores audio mode to MODE_NORMAL if input parameter is false.
     */
    private void setCommunicationAudioModeOnInternal(boolean on) {
        if (DEBUG) logd("setCommunicationAudioModeOn(" + on + ")");

        if (on) {
            try {
                mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
            } catch (SecurityException e) {
                logDeviceInfo();
                throw e;
            }

        } else {
            // Restore the mode that was used before we switched to
            // communication mode.
            try {
                mAudioManager.setMode(AudioManager.MODE_NORMAL);
            } catch (SecurityException e) {
                logDeviceInfo();
                throw e;
            }
        }
    }

    /**
     * Activates, i.e., starts routing audio to, the specified audio device.
     *
     * @param deviceId Unique device ID (integer converted to string)
     * representing the selected device. This string is empty if the so-called
     * default device is requested.
     * Required permissions: android.Manifest.permission.MODIFY_AUDIO_SETTINGS
     * and android.Manifest.permission.RECORD_AUDIO.
     */
    @CalledByNative
    private boolean setDevice(String deviceId) {
        if (DEBUG) logd("setDevice: " + deviceId);
        if (!mIsInitialized) return false;

        boolean hasRecordAudioPermission = hasPermission(android.Manifest.permission.RECORD_AUDIO);
        if (!mHasModifyAudioSettingsPermission || !hasRecordAudioPermission) {
            Log.w(
                    TAG,
                    "Requires MODIFY_AUDIO_SETTINGS and RECORD_AUDIO. "
                            + "Selected device will not be available for recording");
            return false;
        }

        return mAudioDeviceSelector.selectDevice(deviceId);
    }

    /**
     * @return the current list of available audio devices.
     * Note that this call does not trigger any update of the list of devices,
     * it only copies the current state in to the output array.
     * Required permissions: android.Manifest.permission.MODIFY_AUDIO_SETTINGS
     * and android.Manifest.permission.RECORD_AUDIO.
     */
    @CalledByNative
    private AudioDeviceName[] getAudioInputDeviceNames() {
        if (DEBUG) logd("getAudioInputDeviceNames");
        if (!mIsInitialized) return null;

        boolean hasRecordAudioPermission = hasPermission(android.Manifest.permission.RECORD_AUDIO);
        if (!mHasModifyAudioSettingsPermission || !hasRecordAudioPermission) {
            Log.w(
                    TAG,
                    "Requires MODIFY_AUDIO_SETTINGS and RECORD_AUDIO. "
                            + "No audio device will be available for recording");
            return null;
        }

        return mAudioDeviceSelector.getAudioInputDeviceNames();
    }

    @CalledByNative
    private int getNativeOutputSampleRate() {
        String sampleRateString =
                mAudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
        return sampleRateString == null
                ? DEFAULT_SAMPLING_RATE
                : Integer.parseInt(sampleRateString);
    }

    /**
     * Returns the minimum frame size required for audio input.
     *
     * @param sampleRate sampling rate
     * @param channels number of channels
     */
    @CalledByNative
    private static int getMinInputFrameSize(int sampleRate, int channels) {
        int channelConfig;
        if (channels == 1) {
            channelConfig = AudioFormat.CHANNEL_IN_MONO;
        } else if (channels == 2) {
            channelConfig = AudioFormat.CHANNEL_IN_STEREO;
        } else {
            return -1;
        }
        return AudioRecord.getMinBufferSize(
                        sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT)
                / 2
                / channels;
    }

    /**
     * Returns the minimum frame size required for audio output.
     *
     * @param sampleRate sampling rate
     * @param channels number of channels
     */
    @CalledByNative
    private static int getMinOutputFrameSize(int sampleRate, int channels) {
        int channelConfig;
        if (channels == 1) {
            channelConfig = AudioFormat.CHANNEL_OUT_MONO;
        } else if (channels == 2) {
            channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
        } else {
            return -1;
        }
        return AudioTrack.getMinBufferSize(
                        sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT)
                / 2
                / channels;
    }

    @CalledByNative
    private boolean isAudioLowLatencySupported() {
        return ContextUtils.getApplicationContext()
                .getPackageManager()
                .hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY);
    }

    @CalledByNative
    private int getAudioLowLatencyOutputFrameSize() {
        String framesPerBuffer =
                mAudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER);
        return framesPerBuffer == null
                ? DEFAULT_FRAME_PER_BUFFER
                : Integer.parseInt(framesPerBuffer);
    }

    @CalledByNative
    private static boolean acousticEchoCancelerIsAvailable() {
        return AcousticEchoCanceler.isAvailable();
    }

    // Used for reflection of hidden method getOutputLatency.  Will be `null` before reflection, and
    // a (possibly empty) Optional after.
    private static Optional<Method> sGetOutputLatency;

    // Reflect |methodName(int)|, and return it.
    private static final Method reflectMethod(String methodName) {
        try {
            return AudioManager.class.getMethod(methodName, int.class);
        } catch (NoSuchMethodException e) {
            return null;
        }
    }

    // Return the output latency, as reported by AudioManager.  Do not use this,
    // since it is (a) a hidden API call, and (b) documented as being
    // unreliable.  It's here only to adjust for some hardware devices that do
    // not handle latency properly otherwise.
    // See b/80326798 for more information.
    @CalledByNative
    private int getOutputLatency() {
        mThreadChecker.assertOnValidThread();

        if (sGetOutputLatency == null) {
            // It's okay if this assigns `null`; we won't call it, but we also won't try again to
            // reflect it.
            sGetOutputLatency = Optional.ofNullable(reflectMethod("getOutputLatency"));
        }

        int result = 0;
        if (sGetOutputLatency.isPresent()) {
            try {
                result =
                        (Integer)
                                sGetOutputLatency
                                        .get()
                                        .invoke(mAudioManager, AudioManager.STREAM_MUSIC);
            } catch (Exception e) {
                // Ignore.
            }
        }

        return result;
    }

    /** Sets the microphone mute state. */
    private void setMicrophoneMute(boolean on) {
        boolean wasMuted = mAudioManager.isMicrophoneMute();
        if (wasMuted == on) {
            return;
        }
        mAudioManager.setMicrophoneMute(on);
    }

    /** Gets  the current microphone mute state. */
    private boolean isMicrophoneMute() {
        return mAudioManager.isMicrophoneMute();
    }

    /** Checks if the process has as specified permission or not. */
    private boolean hasPermission(String permission) {
        return ContextUtils.getApplicationContext().checkSelfPermission(permission)
                == PackageManager.PERMISSION_GRANTED;
    }

    /** Information about the current build, taken from system properties. */
    private void logDeviceInfo() {
        logd(
                "Android SDK: "
                        + Build.VERSION.SDK_INT
                        + ", "
                        + "Release: "
                        + Build.VERSION.RELEASE
                        + ", "
                        + "Brand: "
                        + Build.BRAND
                        + ", "
                        + "Device: "
                        + Build.DEVICE
                        + ", "
                        + "Id: "
                        + Build.ID
                        + ", "
                        + "Hardware: "
                        + Build.HARDWARE
                        + ", "
                        + "Manufacturer: "
                        + Build.MANUFACTURER
                        + ", "
                        + "Model: "
                        + Build.MODEL
                        + ", "
                        + "Product: "
                        + Build.PRODUCT);
    }

    /** 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);
    }

    /** Start thread which observes volume changes on the voice stream. */
    private void startObservingVolumeChanges() {
        if (DEBUG) logd("startObservingVolumeChanges");
        if (mSettingsObserverThread != null) return;
        mSettingsObserverThread = new HandlerThread("SettingsObserver");
        mSettingsObserverThread.start();

        mSettingsObserver =
                new ContentObserver(new Handler(mSettingsObserverThread.getLooper())) {
                    @Override
                    public void onChange(boolean selfChange) {
                        if (DEBUG) logd("SettingsObserver.onChange: " + selfChange);
                        super.onChange(selfChange);

                        // Get stream volume for the voice stream and deliver callback if
                        // the volume index is zero. It is not possible to move the volume
                        // slider all the way down in communication mode but the callback
                        // implementation can ensure that the volume is completely muted.
                        int volume = mAudioManager.getStreamVolume(AudioManager.STREAM_VOICE_CALL);
                        if (DEBUG) logd("AudioManagerAndroidJni.get().setMute: " + (volume == 0));
                        AudioManagerAndroidJni.get()
                                .setMute(
                                        mNativeAudioManagerAndroid,
                                        AudioManagerAndroid.this,
                                        (volume == 0));
                    }
                };

        mContentResolver.registerContentObserver(
                Settings.System.CONTENT_URI, true, mSettingsObserver);
    }

    /** Quit observer thread and stop listening for volume changes. */
    private void stopObservingVolumeChanges() {
        if (DEBUG) logd("stopObservingVolumeChanges");
        if (mSettingsObserverThread == null) return;

        mContentResolver.unregisterContentObserver(mSettingsObserver);
        mSettingsObserver = null;

        mSettingsObserverThread.quit();
        try {
            mSettingsObserverThread.join();
        } catch (InterruptedException e) {
            Log.e(TAG, "Thread.join() exception: ", e);
        }
        mSettingsObserverThread = null;
    }

    /** Return the AudioDeviceInfo array as reported by the Android OS. */
    private static AudioDeviceInfo[] getAudioDeviceInfo() {
        AudioManager audioManager =
                (AudioManager)
                        ContextUtils.getApplicationContext()
                                .getSystemService(Context.AUDIO_SERVICE);
        return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
    }

    /** Returns whether an audio sink device is connected. */
    @CalledByNative
    private static boolean isAudioSinkConnected() {
        for (AudioDeviceInfo deviceInfo : getAudioDeviceInfo()) {
            if (deviceInfo.isSink()) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns a bit mask of Audio Formats (C++ AudioParameters::Format enum)
     * supported by all of the sink devices.
     */
    @CalledByNative
    private static int getAudioEncodingFormatsSupported() {
        int intersection_mask = 0; // intersection of multiple device encoding arrays
        boolean first = true;
        for (AudioDeviceInfo deviceInfo : getAudioDeviceInfo()) {
            int[] encodings = deviceInfo.getEncodings();
            if (deviceInfo.isSink() && deviceInfo.getType() == AudioDeviceInfo.TYPE_HDMI) {
                int mask = 0; // bit mask for a single device

                // Map AudioFormat values to C++ media/base/audio_parameters.h Format enum
                for (int i : encodings) {
                    switch (i) {
                        case AudioFormat.ENCODING_PCM_16BIT:
                            mask |= AudioEncodingFormat.PCM_LINEAR;
                            break;
                        case AudioFormat.ENCODING_AC3:
                            mask |= AudioEncodingFormat.BITSTREAM_AC3;
                            break;
                        case AudioFormat.ENCODING_E_AC3:
                            mask |= AudioEncodingFormat.BITSTREAM_EAC3;
                            break;
                        case AudioFormat.ENCODING_DTS:
                            mask |= AudioEncodingFormat.BITSTREAM_DTS;
                            break;
                        case AudioFormat.ENCODING_DTS_HD:
                            mask |= AudioEncodingFormat.BITSTREAM_DTS_HD;
                            break;
                        case AudioFormat.ENCODING_IEC61937:
                            mask |= AudioEncodingFormat.BITSTREAM_IEC61937;
                            break;
                    }
                }

                // Require all devices to support a format
                if (first) {
                    first = false;
                    intersection_mask = mask;
                } else {
                    intersection_mask &= mask;
                }
            }
        }
        return intersection_mask;
    }

    @NativeMethods
    interface Natives {
        void setMute(long nativeAudioManagerAndroid, AudioManagerAndroid caller, boolean muted);
    }
}